Playlists: stitch a batch's owned+downloaded paths into the materializer
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.
This commit is contained in:
parent
3a6cb8cda5
commit
bef73d855d
2 changed files with 180 additions and 0 deletions
77
core/playlists/materialize_service.py
Normal file
77
core/playlists/materialize_service.py
Normal file
|
|
@ -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"]
|
||||
103
tests/test_playlist_materialize_service.py
Normal file
103
tests/test_playlist_materialize_service.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue