Files
alembic/pipeline/lib/recover-azuracast-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

316 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
recover-azuracast-playlists.py — recover playlist assignment for files that
lost their GROUPING tag (e.g. from a pre-tag-copy upgrade-mp3-to-flac.sh run).
For each AzuraCast file currently unassigned to any playlist:
1. Resolve the source playlist by matching (artist, title) against the
Spotify playlists named in $ALEMBIC_CONFIG_DIR/pipeline/*.conf.
2. Write GROUPING=<playlist> onto the FLAC/MP3 file.
3. `beet update` so beets sees the new tag.
4. Assign the file to the matching AzuraCast playlist via API.
Default is dry-run. Pass --apply to actually write tags + assign.
Env / args:
--azuracast-key KEY AzuraCast API key (required)
--azuracast-base URL default from AZURACAST_BASE env (empty if unset)
--station N default 1
--apply actually act (default is dry-run)
--only PLAYLIST limit recovery to one playlist (testing)
"""
import argparse, base64, json, os, re, subprocess, sys, urllib.parse, urllib.request
from difflib import SequenceMatcher
from pathlib import Path
CONF_DIR = Path(f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline")
LIB_ROOT_HOST = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library"
# Still "/music" through the migration's Stage 0-3 transitional beets mount;
# revisit at Stage 4 once beets' directory: becomes MUSIC_DATA_DIR/Library.
LIB_ROOT_CONTAINER = "/music"
def normalize(s: str) -> str:
s = (s or "").lower()
s = re.sub(r"\s*\([^)]*\)", "", s)
s = re.sub(r"\s*\[[^\]]*\]", "", s)
s = re.sub(r"\s*-?\s*(extended|original|radio|club|vip|vocal)\s*(mix|edit|version)\s*$", "", s)
s = re.sub(r"\s*feat\.?\s.*", "", s)
s = re.sub(r"\s*ft\.?\s.*", "", s)
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def read_conf(path: Path) -> dict:
out = {}
for line in path.read_text().splitlines():
m = re.match(r"\s*([a-z0-9_-]+)\s*=\s*(.*?)\s*$", line)
if m: out[m.group(1)] = m.group(2)
return out
def spotify_token(cid: str, csec: str) -> str:
creds = base64.b64encode(f"{cid}:{csec}".encode()).decode()
req = urllib.request.Request(
"https://accounts.spotify.com/api/token",
data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
headers={"Authorization": f"Basic {creds}",
"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())["access_token"]
def fetch_spotify_playlist(url: str, token: str) -> list[tuple[str, str]]:
"""Return list of (artists_normalized_joined, title_normalized)."""
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", url)
if not m: return []
pid = m.group(1)
out = []
next_url = f"https://api.spotify.com/v1/playlists/{pid}/tracks?limit=100"
while next_url:
req = urllib.request.Request(next_url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req, timeout=20) as r:
data = json.loads(r.read())
for item in data["items"]:
t = item.get("track")
if not t or t.get("is_local"): continue
artists = [a["name"] for a in t["artists"]]
out.append((artists, t["name"]))
next_url = data.get("next")
return out
def beets_dump() -> dict:
"""Return path → {grouping, artist, albumartist, album, title}"""
proc = subprocess.run(
["beet", "ls", "-f",
"$path\t$grouping\t$artist\t$albumartist\t$album\t$title"],
capture_output=True, text=True, check=True)
out = {}
for line in proc.stdout.splitlines():
parts = line.split("\t")
if len(parts) < 6: continue
out[parts[0]] = {
"grouping": parts[1],
"artist": parts[2],
"albumartist": parts[3],
"album": parts[4],
"title": parts[5],
}
return out
def az_request(base: str, key: str, path: str, method="GET", body=None) -> dict:
url = f"{base}{path}"
headers = {"X-API-Key": key, "Accept": "application/json"}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def az_all_files(base: str, key: str, station: int) -> list[dict]:
out = []
page = 1
while True:
d = az_request(base, key, f"/api/station/{station}/files?per_page=500&page={page}")
out.extend(d["rows"])
if page >= d["total_pages"]: break
page += 1
return out
def az_playlists_by_name(base: str, key: str, station: int) -> dict:
return {p["name"]: p["id"]
for p in az_request(base, key, f"/api/station/{station}/playlists")}
def write_grouping(host_path: str, grouping: str) -> bool:
"""Write GROUPING tag in-place. Return True on success."""
ext = host_path.rsplit(".", 1)[-1].lower()
if ext == "flac":
subprocess.run(["metaflac", "--remove-tag=GROUPING", host_path],
capture_output=True, check=False)
return subprocess.run(["metaflac", f"--set-tag=GROUPING={grouping}", host_path],
capture_output=True).returncode == 0
if ext == "mp3":
return subprocess.run(["id3v2", "--TIT1", grouping, host_path],
capture_output=True).returncode == 0
return False
def beet_update(container_paths: list[str], log) -> None:
"""Re-read tags from disk into beets DB for the given container paths.
Uses `path:<exact>` query (not `path::<regex>`) and pipes "y" since
beet update prompts interactively for each change."""
if not container_paths: return
for cp in container_paths:
subprocess.run(
["beet", "update", "--nomove", f"path:{cp}"],
input="y\n", capture_output=True, text=True, check=False)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--azuracast-key", required=True)
ap.add_argument("--azuracast-base", default=os.environ.get("AZURACAST_BASE", ""))
ap.add_argument("--station", type=int, default=1)
ap.add_argument("--apply", action="store_true")
ap.add_argument("--only", default=None, help="limit recovery to one playlist name")
args = ap.parse_args()
print("[1/5] Loading beets metadata...", flush=True)
beets = beets_dump()
print(f" {len(beets)} tracks in beets")
print("[2/5] Fetching Spotify playlist contents...", flush=True)
# Build (title_norm) → list of (playlist_name, artists_list)
title_idx: dict[str, list[tuple[str, list[str]]]] = {}
confs = sorted(CONF_DIR.glob("*.conf"))
confs = [c for c in confs if not c.name.startswith("_")]
if not confs:
print(f"ERROR: no *.conf files in {CONF_DIR}/", file=sys.stderr); return 1
first = read_conf(confs[0])
token = spotify_token(first["spotify-id"], first["spotify-secret"])
pl_count = 0
for c in confs:
cfg = read_conf(c)
url = cfg.get("input"); pname = c.stem
if not url or not url.startswith("https://open.spotify.com/"): continue
try:
tracks = fetch_spotify_playlist(url, token)
except Exception as e:
print(f" WARN: {pname}: {e}"); continue
for artists, title in tracks:
title_idx.setdefault(normalize(title), []).append((pname, artists))
pl_count += 1
print(f" {pname}: {len(tracks)} tracks")
print(f" indexed {pl_count} playlists, {sum(len(v) for v in title_idx.values())} title→playlist entries")
print("[3/5] Fetching AzuraCast playlists + files...", flush=True)
playlists_by_name = az_playlists_by_name(args.azuracast_base, args.azuracast_key, args.station)
print(f" {len(playlists_by_name)} AzuraCast playlists")
all_files = az_all_files(args.azuracast_base, args.azuracast_key, args.station)
unassigned = [r for r in all_files if not r.get("playlists")]
print(f" {len(all_files)} total files, {len(unassigned)} unassigned")
print("[4/5] Matching unassigned files to playlists...", flush=True)
matches: list[dict] = [] # rows with .target_playlist
unmatched: list[dict] = []
multi_match: list[dict] = [] # ambiguous
no_az_playlist: list[dict] = []
for r in unassigned:
cp = f"{LIB_ROOT_CONTAINER}/{r['path']}"
bmeta = beets.get(cp, {})
# 1) trust beets GROUPING if present
target = bmeta.get("grouping", "").strip() if bmeta else ""
source = "beets_grouping" if target else None
if not target:
# 2) resolve via Spotify (artist, title) match
title_n = normalize(bmeta.get("title") or r.get("title") or "")
file_artists = bmeta.get("artist") or r.get("artist") or ""
candidates = title_idx.get(title_n, [])
if not candidates:
unmatched.append({"id": r["id"], "path": r["path"], "reason": "no spotify title hit"})
continue
# Score candidates by artist overlap
file_artist_n = normalize(file_artists)
scored = []
for pname, artists in candidates:
joined_n = normalize("; ".join(artists))
score = SequenceMatcher(None, file_artist_n, joined_n).ratio()
scored.append((score, pname))
scored.sort(reverse=True)
top_score, top_pname = scored[0]
if top_score < 0.4:
unmatched.append({"id": r["id"], "path": r["path"], "reason": f"low artist score {top_score:.2f}"})
continue
# If second-best is within 0.05, it's ambiguous between playlists
distinct_playlists = {p for _, p in scored}
if len(distinct_playlists) > 1 and len(scored) > 1 and scored[1][0] > top_score - 0.05 and scored[1][1] != top_pname:
multi_match.append({"id": r["id"], "path": r["path"], "candidates": [p for _, p in scored[:3]]})
continue
target = top_pname
source = "spotify_match"
if args.only and target != args.only:
continue
if target not in playlists_by_name:
no_az_playlist.append({"id": r["id"], "path": r["path"], "target": target})
continue
matches.append({
"id": r["id"], "path": r["path"], "target": target, "source": source,
"host_path": f"{LIB_ROOT_HOST}/{r['path']}",
"container_path": cp,
"playlist_id": playlists_by_name[target],
})
# Report
by_target: dict[str, int] = {}
for m in matches:
by_target[m["target"]] = by_target.get(m["target"], 0) + 1
print()
print(f"=== Match summary ===")
print(f" matched: {len(matches)}")
for pname, n in sorted(by_target.items(), key=lambda x: -x[1]):
print(f" {pname}: {n}")
print(f" unmatched (no spotify hit): {len(unmatched)}")
print(f" ambiguous (multiple playlists tied): {len(multi_match)}")
print(f" no matching AzuraCast playlist: {len(no_az_playlist)}")
if unmatched[:5]:
print(" unmatched sample:")
for u in unmatched[:5]:
print(f" {u['path']} ({u['reason']})")
if multi_match[:5]:
print(" ambiguous sample:")
for u in multi_match[:5]:
print(f" {u['path']} candidates={u['candidates']}")
if not args.apply:
print()
print("DRY RUN — pass --apply to (a) write GROUPING tags, (b) beet update, (c) assign in AzuraCast")
return 0
print()
print("[5/5] Applying...", flush=True)
tag_ok = 0; tag_fail = 0
az_ok = 0; az_fail = 0
for m in matches:
# Write tag
if write_grouping(m["host_path"], m["target"]):
tag_ok += 1
else:
tag_fail += 1
print(f" TAG-FAIL {m['host_path']}")
# Assign in AzuraCast: PUT /api/station/N/file/{id} with playlists field.
# Send ONLY the playlists key — sending the full record back causes
# AzuraCast to reject with HTTP 500 (validation fails on derived fields).
try:
az_request(args.azuracast_base, args.azuracast_key,
f"/api/station/{args.station}/file/{m['id']}",
method="PUT", body={"playlists": [{"id": m["playlist_id"]}]})
az_ok += 1
except Exception as e:
az_fail += 1
print(f" AZ-FAIL {m['path']}: {e}")
print(f" tags written: {tag_ok} (failed: {tag_fail})")
print(f" azuracast assigned: {az_ok} (failed: {az_fail})")
# beet update so beets DB sees the new GROUPING
print(" running beet update on changed paths...")
beet_update([m["container_path"] for m in matches], None)
print(" done")
return 0
if __name__ == "__main__":
sys.exit(main())