From bef73d855d5a0336b60fa2fa8cd5eb4d1b4b6195 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 12 Jun 2026 13:32:53 -0700 Subject: [PATCH] Playlists: stitch a batch's owned+downloaded paths into the materializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit materialize_playlist_from_batch(batch, download_tasks, config) collects the real on-disk path of every resolved track from the batch's OWN payload — owned via analysis_results.matched_file_path, downloaded via tasks.final_file_path — runs each through the playback path resolver (Docker-correct), de-dupes, and hands the list to rebuild_playlist_folder. Gated on playlist_folder_mode. No re-matching, no source IDs, no mirrored-playlist lookup — works for any organize-by-playlist download including the all-owned case. 5 tests. Still isolated; the triggers wire it in next. --- core/playlists/materialize_service.py | 77 +++++++++++++++ tests/test_playlist_materialize_service.py | 103 +++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 core/playlists/materialize_service.py create mode 100644 tests/test_playlist_materialize_service.py diff --git a/core/playlists/materialize_service.py b/core/playlists/materialize_service.py new file mode 100644 index 00000000..0f6cc891 --- /dev/null +++ b/core/playlists/materialize_service.py @@ -0,0 +1,77 @@ +"""Build a playlist's materialized folder from a FINISHED organize-by-playlist +download batch. + +The batch already carries the real on-disk location of every resolved track: + + - **owned** tracks → ``analysis_results[*]['matched_file_path']`` (the library + matcher's result, captured during analysis), and + - **downloaded** tracks → ``download_tasks[tid]['final_file_path']`` (where the + import landed). + +So this is pure stitching + filesystem work: **no re-matching, no source-ID +lookup, no mirrored-playlist resolution**. It works for any organize-by-playlist +download, mirrored or not, and for the all-owned case (where nothing downloads). + +Each stored path is run through ``resolve_library_file_path`` — the same resolver +playback uses — so a DB path that's host-formatted (Docker) maps to the real file +the container can see; a freshly-downloaded path already exists and passes +through unchanged. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +from core.imports.paths import docker_resolve_path +from core.playlists.materialize import ( + RebuildSummary, + normalize_mode, + rebuild_playlist_folder, +) + + +def collect_batch_real_paths(batch: dict, download_tasks: dict, *, config_manager) -> List[str]: + """Real on-disk paths of every track in a finished batch that resolved — + owned (from analysis) + downloaded (from completed tasks) — resolved to this + process's filesystem and de-duplicated, in playlist order then download order.""" + from core.library.path_resolver import resolve_library_file_path + + out: List[str] = [] + seen = set() + + def _add(stored_path: Any) -> None: + if not stored_path: + return + real = resolve_library_file_path(str(stored_path), config_manager=config_manager) + if real and real not in seen: + seen.add(real) + out.append(real) + + for res in (batch.get("analysis_results") or []): + if res.get("found"): + _add(res.get("matched_file_path")) + + for tid in (batch.get("queue") or []): + task = (download_tasks or {}).get(tid) or {} + if task.get("status") == "completed": + _add(task.get("final_file_path")) + + return out + + +def materialize_playlist_from_batch(batch: dict, download_tasks: dict, config_manager) -> Optional[RebuildSummary]: + """(Re)build the playlist folder for a finished organize-by-playlist batch. + + Returns the :class:`RebuildSummary`, or ``None`` when the batch isn't an + organize-by-playlist batch. Reads ``playlists.materialize_path`` / + ``playlists.materialize_mode`` from config.""" + if not batch or not batch.get("playlist_folder_mode"): + return None + name = batch.get("playlist_name") or "Unknown Playlist" + real_paths = collect_batch_real_paths(batch, download_tasks, config_manager=config_manager) + root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists")) + mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink")) + return rebuild_playlist_folder(root, name, real_paths, mode) + + +__all__ = ["collect_batch_real_paths", "materialize_playlist_from_batch"] diff --git a/tests/test_playlist_materialize_service.py b/tests/test_playlist_materialize_service.py new file mode 100644 index 00000000..e61d999a --- /dev/null +++ b/tests/test_playlist_materialize_service.py @@ -0,0 +1,103 @@ +"""Playlist materialize SERVICE — builds the folder from a finished batch's own +payload (owned matched_file_path + downloaded final_file_path). Locks down: it +stitches owned + downloaded, ignores not-found/not-completed, de-dupes, gates on +the organize flag, and never depends on source IDs or a mirrored playlist.""" + +from __future__ import annotations + +from pathlib import Path + +from core.playlists.materialize_service import ( + collect_batch_real_paths, + materialize_playlist_from_batch, +) + + +class _Cfg: + def __init__(self, root, mode="symlink"): + self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode} + + def get(self, key, default=None): + return self._d.get(key, default) + + +def _mk(tmp_path: Path, *names) -> list[str]: + paths = [] + for n in names: + f = tmp_path / "Music" / n + f.parent.mkdir(parents=True, exist_ok=True) + f.write_bytes(b"audio") + paths.append(str(f)) + return paths + + +def test_collect_stitches_owned_and_downloaded(tmp_path: Path): + owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3") + batch = { + "analysis_results": [ + {"found": True, "matched_file_path": owned[0]}, + {"found": False, "matched_file_path": None}, # not owned → skip + ], + "queue": ["t1", "t2"], + } + tasks = { + "t1": {"status": "completed", "final_file_path": downloaded[0]}, + "t2": {"status": "failed", "final_file_path": None}, # not completed → skip + } + cfg = _Cfg(str(tmp_path / "Playlists")) + paths = collect_batch_real_paths(batch, tasks, config_manager=cfg) + assert paths == [owned[0], downloaded[0]] # owned first, then downloaded + + +def test_collect_dedupes(tmp_path: Path): + owned = _mk(tmp_path, "Same.mp3") + batch = { + "analysis_results": [{"found": True, "matched_file_path": owned[0]}], + "queue": ["t1"], + } + tasks = {"t1": {"status": "completed", "final_file_path": owned[0]}} # same file + cfg = _Cfg(str(tmp_path / "Playlists")) + assert collect_batch_real_paths(batch, tasks, config_manager=cfg) == [owned[0]] + + +def test_materialize_from_batch_all_owned(tmp_path: Path): + """The all-owned case (no downloads) — folder built entirely from analysis.""" + owned = _mk(tmp_path, "A.mp3", "B.mp3") + batch = { + "playlist_folder_mode": True, + "playlist_name": "Smack That", + "analysis_results": [ + {"found": True, "matched_file_path": owned[0]}, + {"found": True, "matched_file_path": owned[1]}, + ], + "queue": [], + } + cfg = _Cfg(str(tmp_path / "Playlists")) + summary = materialize_playlist_from_batch(batch, {}, cfg) + assert summary is not None and summary.linked == 2 + pdir = Path(summary.playlist_dir) + assert pdir == tmp_path / "Playlists" / "Smack That" + assert (pdir / "A.mp3").resolve() == Path(owned[0]).resolve() + assert (pdir / "B.mp3").resolve() == Path(owned[1]).resolve() + + +def test_materialize_from_batch_owned_plus_downloaded(tmp_path: Path): + owned, downloaded = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3") + batch = { + "playlist_folder_mode": True, + "playlist_name": "Mix", + "analysis_results": [{"found": True, "matched_file_path": owned[0]}], + "queue": ["t1"], + } + tasks = {"t1": {"status": "completed", "final_file_path": downloaded[0]}} + cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy") + summary = materialize_playlist_from_batch(batch, tasks, cfg) + assert summary.copied == 2 + pdir = Path(summary.playlist_dir) + assert (pdir / "Owned.mp3").is_file() and (pdir / "Fresh.mp3").is_file() + + +def test_materialize_skipped_when_not_organize(tmp_path: Path): + batch = {"playlist_folder_mode": False, "playlist_name": "X", "analysis_results": [], "queue": []} + assert materialize_playlist_from_batch(batch, {}, _Cfg(str(tmp_path / "Playlists"))) is None + assert not (tmp_path / "Playlists").exists()