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).
226 lines
8.4 KiB
Python
226 lines
8.4 KiB
Python
"""Materialize a playlist as a folder of links into the real music library.
|
|
|
|
A playlist folder is a **view**, not storage. Every entry points at the one real
|
|
file that already lives in the library (``Artist/Album/track.ext``); a track is
|
|
never stored twice no matter how many playlists it's in. Two modes:
|
|
|
|
- ``symlink`` (default): a *relative* symlink to the real file — ~zero disk, and
|
|
relative so the tree stays valid if the parent folder is moved.
|
|
- ``copy``: a real duplicate of the file — for filesystems/players that can't
|
|
follow symlinks (FAT USB sticks, some DAPs) or when a self-contained,
|
|
portable folder is wanted.
|
|
|
|
Symlinks silently fail or are unsupported on a lot of real setups (Windows
|
|
without the privilege, SMB/CIFS shares, FAT/exFAT). So when a symlink can't be
|
|
created we **fall back to a copy automatically** — the folder is always fully
|
|
populated, never left with dangling links.
|
|
|
|
This module is pure filesystem mechanics: no DB, no app state. Given a list of
|
|
real file paths, a playlists root, a playlist name and a mode, it (re)builds the
|
|
folder to match. That makes it the single unit-tested source of truth for "how a
|
|
playlist folder looks on disk", and means the folder is a *derived view* that can
|
|
be rebuilt from scratch at any time. Filesystem ops are injectable so the
|
|
behaviour — including the symlink→copy fallback — is testable without depending
|
|
on the host filesystem's symlink support.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, List, Optional, Sequence
|
|
|
|
from core.imports.paths import sanitize_filename
|
|
|
|
MATERIALIZE_MODES = ("symlink", "copy")
|
|
DEFAULT_MODE = "symlink"
|
|
|
|
|
|
def normalize_mode(mode: Optional[str]) -> str:
|
|
"""Coerce a config value to a valid mode (``symlink`` default)."""
|
|
m = (mode or "").strip().lower()
|
|
return m if m in MATERIALIZE_MODES else DEFAULT_MODE
|
|
|
|
|
|
@dataclass
|
|
class RebuildSummary:
|
|
"""Outcome of rebuilding one playlist folder. ``copied`` may be non-zero even
|
|
in symlink mode when the fallback kicked in (flagged by ``fellback``)."""
|
|
playlist_dir: str = ""
|
|
linked: int = 0
|
|
copied: int = 0
|
|
unchanged: int = 0
|
|
removed_stale: int = 0
|
|
missing_source: int = 0
|
|
failed: int = 0
|
|
fellback: bool = False
|
|
mode_requested: str = DEFAULT_MODE
|
|
errors: List[str] = field(default_factory=list)
|
|
|
|
|
|
def playlist_dir_for(playlists_root: str, playlist_name: str) -> str:
|
|
"""Absolute path of one playlist's folder, sanitized and guaranteed to stay
|
|
directly under ``playlists_root`` (defends against ``..`` / separators in a
|
|
playlist name)."""
|
|
root = os.path.abspath(playlists_root)
|
|
safe_name = sanitize_filename(playlist_name or "").strip() or "Unnamed Playlist"
|
|
candidate = os.path.abspath(os.path.join(root, safe_name))
|
|
if os.path.dirname(candidate) != root:
|
|
safe_name = sanitize_filename(os.path.basename(candidate)) or "Unnamed Playlist"
|
|
candidate = os.path.join(root, safe_name)
|
|
return candidate
|
|
|
|
|
|
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 i, real in enumerate(real_paths):
|
|
if not real:
|
|
continue
|
|
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
|
|
while name in used and used[name] != os.path.abspath(real):
|
|
counter += 1
|
|
name = f"{stem} ({counter}){ext}"
|
|
used[name] = os.path.abspath(real)
|
|
entries.append((os.path.abspath(real), os.path.join(playlist_dir, name)))
|
|
return entries
|
|
|
|
|
|
def _symlink_is_current(dest: str, rel_target: str) -> bool:
|
|
try:
|
|
return os.path.islink(dest) and os.readlink(dest) == rel_target
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _remove_entry(path: str) -> None:
|
|
"""Remove an existing file/symlink at ``path`` (incl. a broken symlink)."""
|
|
if os.path.islink(path) or os.path.exists(path):
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def materialize_one(
|
|
real_path: str,
|
|
dest_path: str,
|
|
mode: str = DEFAULT_MODE,
|
|
*,
|
|
symlink_fn: Callable[[str, str], None] = os.symlink,
|
|
copy_fn: Callable[[str, str], object] = shutil.copy2,
|
|
) -> str:
|
|
"""Create one playlist entry at ``dest_path`` pointing at ``real_path``.
|
|
|
|
Idempotent: a correct existing entry is left alone. In ``symlink`` mode a
|
|
relative link is used; if it can't be created (unsupported FS, no privilege)
|
|
it falls back to a copy so the entry is never left broken. Returns one of:
|
|
``'linked'``, ``'copied'``, ``'unchanged'``, ``'fellback'`` (symlink
|
|
requested but copied), ``'missing'`` (source gone)."""
|
|
if not real_path or not os.path.exists(real_path):
|
|
return "missing"
|
|
|
|
dest_dir = os.path.dirname(dest_path)
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
rel_target = os.path.relpath(os.path.abspath(real_path), start=dest_dir)
|
|
|
|
if mode == "copy":
|
|
if os.path.isfile(dest_path) and not os.path.islink(dest_path):
|
|
return "unchanged"
|
|
_remove_entry(dest_path)
|
|
copy_fn(real_path, dest_path)
|
|
return "copied"
|
|
|
|
# symlink mode
|
|
if _symlink_is_current(dest_path, rel_target):
|
|
return "unchanged"
|
|
_remove_entry(dest_path)
|
|
try:
|
|
symlink_fn(rel_target, dest_path)
|
|
return "linked"
|
|
except (OSError, NotImplementedError):
|
|
copy_fn(real_path, dest_path)
|
|
return "fellback"
|
|
|
|
|
|
def rebuild_playlist_folder(
|
|
playlists_root: str,
|
|
playlist_name: str,
|
|
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,
|
|
) -> RebuildSummary:
|
|
"""(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, dest_names)
|
|
keep = {dest for _real, dest in entries}
|
|
|
|
for real, dest in entries:
|
|
try:
|
|
outcome = materialize_one(real, dest, mode, symlink_fn=symlink_fn, copy_fn=copy_fn)
|
|
except OSError as e:
|
|
summary.failed += 1
|
|
summary.errors.append(f"{os.path.basename(dest)}: {e}")
|
|
continue
|
|
if outcome == "linked":
|
|
summary.linked += 1
|
|
elif outcome == "copied":
|
|
summary.copied += 1
|
|
elif outcome == "fellback":
|
|
summary.copied += 1
|
|
summary.fellback = True
|
|
elif outcome == "unchanged":
|
|
summary.unchanged += 1
|
|
elif outcome == "missing":
|
|
summary.missing_source += 1
|
|
|
|
if prune_stale and os.path.isdir(pdir):
|
|
for name in os.listdir(pdir):
|
|
full = os.path.join(pdir, name)
|
|
if full in keep:
|
|
continue
|
|
if os.path.islink(full) or os.path.isfile(full):
|
|
_remove_entry(full)
|
|
summary.removed_stale += 1
|
|
|
|
return summary
|
|
|
|
|
|
__all__ = [
|
|
"MATERIALIZE_MODES",
|
|
"DEFAULT_MODE",
|
|
"normalize_mode",
|
|
"RebuildSummary",
|
|
"playlist_dir_for",
|
|
"materialize_one",
|
|
"rebuild_playlist_folder",
|
|
]
|