Playlists: reconcile rebuilds touched playlists from the LIBRARY, not task fields

The reconcile read each completed task's final_file_path to find paths — but not
every import path sets it (the verification worker marks the task completed
without it), so tracks that imported via that path were silently dropped (user
saw 3 of 5 symlinks). Root cause: leaning on a fragile per-task field.

Now reconcile_batch_playlists identifies the organize playlists the batch touched
(its own + any reached via a completed track's source_info provenance) and
rebuilds each from CURRENT library ownership via _rebuild_one_from_db
(check_track_exists over membership). It just asks the library what's owned, so
it's robust to HOW a track imported (modal worker / slskd monitor / verification
worker) and still prunes tracks that left. Takes a db handle; all three callers
pass MusicDatabase().

Reconcile tests rewritten for the DB-rebuild form (organize batch, wishlist
provenance, non-organize skip, plain no-op). 973 downloads/imports/playlist
tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-12 17:08:27 -07:00
parent 7fb1b115f0
commit 4d7267e906
4 changed files with 99 additions and 88 deletions

View file

@ -556,7 +556,8 @@ def on_download_completed(batch_id: str, task_id: str, success: bool, deps: Life
# from the batch's own captured paths — non-fatal, derived view. # from the batch's own captured paths — non-fatal, derived view.
try: try:
from core.playlists.materialize_service import reconcile_batch_playlists from core.playlists.materialize_service import reconcile_batch_playlists
for _pl_name, _mat in reconcile_batch_playlists(batch, download_tasks, deps.config_manager): from database.music_database import MusicDatabase
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
logger.info( logger.info(
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': " f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
f"{_mat.linked} linked, {_mat.copied} copied, " f"{_mat.linked} linked, {_mat.copied} copied, "
@ -758,7 +759,8 @@ def check_batch_completion_v2(batch_id: str, deps: LifecycleDeps) -> Optional[bo
# get built for them. Path-independent, non-fatal, derived view. # get built for them. Path-independent, non-fatal, derived view.
try: try:
from core.playlists.materialize_service import reconcile_batch_playlists from core.playlists.materialize_service import reconcile_batch_playlists
for _pl_name, _mat in reconcile_batch_playlists(batch, download_tasks, deps.config_manager): from database.music_database import MusicDatabase
for _pl_name, _mat in reconcile_batch_playlists(MusicDatabase(), batch, download_tasks, deps.config_manager):
logger.info( logger.info(
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': " f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}': "
f"{_mat.linked} linked, {_mat.copied} copied, " f"{_mat.linked} linked, {_mat.copied} copied, "

View file

@ -792,9 +792,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
if effective_playlist_folder_mode: if effective_playlist_folder_mode:
try: try:
from core.playlists.materialize_service import reconcile_batch_playlists from core.playlists.materialize_service import reconcile_batch_playlists
from database.music_database import MusicDatabase as _MDB
_batch = download_batches.get(batch_id) _batch = download_batches.get(batch_id)
if _batch is not None: if _batch is not None:
for _pl_name, _mat in reconcile_batch_playlists(_batch, download_tasks, deps.config_manager): for _pl_name, _mat in reconcile_batch_playlists(_MDB(), _batch, download_tasks, deps.config_manager):
logger.info( logger.info(
f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): " f"[Playlist Folder] Rebuilt '{_mat.playlist_dir}' (all owned): "
f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed" f"{_mat.linked} linked, {_mat.copied} copied, {_mat.removed_stale} stale removed"

View file

@ -74,55 +74,63 @@ def materialize_playlist_from_batch(batch: dict, download_tasks: dict, config_ma
return rebuild_playlist_folder(root, name, real_paths, mode) return rebuild_playlist_folder(root, name, real_paths, mode)
def reconcile_batch_playlists(batch: dict, download_tasks: dict, config_manager): def reconcile_batch_playlists(db, batch: dict, download_tasks: dict, config_manager, *, profile_id: int = 1):
"""One post-batch step: drop the batch's newly-resolved tracks into the right """One post-batch step: rebuild every organize-by-playlist playlist this batch
``Playlists/<name>/`` folders. Path-independent covers an organize-by-playlist TOUCHED, from CURRENT library ownership.
download AND a late wishlist arrival using the per-track playlist provenance
each download already carries, plus the file paths the batch already captured
(so it needs no DB re-match and no waiting on the server scan/sync).
- The batch's OWN organize playlist is fully rebuilt from the batch payload Touched playlists = the batch's own organize playlist + any playlist a completed
(owned + downloaded), pruning tracks no longer present. track belongs to (via the per-track ``source_info`` provenance covers a
- A track that completed for a DIFFERENT playlist (e.g. a wishlist wishlist batch fulfilling a track that belongs to an organize playlist). Each is
fulfillment) is ADDED to that playlist's folder — no prune, since this rebuilt via ``_rebuild_one_from_db`` (``check_track_exists`` over its membership),
batch isn't that playlist's full membership (removals are handled by a so it's robust to HOW a track imported — modal worker, slskd monitor, or the
full rebuild on the next sync / the manual button). verification worker, which don't all set the same task fields. It simply asks the
library what's owned, and prunes tracks that have left the playlist.
Returns a list of ``(playlist_name, RebuildSummary)``. Callers wrap non-fatally.""" Returns ``[(playlist_name, RebuildSummary)]``. Callers wrap non-fatally."""
from core.library.path_resolver import resolve_library_file_path import json as _json
results = [] # Collect the (ref, source) of every organize playlist this batch touched.
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists")) wanted, seen_ref = [], set()
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
batch_name = batch.get("playlist_name") if batch.get("playlist_folder_mode") else None
# 1. The batch's own organize playlist → full materialize (prunes removed). def _want(ref, source):
if batch_name: ref = str(ref or "").strip()
summary = materialize_playlist_from_batch(batch, download_tasks, config_manager) if not ref:
if summary: return
results.append((batch_name, summary)) key = (ref, source or "spotify")
if key not in seen_ref:
seen_ref.add(key)
wanted.append(key)
if batch.get("playlist_folder_mode"):
_want(batch.get("source_playlist_ref") or batch.get("playlist_id"),
batch.get("batch_source") or "spotify")
# 2. Tracks that completed for OTHER playlists (e.g. wishlist fulfilling a
# track that belongs to an organize playlist) → add-only into that folder.
extra: dict = {}
for tid in (batch.get("queue") or []): for tid in (batch.get("queue") or []):
task = (download_tasks or {}).get(tid) or {} task = (download_tasks or {}).get(tid) or {}
if task.get("status") != "completed" or not task.get("final_file_path"): if task.get("status") != "completed":
continue continue
pname = (task.get("track_info") or {}).get("_playlist_name") si = (task.get("track_info") or {}).get("source_info") or {}
if pname and pname != batch_name: if isinstance(si, str):
extra.setdefault(pname, []).append(task["final_file_path"]) try:
si = _json.loads(si)
for name, stored in extra.items(): except Exception:
real, seen = [], set() si = {}
for p in stored: if isinstance(si, dict) and si.get("playlist_id"):
r = resolve_library_file_path(str(p), config_manager=config_manager) _want(si["playlist_id"], si.get("source") or "spotify")
if r and r not in seen:
seen.add(r)
real.append(r)
if real:
results.append((name, rebuild_playlist_folder(root, name, real, mode, prune_stale=False)))
results, seen_id = [], set()
for ref, source in wanted:
try:
pl = db.resolve_mirrored_playlist(ref, profile_id, default_source=source)
except Exception:
pl = None
if not pl or not pl.get("organize_by_playlist") or pl.get("id") in seen_id:
continue
seen_id.add(pl.get("id"))
try:
results.append(_rebuild_one_from_db(db, config_manager, pl))
except Exception:
continue
return results return results

View file

@ -30,8 +30,8 @@ class _RebuildDB:
def get_mirrored_playlists(self, profile_id=1): def get_mirrored_playlists(self, profile_id=1):
return [ return [
{"id": 1, "name": "Mix", "organize_by_playlist": True}, {"id": 1, "name": "Mix", "source_playlist_id": "PL1", "organize_by_playlist": True},
{"id": 2, "name": "Off", "organize_by_playlist": False}, {"id": 2, "name": "Off", "source_playlist_id": "PL2", "organize_by_playlist": False},
] ]
def get_mirrored_playlist_tracks(self, pid): def get_mirrored_playlist_tracks(self, pid):
@ -46,6 +46,12 @@ class _RebuildDB:
return pl return pl
return None 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, def check_track_exists(self, title, artist, confidence_threshold=0.8,
server_source=None, album=None, candidate_tracks=None): server_source=None, album=None, candidate_tracks=None):
fp = self.owned.get(title) fp = self.owned.get(title)
@ -142,59 +148,53 @@ def test_materialize_skipped_when_not_organize(tmp_path: Path):
assert not (tmp_path / "Playlists").exists() assert not (tmp_path / "Playlists").exists()
def test_reconcile_organize_batch_full_rebuild(tmp_path: Path): def test_reconcile_organize_batch_rebuilds_from_library(tmp_path: Path):
"""An organize-by-playlist batch → its own folder is fully (re)built from the """An organize batch → its playlist is rebuilt from the LIBRARY (membership ×
batch payload.""" check_track_exists), not from fragile per-task fields. Owned members link,
owned, fresh = _mk(tmp_path, "Owned.mp3"), _mk(tmp_path, "Fresh.mp3") non-owned drop out."""
batch = { a = _mk(tmp_path, "A.mp3")[0] # Mix membership = A, Gone; only A owned
"playlist_folder_mode": True, db = _RebuildDB({"A": a})
"playlist_name": "Mix", batch = {"playlist_folder_mode": True, "playlist_name": "Mix",
"analysis_results": [{"found": True, "matched_file_path": owned[0]}], "source_playlist_ref": "PL1", "batch_source": "spotify", "queue": []}
"queue": ["t1"],
}
tasks = {"t1": {"status": "completed", "final_file_path": fresh[0],
"track_info": {"_playlist_name": "Mix"}}} # same playlist as batch
cfg = _Cfg(str(tmp_path / "Playlists")) cfg = _Cfg(str(tmp_path / "Playlists"))
results = reconcile_batch_playlists(batch, tasks, cfg) results = reconcile_batch_playlists(db, batch, {}, cfg)
assert len(results) == 1 # only Mix (no double-add) assert len(results) == 1
name, s = results[0] name, s = results[0]
assert name == "Mix" and s.linked == 2 assert name == "Mix" and s.linked == 1 # A owned; Gone not in library → skipped
pdir = Path(s.playlist_dir) assert (tmp_path / "Playlists" / "Mix" / "A.mp3").exists()
assert (pdir / "Owned.mp3").exists() and (pdir / "Fresh.mp3").exists()
def test_reconcile_wishlist_adds_to_other_playlist_without_pruning(tmp_path: Path): def test_reconcile_wishlist_track_rebuilds_its_playlist(tmp_path: Path):
"""The wishlist gap: a wishlist batch (not organize) downloads a track that """The wishlist gap: a wishlist batch (not organize) completes a track whose
belongs to an organize playlist it's ADDED to that playlist's folder, and provenance points to an organize playlist that playlist gets rebuilt from the
the folder's existing entries are NOT pruned.""" library, regardless of which import path set which task field."""
# Pre-seed Smack That with an existing symlink (a track from a prior batch). a = _mk(tmp_path, "A.mp3")[0]
existing = _mk(tmp_path, "Existing.mp3")[0] db = _RebuildDB({"A": a})
from core.playlists.materialize import rebuild_playlist_folder batch = {"playlist_folder_mode": False, "playlist_name": "wishlist", "queue": ["w1"]}
rebuild_playlist_folder(str(tmp_path / "Playlists"), "Smack That", [existing], "symlink") tasks = {"w1": {"status": "completed",
"track_info": {"source_info": {"playlist_id": "PL1", "source": "spotify"}}}}
# A WISHLIST batch (playlist_folder_mode False) completes a track tagged for
# the "Smack That" organize playlist.
late = _mk(tmp_path, "Late.mp3")[0]
batch = {"playlist_folder_mode": False, "playlist_name": "wishlist",
"analysis_results": [], "queue": ["w1"]}
tasks = {"w1": {"status": "completed", "final_file_path": late,
"track_info": {"_playlist_name": "Smack That"}}}
cfg = _Cfg(str(tmp_path / "Playlists")) 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
results = reconcile_batch_playlists(batch, tasks, cfg)
assert len(results) == 1 and results[0][0] == "Smack That" def test_reconcile_skips_non_organized_provenance(tmp_path: Path):
pdir = tmp_path / "Playlists" / "Smack That" """A completed track pointing to a NON-organize playlist (Off / PL2) is ignored."""
assert (pdir / "Late.mp3").exists() # the wishlist track was ADDED db = _RebuildDB({"B": _mk(tmp_path, "B.mp3")[0]})
assert (pdir / "Existing.mp3").exists() # and the prior entry was NOT pruned 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): def test_reconcile_noop_for_plain_batch(tmp_path: Path):
"""A normal (non-organize, no provenance) batch → nothing happens.""" """A normal (non-organize, no provenance) batch → nothing happens."""
f = _mk(tmp_path, "X.mp3")[0] db = _RebuildDB({})
batch = {"playlist_folder_mode": False, "playlist_name": "album", batch = {"playlist_folder_mode": False, "playlist_name": "album", "queue": ["a1"]}
"analysis_results": [], "queue": ["a1"]} tasks = {"a1": {"status": "completed", "track_info": {}}} # no source_info
tasks = {"a1": {"status": "completed", "final_file_path": f, "track_info": {}}} assert reconcile_batch_playlists(db, batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
assert reconcile_batch_playlists(batch, tasks, _Cfg(str(tmp_path / "Playlists"))) == []
assert not (tmp_path / "Playlists").exists() assert not (tmp_path / "Playlists").exists()