62251d764e
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>
315 lines
12 KiB
Python
Executable File
315 lines
12 KiB
Python
Executable File
#!/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())
|