soulsync/tests/test_playlist_materialize_service.py
BoulderBadgeDad 9aaafaf341 Organize-by-playlist: optional custom file naming for the playlist folder
Files inside an Organize-by-Playlist folder were stuck with the library filename
(materializer hardcoded os.path.basename) — users wanted control over the naming,
e.g. a playlist-order prefix so a DAP plays them in order.

Add an opt-in FILENAME template "Playlist File Naming" (file_organization.templates
.playlist_item), tokens $position/$artist/$album/$track/$title. It is a filename,
not a path: validated to forbid "/" or "\" and to require $title, both in the
Settings UI (blocks save with a reason) and in core/playlists/item_naming.py, which
also fails safe at apply time — a bad/empty template falls back to the library
filename, so it can never produce a broken name. Default empty = current behavior.

Works for symlink AND copy modes (a symlink name is independent of its target).
Applied in _rebuild_one_from_db (the live reconcile/rebuild path), which has the
per-track metadata + playlist order; the pure FS materializer just gained an
optional dest_names override and is otherwise untouched. $position is zero-padded
to playlist width for correct sorting.

Tests: pure validate/render (slash + missing-title rejected, fallback, sanitize,
no-separator guarantee), FS-layer dest_names + collision disambiguation + back-
compat, and end-to-end through the DB rebuild (07->01 rename + empty-template
keeps library filename).
2026-06-21 21:57:20 -07:00

