soulsync/core/playlists/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

247 lines
11 KiB
Python
Raw Permalink 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,
)
from utils.logging_config import get_logger
logger = get_logger("playlists.materialize")
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(db, batch: dict, download_tasks: dict, config_manager, *, profile_id: int = 1):
"""One post-batch step: rebuild every organize-by-playlist playlist this batch
TOUCHED, from CURRENT library ownership.
Touched playlists = the batch's own organize playlist + any playlist a completed
track belongs to (via the per-track ``source_info`` provenance — covers a
wishlist batch fulfilling a track that belongs to an organize playlist). Each is
rebuilt via ``_rebuild_one_from_db`` (``check_track_exists`` over its membership),
so it's robust to HOW a track imported — modal worker, slskd monitor, or the
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 ``[(playlist_name, RebuildSummary)]``. Callers wrap non-fatally."""
import json as _json
# Collect the (ref, source) of every organize playlist this batch touched.
# (ref, source, force). force=True for the batch's OWN playlist: the per-download
# "organize by playlist" toggle (batch playlist_folder_mode) is the user's
# intent, so it rebuilds regardless of the saved row preference. Provenance
# playlists (force=False) require the saved organize_by_playlist flag.
wanted, seen_ref = [], set()
def _want(ref, source, force):
ref = str(ref or "").strip()
if not ref:
return
key = (ref, source or "spotify")
if key not in seen_ref:
seen_ref.add(key)
wanted.append((ref, source or "spotify", force))
if batch.get("playlist_folder_mode"):
_want(batch.get("source_playlist_ref") or batch.get("playlist_id"),
batch.get("batch_source") or "spotify", True)
for tid in (batch.get("queue") or []):
task = (download_tasks or {}).get(tid) or {}
if task.get("status") != "completed":
continue
si = (task.get("track_info") or {}).get("source_info") or {}
if isinstance(si, str):
try:
si = _json.loads(si)
except Exception:
si = {}
if isinstance(si, dict) and si.get("playlist_id"):
_want(si["playlist_id"], si.get("source") or "spotify", False)
results, seen_id = [], set()
for ref, source, force in wanted:
try:
pl = db.resolve_mirrored_playlist(ref, profile_id, default_source=source)
except Exception as e:
logger.debug("[Playlist Folder] resolve failed for ref=%s source=%s: %s", ref, source, e)
pl = None
if not pl:
logger.info("[Playlist Folder] no mirrored playlist resolved for ref=%s source=%s "
"(force=%s) — folder not built", ref, source, force)
continue
if pl.get("id") in seen_id:
continue
if not force and not pl.get("organize_by_playlist"):
continue
seen_id.add(pl.get("id"))
try:
results.append(_rebuild_one_from_db(db, config_manager, pl))
except Exception as e:
logger.error("[Playlist Folder] rebuild failed for '%s': %s", pl.get("name"), e)
continue
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)``."""
import os as _os
from core.library.path_resolver import resolve_library_file_path
from core.playlists.item_naming import render_playlist_item_name
root = docker_resolve_path(config_manager.get("playlists.materialize_path", "./Playlists"))
mode = normalize_mode(config_manager.get("playlists.materialize_mode", "symlink"))
item_template = ((config_manager.get("file_organization.templates", {}) or {})
.get("playlist_item", "") or "").strip()
# Resolve owned tracks to real paths IN PLAYLIST ORDER, keeping the metadata
# so an optional custom filename template ($position/$artist/$title/...) can
# be applied. $position is the playlist index, which is exactly this order.
resolved: List[dict] = []
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)
resolved.append({
"real": real,
"title": title,
"artist": artist,
"album": (t.get("album_name") or t.get("album") or "").strip(),
"track": getattr(db_track, "track_number", None),
})
real_paths: List[str] = [r["real"] for r in resolved]
dest_names = None
if item_template:
width = max(2, len(str(len(resolved)))) # zero-pad $position for correct sorting
dest_names = [
render_playlist_item_name(
item_template,
title=r["title"], artist=r["artist"], album=r["album"], track=r["track"],
position=f"{i:0{width}d}",
ext=_os.path.splitext(r["real"])[1],
fallback_name=_os.path.basename(r["real"]),
)
for i, r in enumerate(resolved, start=1)
]
name = playlist.get("name") or "Unnamed Playlist"
return name, rebuild_playlist_folder(root, name, real_paths, mode, dest_names=dest_names) # 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",
]