Files
alembic/pipeline/lib/export-laptop-playlists.py
T
andrew b135f11557 Security hardening and first-run/portability improvements
Security (P0):
- Remove committed session-secret default; auto-generate and persist a
  random secret to the config volume when SESSION_SECRET is unset
  (prevents forgeable session cookies / auth bypass).
- Validate playlist names to a safe charset and render sldl configs via
  literal Python substitution instead of sed (closes a command-injection
  and path-traversal path through playlist names).
- shlex-quote credential values written to shell-sourced env files, and
  strip newlines from values patched into .conf files.
- Render playlist .conf files 0600; warn at startup if the master key is
  co-located with the config volume; document keeping it separate.

Portability:
- Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton.
- Remove personal defaults (navidrome user "andrew", ephemeral.club URLs).
- Ship generic example seeds; move the cross-album dedup keep-list and the
  legacy playlist import to editable config files; drop the personal
  _upgrade.csv.
- Generic VPN reference in docker-compose.snippet.yml.

First-run experience:
- Redirect to /setup instead of 500 when OIDC is unconfigured; surface a
  missing master key inline; entrypoint exits with an actionable message
  when the config folder is not writable.
- Add unauthenticated /health (JSON) and /setup (checklist) diagnostics.

Docs:
- Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced
  but missing); expand README with ownership, backups, advanced settings,
  and migration guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:25:55 -06:00

