soulsync/core/playlists/materialize_service.py
BoulderBadgeDad d160c486ec Playlists: mirror-update trigger prunes removed tracks (the other half)
Symmetric to the post-download reconcile (which handles ADDITIONS): when a
playlist's membership is re-synced (the mirror step — scheduled refresh or the
manual mirror endpoint), rebuild its folder from current membership WITH prune
IF it's organize-by-playlist. So a track that just LEFT the playlist has its
symlink cleaned up the instant membership changes, not only on the next download.

Factored a shared _rebuild_one_from_db (used by the manual 'Rebuild' button and
the mirror hook) + rebuild_mirrored_playlist_if_organized. Gated to organized
playlists, non-fatal at both mirror call sites.

Now the invariant 'folder = the playlist's current owned members' holds on every
change: additions caught at download, removals caught at mirror. 2 new tests
(removed track pruned; non-organized skipped). 985 + 277 tests pass.
2026-06-12 15:49:13 -07:00

192 lines
8.6 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.

"""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)
def reconcile_batch_playlists(batch: dict, download_tasks: dict, config_manager):
"""One post-batch step: drop the batch's newly-resolved tracks into the right
``Playlists/<name>/`` folders. Path-independent — covers an organize-by-playlist
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
(owned + downloaded), pruning tracks no longer present.
- A track that completed for a DIFFERENT playlist (e.g. a wishlist
fulfillment) is ADDED to that playlist's folder — no prune, since this
batch isn't that playlist's full membership (removals are handled by a
full rebuild on the next sync / the manual button).
Returns a list of ``(playlist_name, RebuildSummary)``. Callers wrap non-fatally."""
from core.library.path_resolver import resolve_library_file_path
results = []
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
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).
if batch_name:
summary = materialize_playlist_from_batch(batch, download_tasks, config_manager)
if summary:
results.append((batch_name, summary))
# 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 []):
task = (download_tasks or {}).get(tid) or {}
if task.get("status") != "completed" or not task.get("final_file_path"):
continue
pname = (task.get("track_info") or {}).get("_playlist_name")
if pname and pname != batch_name:
extra.setdefault(pname, []).append(task["final_file_path"])
for name, stored in extra.items():
real, seen = [], set()
for p in stored:
r = resolve_library_file_path(str(p), config_manager=config_manager)
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)))
return results
def _rebuild_one_from_db(db, config_manager, playlist: dict):
"""Rebuild ONE playlist's folder from its CURRENT membership × ownership —
re-matching each member via the app's own ``check_track_exists`` (by name, not
source IDs), resolving to disk, and rebuilding WITH prune. Because it's driven
by current membership, a track that has LEFT the playlist drops out of the set
and its symlink is pruned. Returns ``(playlist_name, RebuildSummary)``."""
from core.library.path_resolver import resolve_library_file_path
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
real_paths: List[str] = []
seen = set()
for t in (db.get_mirrored_playlist_tracks(playlist["id"]) or []):
title = (t.get("track_name") or "").strip()
artist = (t.get("artist_name") or "").strip()
if not title:
continue
try:
db_track, conf = db.check_track_exists(title, artist, confidence_threshold=0.7)
except Exception:
continue
if db_track is None or conf < 0.7:
continue
real = resolve_library_file_path(getattr(db_track, "file_path", None), config_manager=config_manager)
if real and real not in seen:
seen.add(real)
real_paths.append(real)
name = playlist.get("name") or "Unnamed Playlist"
return name, rebuild_playlist_folder(root, name, real_paths, mode) # prune_stale=True
def rebuild_organized_playlists_from_db(db, config_manager, *, profile_id: int = 1):
"""Rebuild EVERY "organize by playlist" folder from current library ownership —
for the manual "Rebuild" button. Self-heals after a library reorganize moves
files or membership changes. Returns a list of ``(playlist_name, summary)``."""
return [
_rebuild_one_from_db(db, config_manager, pl)
for pl in (db.get_mirrored_playlists(profile_id) or [])
if pl.get("organize_by_playlist")
]
def rebuild_mirrored_playlist_if_organized(db, config_manager, playlist_id, *, profile_id: int = 1):
"""Mirror-update hook: after a playlist's membership is re-synced, rebuild its
folder (with prune) IF it's organize-by-playlist — so a track that just LEFT
the playlist has its symlink cleaned up the instant membership changes (the
mirror image of the post-download reconcile that handles additions). Returns
the summary, or ``None`` when the playlist isn't organized / can't be found."""
if playlist_id is None:
return None
pl = db.get_mirrored_playlist(playlist_id)
if not pl or not pl.get("organize_by_playlist"):
return None
_name, summary = _rebuild_one_from_db(db, config_manager, pl)
return summary
__all__ = [
"collect_batch_real_paths",
"materialize_playlist_from_batch",
"reconcile_batch_playlists",
"rebuild_organized_playlists_from_db",
"rebuild_mirrored_playlist_if_organized",
]