299 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
rebuild_mirrored_playlist_if_organized,
rebuild_organized_playlists_from_db,
reconcile_batch_playlists,
)
class _Track:
"""Mimics database.DatabaseTrack — what check_track_exists returns."""
def __init__(self, file_path):
self.file_path = file_path
class _RebuildDB:
"""One organized playlist (Mix) + one not (Off); check_track_exists matches
by NAME via `owned` (track_name -> file_path), so no source IDs involved."""
def __init__(self, owned):
self.owned = owned
def get_mirrored_playlists(self, profile_id=1):
return [
{"id": 1, "name": "Mix", "source_playlist_id": "PL1", "organize_by_playlist": True},
{"id": 2, "name": "Off", "source_playlist_id": "PL2", "organize_by_playlist": False},
]
def get_mirrored_playlist_tracks(self, pid):
if pid == 1:
return [{"track_name": "A", "artist_name": "x"},
{"track_name": "Gone", "artist_name": "y"}] # not owned
return [{"track_name": "B", "artist_name": "z"}]
def get_mirrored_playlist(self, playlist_id):
for pl in self.get_mirrored_playlists():
if pl["id"] == playlist_id:
return pl
return None
def resolve_mirrored_playlist(self, ref, profile_id=1, *, default_source="spotify"):
for pl in self.get_mirrored_playlists():
if str(ref) in (pl["source_playlist_id"], str(pl["id"]), pl["name"]):
return pl
return None
def check_track_exists(self, title, artist, confidence_threshold=0.8,
server_source=None, album=None, candidate_tracks=None):
fp = self.owned.get(title)
return (_Track(fp), 1.0) if fp else (None, 0.0)
class _Cfg:
def __init__(self, root, mode="symlink", item_template=""):
self._d = {
"playlists.materialize_path": root,
"playlists.materialize_mode": mode,
"file_organization.templates": {"playlist_item": item_template},
}
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()
def test_reconcile_organize_batch_rebuilds_from_library(tmp_path: Path):
"""An organize batch → its playlist is rebuilt from the LIBRARY (membership ×
check_track_exists), not from fragile per-task fields. Owned members link,
non-owned drop out."""
a = _mk(tmp_path, "A.mp3")[0] # Mix membership = A, Gone; only A owned
db = _RebuildDB({"A": a})
batch = {"playlist_folder_mode": True, "playlist_name": "Mix",
"source_playlist_ref": "PL1", "batch_source": "spotify", "queue": []}
cfg = _Cfg(str(tmp_path / "Playlists"))
results = reconcile_batch_playlists(db, batch, {}, cfg)
assert len(results) == 1
name, s = results[0]
assert name == "Mix" and s.linked == 1 # A owned; Gone not in library → skipped
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
def test_reconcile_batch_playlist_rebuilds_even_if_row_flag_off(tmp_path: Path):
"""The batch's OWN playlist rebuilds because the per-download toggle (batch
playlist_folder_mode) is the intent — even when the saved organize_by_playlist
row flag is off (the common case: user flips the download-modal toggle, never
the saved preference). 'Off' (PL2) has organize_by_playlist=False on the row."""
b = _mk(tmp_path, "B.mp3")[0] # Off's membership = B
db = _RebuildDB({"B": b})
batch = {"playlist_folder_mode": True, "playlist_name": "Off",
"source_playlist_ref": "PL2", "batch_source": "spotify", "queue": []}
cfg = _Cfg(str(tmp_path / "Playlists"))
results = reconcile_batch_playlists(db, batch, {}, cfg)
assert len(results) == 1 and results[0][0] == "Off"
assert (tmp_path / "Playlists" / "Off" / "B.mp3").exists()
def test_reconcile_wishlist_track_rebuilds_its_playlist(tmp_path: Path):
"""The wishlist gap: a wishlist batch (not organize) completes a track whose
provenance points to an organize playlist → that playlist gets rebuilt from the
library, regardless of which import path set which task field."""
a = _mk(tmp_path, "A.mp3")[0]
db = _RebuildDB({"A": a})
batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]}
tasks = {"w1": {"status": "completed",
"track_info": {"source_info": {"playlist_id": "PL1", "source": "spotify"}}}}
cfg = _Cfg(str(tmp_path / "Playlists"))
results = reconcile_batch_playlists(db, batch, tasks, cfg)
assert len(results) == 1 and results[0][0] == "Mix"
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists() # Mix rebuilt from the library
def test_reconcile_skips_non_organized_provenance(tmp_path: Path):
"""A completed track pointing to a NON-organize playlist (Off / PL2) is ignored."""
db = _RebuildDB({"B": _mk(tmp_path, "B.mp3")[0]})
batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]}
tasks = {"w1": {"status": "completed",
"track_info": {"source_info": {"playlist_id": "PL2", "source": "spotify"}}}}
assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
assert not (tmp_path / "Playlists" / "Off").exists()
def test_reconcile_noop_for_plain_batch(tmp_path: Path):
"""A normal (non-organize, no provenance) batch → nothing happens."""
db = _RebuildDB({})
batch = {"playlist_folder_mode": False, "playlist_name": "album", "queue": ["a1"]}
tasks = {"a1": {"status": "completed", "track_info": {}}} # no source_info
assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
assert not (tmp_path / "Playlists").exists()
def test_mirror_cleanup_prunes_removed_track(tmp_path: Path):
"""Mirror-update hook: after a track LEAVES the playlist, its symlink is pruned."""
a = tmp_path / "Music" / "A.mp3"
gone = tmp_path / "Music" / "Gone.mp3"
for f in (a, gone):
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"audio")
class _DB:
def get_mirrored_playlist(self, pid):
return {"id": 1, "name": "Mix", "organize_by_playlist": True} if pid == 1 else None
def get_mirrored_playlist_tracks(self, pid):
return [{"track_name": "A", "artist_name": "x"}] # 'Gone' was removed upstream
def check_track_exists(self, title, artist, confidence_threshold=0.8,
server_source=None, album=None, candidate_tracks=None):
fp = {"A": str(a), "Gone": str(gone)}.get(title)
return (_Track(fp), 1.0) if fp else (None, 0.0)
from core.playlists.materialize import rebuild_playlist_folder
rebuild_playlist_folder(str(tmp_path / "Playlists"), "Mix", [str(a), str(gone)], "symlink")
assert (tmp_path / "Playlists" / "Mix" / "Gone.mp3").exists() # both present before
summary = rebuild_mirrored_playlist_if_organized(_DB(), _Cfg(str(tmp_path / "Playlists")), 1, profile_id=1)
assert summary is not None
pdir = tmp_path / "Playlists" / "Mix"
assert (pdir / "A.mp3").exists()
assert not (pdir / "Gone.mp3").exists() # pruned on mirror update
def test_mirror_cleanup_skips_non_organized(tmp_path: Path):
db = _RebuildDB({})
cfg = _Cfg(str(tmp_path / "Playlists"))
assert rebuild_mirrored_playlist_if_organized(db, cfg, 2, profile_id=1) is None # Off (organize=0)
assert rebuild_mirrored_playlist_if_organized(db, cfg, 999, profile_id=1) is None # unknown
assert rebuild_mirrored_playlist_if_organized(db, cfg, None, profile_id=1) is None
def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path):
"""The manual button: rebuild every organized playlist by re-matching with
check_track_exists (name), linking only owned tracks."""
f = tmp_path / "Music" / "A.mp3"
f.parent.mkdir(parents=True, exist_ok=True)
f.write_bytes(b"audio")
db = _RebuildDB({"A": str(f)}) # 'Gone' + the 'Off' playlist's 'B' not owned
cfg = _Cfg(str(tmp_path / "Playlists"))
results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
assert len(results) == 1 # only Mix (organize on)
name, s = results[0]
assert name == "Mix" and s.linked == 1 # only A owned; Gone skipped
assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
assert not (tmp_path / "Playlists" / "Off").exists()
def test_playlist_item_template_renames_entries(tmp_path: Path):
"""The custom-naming opt-in: a configured playlist_item template renames the
files INSIDE the playlist folder (real library file untouched), with $position
coming straight from playlist order."""
a = (tmp_path / "Music" / "Artist X" / "07 - A.flac")
a.parent.mkdir(parents=True, exist_ok=True)
a.write_bytes(b"audio")
db = _RebuildDB({"A": str(a)})
cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy", item_template="$position - $title")
results = rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
assert results[0][0] == "Mix"
mix = tmp_path / "Playlists" / "Mix"
assert sorted(p.name for p in mix.iterdir()) == ["01 - A.flac"] # templated, NOT "07 - A.flac"
def test_empty_playlist_item_template_keeps_library_filename(tmp_path: Path):
"""Back-compat: with no template configured, entries keep the library filename."""
a = (tmp_path / "Music" / "Artist X" / "07 - A.flac")
a.parent.mkdir(parents=True, exist_ok=True)
a.write_bytes(b"audio")
db = _RebuildDB({"A": str(a)})
cfg = _Cfg(str(tmp_path / "Playlists"), mode="copy") # item_template="" (default)
rebuild_organized_playlists_from_db(db, cfg, profile_id=1)
mix = tmp_path / "Playlists" / "Mix"
assert sorted(p.name for p in mix.iterdir()) == ["07 - A.flac"] # unchanged