b135f11557
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>
316 lines
13 KiB
Python
Executable File
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())
|