soulsync/tests/playlists/test_playlist_item_naming.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

121 lines
4.8 KiB
Python

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