diff --git a/core/playlists/item_naming.py b/core/playlists/item_naming.py new file mode 100644 index 00000000..94e56790 --- /dev/null +++ b/core/playlists/item_naming.py @@ -0,0 +1,94 @@ +"""Optional custom naming for the FILES inside an organize-by-playlist folder. + +By default a playlist entry keeps the real library filename (the materialized +folder is a view onto Artist/Album/track.ext). A user can opt into a flat +filename template — e.g. ``$position - $artist - $title`` — so the folder sorts +and plays the way they want (most commonly: in playlist order on a dumb DAP). + +It is a **filename** template, never a path: + - it may NOT contain a path separator (``/`` or ``\\``) — it names the file, + not a folder tree, and + - it MUST contain ``$title`` — so every file has a real, non-empty name. + +Both rules are validated up front (so the Settings UI can reject a bad value +with a reason) AND re-checked at apply time, where an invalid/empty template or +an empty render falls back to the library filename. So a bad value can never +produce a broken name — the worst case is "no change from today". + +Pure logic: no DB, no config, no filesystem. The caller supplies the metadata. +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +from core.imports.paths import sanitize_filename + +# Tokens a user may use in the template (for docs / UI hints). +PLAYLIST_ITEM_TOKENS = ("$position", "$artist", "$album", "$track", "$title") + + +def validate_playlist_item_template(template: Optional[str]) -> Tuple[bool, str]: + """Return ``(ok, reason)``. An empty template is VALID and means "feature off" + (keep the library filename). ``reason`` is '' when ok.""" + t = (template or "").strip() + if not t: + return True, "" # empty == disabled, not an error + if "/" in t or "\\" in t: + return False, ("Playlist file naming can't contain a folder separator " + "( / or \\ ) — it names the file, not a path.") + if "$title" not in t: + return False, "Playlist file naming must include $title so every file has a name." + return True, "" + + +def render_playlist_item_name( + template: Optional[str], + *, + title: str, + artist: str = "", + album: str = "", + track: object = None, + position: object = None, + ext: str = "", + fallback_name: str = "", +) -> str: + """Render ``template`` to a sanitized filename WITH ``ext`` appended. + + Falls back to ``fallback_name`` (the library filename) when the template is + empty/invalid or renders to nothing after sanitizing — so the result is + never broken. ``position`` is used verbatim (the caller pre-pads it for + correct sorting); ``track`` is zero-padded to two digits when numeric.""" + ok, _ = validate_playlist_item_template(template) + t = (template or "").strip() + if not ok or not t: + return fallback_name + + pos_str = "" if position is None else str(position) + if track is None: + trk_str = "" + else: + try: + trk_str = f"{int(track):02d}" + except (TypeError, ValueError): + trk_str = str(track) + + # No token is a prefix of another, so replacement order is irrelevant. + out = t + out = out.replace("$position", pos_str) + out = out.replace("$artist", str(artist or "")) + out = out.replace("$album", str(album or "")) + out = out.replace("$track", trk_str) + out = out.replace("$title", str(title or "")) + + out = sanitize_filename(out).strip() + if not out: + return fallback_name + return out + (ext or "") + + +__all__ = [ + "PLAYLIST_ITEM_TOKENS", + "validate_playlist_item_template", + "render_playlist_item_name", +] diff --git a/core/playlists/materialize.py b/core/playlists/materialize.py index f48a9001..dff26886 100644 --- a/core/playlists/materialize.py +++ b/core/playlists/materialize.py @@ -72,16 +72,25 @@ def playlist_dir_for(playlists_root: str, playlist_name: str) -> str: return candidate -def _desired_entries(playlist_dir: str, real_paths: Sequence[str]) -> "list[tuple[str, str]]": - """Map each real file to a flat destination inside ``playlist_dir``, preserving - the source filename. On a basename collision between two *different* sources, - disambiguate with a numeric suffix rather than silently overwriting.""" +def _desired_entries( + playlist_dir: str, + real_paths: Sequence[str], + dest_names: Optional[Sequence[Optional[str]]] = None, +) -> "list[tuple[str, str]]": + """Map each real file to a flat destination inside ``playlist_dir``. + + By default the source filename is preserved. ``dest_names`` (parallel to + ``real_paths``) lets a caller override the name per entry — e.g. a custom + playlist file-naming template; a falsy override falls back to the source + basename. On a name collision between two *different* sources, disambiguate + with a numeric suffix rather than silently overwriting.""" entries: list[tuple[str, str]] = [] used: dict[str, str] = {} # dest basename -> source real path - for real in real_paths: + for i, real in enumerate(real_paths): if not real: continue - base = os.path.basename(real) + override = dest_names[i] if (dest_names is not None and i < len(dest_names)) else None + base = override or os.path.basename(real) name = base stem, ext = os.path.splitext(base) counter = 1 @@ -156,6 +165,7 @@ def rebuild_playlist_folder( real_paths: Sequence[str], mode: str = DEFAULT_MODE, *, + dest_names: Optional[Sequence[Optional[str]]] = None, prune_stale: bool = True, symlink_fn: Callable[[str, str], None] = os.symlink, copy_fn: Callable[[str, str], object] = shutil.copy2, @@ -163,13 +173,15 @@ def rebuild_playlist_folder( """(Re)build ``playlists_root//`` so it contains exactly one entry per real file in ``real_paths`` — adding missing entries, leaving correct ones untouched, and (when ``prune_stale``) removing entries no longer present. + ``dest_names`` (parallel to ``real_paths``) optionally overrides each entry's + filename (custom playlist naming); falsy entries keep the source basename. Idempotent and safe to re-run any time. Filesystem ops are injectable.""" mode = normalize_mode(mode) pdir = playlist_dir_for(playlists_root, playlist_name) summary = RebuildSummary(playlist_dir=pdir, mode_requested=mode) os.makedirs(pdir, exist_ok=True) - entries = _desired_entries(pdir, real_paths) + entries = _desired_entries(pdir, real_paths, dest_names) keep = {dest for _real, dest in entries} for real, dest in entries: diff --git a/core/playlists/materialize_service.py b/core/playlists/materialize_service.py index c8121b86..c00124eb 100644 --- a/core/playlists/materialize_service.py +++ b/core/playlists/materialize_service.py @@ -155,11 +155,20 @@ def _rebuild_one_from_db(db, config_manager, playlist: dict): 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")) - real_paths: List[str] = [] + 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() @@ -175,9 +184,32 @@ def _rebuild_one_from_db(db, config_manager, playlist: dict): 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) + 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) # prune_stale=True + 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): diff --git a/tests/playlists/test_playlist_item_naming.py b/tests/playlists/test_playlist_item_naming.py new file mode 100644 index 00000000..fd9b38ab --- /dev/null +++ b/tests/playlists/test_playlist_item_naming.py @@ -0,0 +1,121 @@ +"""Custom file naming for organize-by-playlist folders. + +The materialized playlist folder used to be stuck with the library filename. +A user can now opt into a flat filename template (e.g. "$position - $artist - +$title"). It's a FILENAME, not a path — validated so it can't make folders or +broken names, and it falls back to the library filename on anything invalid. +""" + +from __future__ import annotations + +import os + +import pytest + +from core.playlists.item_naming import ( + render_playlist_item_name, + validate_playlist_item_template, +) +from core.playlists.materialize import rebuild_playlist_folder + + +# ── validation ────────────────────────────────────────────────────────────── + +def test_empty_template_is_valid_means_off(): + assert validate_playlist_item_template("") == (True, "") + assert validate_playlist_item_template(" ") == (True, "") + assert validate_playlist_item_template(None) == (True, "") + + +def test_slash_is_rejected_no_folder_structure(): + ok, why = validate_playlist_item_template("$artist/$title") + assert ok is False and "separator" in why + ok, why = validate_playlist_item_template("$artist\\$title") + assert ok is False + + +def test_must_contain_title(): + ok, why = validate_playlist_item_template("$position - $artist") + assert ok is False and "$title" in why + + +def test_valid_flat_template_passes(): + assert validate_playlist_item_template("$position - $artist - $title") == (True, "") + + +# ── rendering ─────────────────────────────────────────────────────────────── + +def test_renders_tokens_and_keeps_extension(): + out = render_playlist_item_name( + "$position - $artist - $title", + title="One More Time", artist="Daft Punk", position="01", ext=".flac", + fallback_name="x.flac") + assert out == "01 - Daft Punk - One More Time.flac" + + +def test_track_is_zero_padded_album_is_optional(): + out = render_playlist_item_name( + "$track - $title", title="Genesis", track=5, ext=".mp3", fallback_name="x.mp3") + assert out == "05 - Genesis.mp3" + + +def test_invalid_template_falls_back_to_library_name(): + # slash / missing-title / empty all fall back — never a broken name + for bad in ("$artist/$title", "$artist - $position", ""): + assert render_playlist_item_name( + bad, title="T", artist="A", ext=".flac", fallback_name="orig.flac") == "orig.flac" + + +def test_garbage_title_still_yields_a_safe_name_not_broken(): + # a title made of separators is sanitized to a safe (ugly) name with the + # extension intact — never a broken name and never a path. + out = render_playlist_item_name( + "$title", title="/////", ext=".flac", fallback_name="orig.flac") + assert out.endswith(".flac") and "/" not in out and "\\" not in out + + +def test_rendered_name_can_never_contain_a_separator(): + out = render_playlist_item_name( + "$artist - $title", title="AC/DC Song", artist="AC/DC", ext=".flac", fallback_name="x.flac") + assert "/" not in out and "\\" not in out + + +# ── end-to-end through the real folder builder ────────────────────────────── + +def _touch(p): + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as f: + f.write(b"\x00") + + +def test_rebuild_uses_dest_names_when_given(tmp_path): + lib = tmp_path / "lib" + a = str(lib / "Artist A" / "05 - Song A.flac") + b = str(lib / "Artist B" / "02 - Song B.flac") + _touch(a); _touch(b) + root = str(tmp_path / "Playlists") + summary = rebuild_playlist_folder( + root, "My Mix", [a, b], "copy", + dest_names=["01 - Artist A - Song A.flac", "02 - Artist B - Song B.flac"]) + got = sorted(os.listdir(summary.playlist_dir)) + assert got == ["01 - Artist A - Song A.flac", "02 - Artist B - Song B.flac"] + + +def test_rebuild_without_dest_names_keeps_basename(tmp_path): + # back-compat: default behavior unchanged + a = str(tmp_path / "lib" / "05 - Song A.flac") + _touch(a) + summary = rebuild_playlist_folder(str(tmp_path / "PL"), "Mix", [a], "copy") + assert os.listdir(summary.playlist_dir) == ["05 - Song A.flac"] + + +def test_rebuild_disambiguates_colliding_dest_names(tmp_path): + # two different sources, same templated name (e.g. template "$title" + dup title) + a = str(tmp_path / "lib" / "a" / "x.flac") + b = str(tmp_path / "lib" / "b" / "y.flac") + _touch(a); _touch(b) + summary = rebuild_playlist_folder( + str(tmp_path / "PL"), "Mix", [a, b], "copy", + dest_names=["Song.flac", "Song.flac"]) + got = sorted(os.listdir(summary.playlist_dir)) + assert got == ["Song (2).flac", "Song.flac"] diff --git a/tests/test_playlist_materialize_service.py b/tests/test_playlist_materialize_service.py index fbb3332e..17648790 100644 --- a/tests/test_playlist_materialize_service.py +++ b/tests/test_playlist_materialize_service.py @@ -59,8 +59,12 @@ class _RebuildDB: class _Cfg: - def __init__(self, root, mode="symlink"): - self._d = {"playlists.materialize_path": root, "playlists.materialize_mode": mode} + 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) @@ -266,3 +270,30 @@ def test_rebuild_from_db_only_organized_and_owned(tmp_path: Path): 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 diff --git a/webui/index.html b/webui/index.html index cc7e4de1..5f1a3d3d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5623,6 +5623,13 @@ Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only). Use ${var} to append text +
+ + + Renames the files inside each "Organize by Playlist" folder (your real library files are never touched). Filename only — no folders. Variables: $position (playlist order), $artist, $album, $track, $title. Must include $title and cannot contain "/". Example: $position - $artist - $title01 - Daft Punk - One More Time.flac. Empty = keep the original library filename. +
+