311 lines
12 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
export-laptop-playlists.py — daily Navidrome → Traktor M3U export.
Lists every Navidrome playlist whose name starts with `dj-`, fetches each
playlist's tracks via Subsonic, looks up the real on-disk path of each
track in beets, and writes a `.m3u` for Syncthing → Traktor.
Output dir: $MUSIC_DATA_DIR/playlists-laptop/
- One `dj-<name>.m3u` per Navidrome dj-* playlist (overwritten each run)
- Exact format Traktor Pro 4 itself uses when you Export Playlist as M3U:
one absolute Windows path per line (`C:\\Music\\Library\\...\\Track.flac`),
UTF-8 encoded, LF line endings, NO `#EXTM3U` header. Traktor's M3U
importer treats `#EXTM3U` as either a parse error or a phantom track
path, which kicks the rest of the import into "treat-as-new" mode and
the playlist ends up with unanalyzed records. With the header omitted,
Traktor parses each line as a LOCATION, matches against the existing
Collection by DIR+FILE+VOLUME, and the playlist inherits BPM, waveform,
and key from the already-analyzed entries.
Why beets, not Subsonic, for paths: Navidrome's Subsonic `path` attribute
is a *synthesized* string like `Artist/Album/01 - Title.flac` even when
the real file is just `Artist/Album/Title.flac`. The track-number prefix
is generated from metadata, not read from disk. Traktor then can't find
the file. Beets stores the real path, so we look up each track by
(album, title) in a one-shot beets index, falling back to artist
disambiguation when multiple albums share the same album+title.
Reconciles deletes: any `dj-*.m3u` on disk whose stem is no longer a
current Navidrome playlist is removed; also sweeps legacy `.m3u8`/`.nml`
files from earlier experiments.
Existing M3U files in `$MUSIC_DATA_DIR/playlists/` (Navidrome's M3U
import source for the automated Spotify pipeline) are NOT touched.
Usage:
export-laptop-playlists.py [--verbose]
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
OUT_DIR = f"{_MUSIC_DATA_DIR}/playlists-laptop"
# Absolute Windows path to the laptop's library root.
LIBRARY_LAPTOP_ROOT = r"C:\Music\Library"
# Beets stores paths with this prefix (the container-side mount of the
# library); strip it to get the path relative to the library root. This is
# still "/music/" through the migration's Stage 0-3 transitional mount;
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
BEETS_LIBRARY_PREFIX = "/music/"
M3U_EXT = ".m3u"
# Older formats from earlier iterations of this script — swept by reconcile.
LEGACY_EXTS = (".m3u8", ".nml")
def _load_navidrome_env():
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
env = {}
if os.path.exists(path):
for line in open(path):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return env
_nd_env = _load_navidrome_env()
ND_BASE = _nd_env.get("ND_BASE", "http://navidrome:4533")
ND_USER = _nd_env.get("ND_USER", "")
ND_PASS = _nd_env.get("ND_PASS", "")
ND_API_VERSION = "1.16.0"
ND_CLIENT = "dj-export"
NS = "{http://subsonic.org/restapi}"
# ASCII Unit Separator — safe field separator for beets `-f` output.
SEP = "\x1f"
def _call(endpoint: str, params: dict[str, str]) -> ET.Element:
qp = {
"u": ND_USER, "p": ND_PASS,
"v": ND_API_VERSION, "c": ND_CLIENT, "f": "xml",
**params,
}
url = f"{ND_BASE}/rest/{endpoint}.view?{urllib.parse.urlencode(qp)}"
with urllib.request.urlopen(url, timeout=60) as r:
body = r.read()
root = ET.fromstring(body)
if root.attrib.get("status") != "ok":
err = root.find(f"{NS}error")
msg = err.attrib.get("message", "unknown") if err is not None else "?"
raise RuntimeError(f"Subsonic {endpoint} failed: {msg}")
return root
def list_dj_playlists() -> list[tuple[str, str]]:
root = _call("getPlaylists", {})
out: list[tuple[str, str]] = []
for p in root.iter(f"{NS}playlist"):
name = p.attrib.get("name", "")
if name.startswith("dj-"):
out.append((p.attrib["id"], name))
return out
def get_playlist_tracks(pid: str) -> list[tuple[str, str, str]]:
"""Return (albumartist, album, title) tuples per playlist entry."""
root = _call("getPlaylist", {"id": pid})
out: list[tuple[str, str, str]] = []
for entry in root.iter(f"{NS}entry"):
aa = (entry.attrib.get("displayAlbumArtist")
or entry.attrib.get("artist", ""))
album = entry.attrib.get("album", "")
title = entry.attrib.get("title", "")
out.append((aa, album, title))
return out
def _normkey(s: str) -> str:
"""Lowercase + normalize curly quotes to straight ASCII."""
return (s.lower()
.replace("", "'").replace("", "'")
.replace("", '"').replace("", '"'))
def build_beets_index() -> dict[tuple[str, str], list[tuple[str, str, str, str]]]:
"""Map (album, title) →
[(albumartist, album, title, library-relative path), ...].
Album and title are returned with original casing (not _normkey'd) so the
NML COLLECTION entries can carry the real metadata for display."""
fmt = SEP.join(["$albumartist", "$album", "$title", "$path"])
res = subprocess.run(
["beet", "ls", "-f", fmt],
capture_output=True, text=True, check=True
)
idx: dict[tuple[str, str], list[tuple[str, str, str, str]]] = {}
for ln in res.stdout.splitlines():
parts = ln.split(SEP)
if len(parts) != 4:
continue
aa, alb, ti, path = parts
if not path.startswith(BEETS_LIBRARY_PREFIX):
continue
rel = path[len(BEETS_LIBRARY_PREFIX):]
idx.setdefault((_normkey(alb), _normkey(ti)), []).append((aa, alb, ti, rel))
return idx
def resolve(idx: dict[tuple[str, str], list[tuple[str, str, str, str]]],
aa_query: str, album: str, title: str
) -> tuple[str, str, str, str] | None:
"""Return (albumartist, album, title, rel_path) for a Subsonic track, or None."""
candidates = idx.get((_normkey(album), _normkey(title)))
if not candidates:
return None
if len(candidates) == 1:
return candidates[0]
q = _normkey(aa_query).replace("", " ").replace(";", " ")
for c in candidates:
if _normkey(c[0]) in q:
return c
return candidates[0]
def to_windows_path(rel: str) -> str:
"""Library-relative POSIX path → absolute Windows path with backslashes."""
return LIBRARY_LAPTOP_ROOT + "\\" + rel.replace("/", "\\")
def write_m3u(name: str, tracks: list[tuple[str, str, str, str]], verbose: bool) -> int:
"""Plain-path M3U, no `#EXTM3U` header — matches the format Traktor
Pro 4 itself emits via Export Playlist → M3U. With this byte-exact
format, Traktor's import re-uses existing analyzed Collection entries
(matched by DIR+FILE+VOLUME) instead of creating fresh unanalyzed
records."""
target = os.path.join(OUT_DIR, f"{name}{M3U_EXT}")
tmp = target + ".tmp"
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
for _aa, _alb, _ti, rel in tracks:
f.write(to_windows_path(rel) + "\n")
os.replace(tmp, target)
if verbose:
print(f" wrote {target} ({len(tracks)} tracks)")
return len(tracks)
def reconcile(current_names: set[str], verbose: bool) -> int:
"""Delete dj-*.m3u files whose stem is no longer current, and sweep
any legacy .m3u8/.nml leftovers from earlier iterations."""
removed = 0
if not os.path.isdir(OUT_DIR):
return 0
for entry in os.scandir(OUT_DIR):
if not entry.is_file() or not entry.name.startswith("dj-"):
continue
if entry.name.endswith(M3U_EXT):
stem = entry.name[:-len(M3U_EXT)]
if stem in current_names:
continue
elif any(entry.name.endswith(e) for e in LEGACY_EXTS):
pass # always sweep
else:
continue
os.remove(entry.path)
removed += 1
if verbose:
print(f" removed stale {entry.path}")
return removed
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
print(f"[{stamp}] export-laptop-playlists starting")
idx = build_beets_index()
print(f" beets index: {len(idx)} (album,title) keys")
playlists = list_dj_playlists()
print(f" found {len(playlists)} dj-* playlists in Navidrome")
# Safety guard: if Navidrome answers successfully but reports ZERO dj-*
# playlists while exported files exist on disk, something is wrong on the
# Navidrome side (fresh/restored DB, purge, renamed playlists). Running
# reconcile() would delete every dj-*.m3u and Syncthing would propagate
# the deletions to the laptop — the 2026-06-01 Traktor wipe through the
# other door. Refuse and keep the files.
if not playlists:
existing = [e.name for e in os.scandir(OUT_DIR)
if e.is_file() and e.name.startswith("dj-")]
if existing:
print(f" WARN: Navidrome returned 0 dj-* playlists but "
f"{len(existing)} dj-* file(s) exist in {OUT_DIR}"
f"REFUSING to reconcile/delete. Check Navidrome before "
f"trusting this.")
return 0
total_tracks = 0
total_unresolved = 0
written = 0
skipped = 0
for pid, name in playlists:
try:
tracks = get_playlist_tracks(pid)
except Exception as e:
print(f" WARN: failed to fetch {name}: {e}")
continue
resolved: list[tuple[str, str, str, str]] = []
unresolved: list[tuple[str, str, str]] = []
for aa, album, title in tracks:
hit = resolve(idx, aa, album, title)
if hit is None:
unresolved.append((aa, album, title))
continue
resolved.append(hit)
if unresolved:
total_unresolved += len(unresolved)
print(f" WARN: {name}: {len(unresolved)} track(s) not in beets index "
f"(skipped). First: {unresolved[0]}")
# Safety guard: never blow away a populated .m3u with an empty export.
# On 2026-06-01 a PURGEMISSING scan emptied the Navidrome dj-* playlists
# while the QNAP share was flaky; this script then overwrote every
# laptop .m3u with 0 tracks, and the laptop's auto-merge wiped Traktor.
# If Navidrome resolves to 0 tracks but a non-empty file already exists,
# keep the file and warn loudly instead of trusting the empty result.
if not resolved:
target = os.path.join(OUT_DIR, f"{name}{M3U_EXT}")
if os.path.exists(target) and os.path.getsize(target) > 0:
print(f" WARN: {name}: Navidrome returned 0 resolvable tracks but "
f"{target} is non-empty — REFUSING to overwrite (kept existing). "
f"Check Navidrome (likely a purge/scan glitch) before trusting this.")
skipped += 1
continue
total_tracks += write_m3u(name, resolved, args.verbose)
written += 1
current_names = {name for _pid, name in playlists}
removed = reconcile(current_names, args.verbose)
print(f"[{stamp}] done. wrote {written} playlists, {total_tracks} total tracks, "
f"{total_unresolved} unresolved, {skipped} kept (empty-guard), "
f"removed {removed} stale files")
return 0
if __name__ == "__main__":
sys.exit(main())