Files
alembic/pipeline/lib/export-laptop-playlists.py
T
andrew 62251d764e 0.6.9: Finish the Stage 4 path revisit in the three remaining scripts
The 2026-07-08 path rewrite changed beets' displayed paths from the
transitional /music/... mount to real /data/music/Library/... paths.
dedup-library.sh was fixed in 0.6.8; three more scripts still assumed
the old prefix, each failing silently:

- replace-with-better.sh resolved every library match to a doubled
  nonexistent path, logged [stale-db], skipped the in-place replace,
  and then imported the staged FLAC as a NEW track via the
  leftover-import step. Net effect: the mp3-to-flac upgrade flow
  produced flac+mp3 twin pairs in the library (surfacing in the dedup
  queue) instead of replacing the MP3 in place.

- fix-track-metadata.py queried beets only by the legacy /music/...
  form, matched nothing, and silently skipped beet update/move after
  retagging.

- export-laptop-playlists.py filtered out every library track (no
  displayed path starts with /music/ anymore), producing empty
  exports.

All three now use the real displayed path and keep the /music/... form
only as a legacy fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 11:51:55 -06:00

315 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"
# Prefixes beets may display library paths under, tried in order: the real
# library dir (everything since the 2026-07-08 path rewrite) and the legacy
# transitional mount (any stale pre-rewrite entry). Strip whichever matches
# to get the path relative to the library root; when neither matches the
# track is outside the library and is skipped.
BEETS_LIBRARY_PREFIXES = (f"{_MUSIC_DATA_DIR}/Library/", "/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
rel = next(
(path[len(p):] for p in BEETS_LIBRARY_PREFIXES if path.startswith(p)),
None,
)
if rel is None:
continue
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())