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).
This commit is contained in:
BoulderBadgeDad 2026-06-21 21:57:20 -07:00
parent 15ea87a154
commit 9aaafaf341
7 changed files with 326 additions and 12 deletions

View file

@ -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",
]

View file

@ -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/<playlist_name>/`` 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:

View file

@ -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):

View file

@ -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"]

View file

@ -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

View file

@ -5623,6 +5623,13 @@
<small class="settings-hint">Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only). Use ${var} to append text</small>
</div>
<div class="form-group">
<label>Playlist File Naming:</label>
<input type="text" id="template-playlist-item"
placeholder="Leave empty to keep the library filename">
<small class="settings-hint">Renames the files inside each "Organize by Playlist" folder (your real library files are never touched). <strong>Filename only — no folders.</strong> Variables: $position (playlist order), $artist, $album, $track, $title. Must include $title and cannot contain "/". Example: <code>$position - $artist - $title</code><code>01 - Daft Punk - One More Time.flac</code>. Empty = keep the original library filename.</small>
</div>
<div class="form-group">
<label>Music Video Path Template:</label>
<input type="text" id="template-video-path"

View file

@ -1274,6 +1274,7 @@ async function loadSettingsData() {
document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title';
document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title';
document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title';
document.getElementById('template-playlist-item').value = settings.file_organization?.templates?.playlist_item || '';
document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video';
document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc';
document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first';
@ -2936,6 +2937,21 @@ async function saveSettings(quiet = false) {
}
}
// Validate the optional "Playlist File Naming" template before saving: it's a
// filename (no path separator) and must include $title — mirrors the server-side
// rule so a broken value can't be stored. Empty = feature off (allowed).
const _plItemTpl = (document.getElementById('template-playlist-item')?.value || '').trim();
if (_plItemTpl) {
if (_plItemTpl.includes('/') || _plItemTpl.includes('\\')) {
showToast('Playlist File Naming can\'t contain a folder separator ( / or \\ ) — it names the file, not a path.', 'error');
return;
}
if (!_plItemTpl.includes('$title')) {
showToast('Playlist File Naming must include $title so every file has a name.', 'error');
return;
}
}
const settings = {
active_media_server: activeServer,
spotify: {
@ -3141,6 +3157,7 @@ async function saveSettings(quiet = false) {
album_path: document.getElementById('template-album-path').value,
single_path: document.getElementById('template-single-path').value,
playlist_path: document.getElementById('template-playlist-path').value,
playlist_item: document.getElementById('template-playlist-item').value,
video_path: document.getElementById('template-video-path').value
}
},