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:
parent
15ea87a154
commit
9aaafaf341
7 changed files with 326 additions and 12 deletions
94
core/playlists/item_naming.py
Normal file
94
core/playlists/item_naming.py
Normal 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",
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
121
tests/playlists/test_playlist_item_naming.py
Normal file
121
tests/playlists/test_playlist_item_naming.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue