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:
parent
7fb1b115f0
commit
4d7267e906
4 changed files with 99 additions and 88 deletions
|
|
@ -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, "
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue