Archive dead/manual scripts to pipeline/lib/manual (R8)

Move six scripts that are not wired into the app, scheduler, or web UI out of
the active pipeline/lib into pipeline/lib/manual, so the scheduled scripts are
easy to see and these stay available for hands-on use: convert-m4a.sh,
fix-empty-album.sh, backfill-spotify-tags.sh, backfill-buy-url.py,
recover-azuracast-playlists.py, import-dj-collection.py. Adds a README
explaining each, and extends the Dockerfile chmod to cover the new folder.
Nothing referenced these from active code (verified), so no wiring changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-10 10:52:44 -06:00
parent db13890fe1
commit 2d7243332d
8 changed files with 32 additions and 1 deletions
+31
View File
@@ -0,0 +1,31 @@
# Manual / archived scripts
These scripts are kept for occasional hands-on use but are **not** wired into
the app, the scheduler, or the web UI. Nothing here runs automatically. They
live outside `pipeline/lib/` so the active, scheduled scripts there stay easy
to see at a glance.
Run one by hand inside the container when you actually need it, e.g.:
```bash
docker exec -it alembic bash /app/pipeline/lib/manual/convert-m4a.sh
docker exec -it alembic python /app/pipeline/lib/manual/import-dj-collection.py --help
```
What's here and why it's manual-only:
- **convert-m4a.sh** — one-off bulk M4A to FLAC/MP3 conversion. Not part of the
normal download flow.
- **fix-empty-album.sh** — repairs tracks with an empty album tag. A cleanup
tool for a specific past problem, not a recurring job.
- **backfill-spotify-tags.sh** — re-pulls Spotify tags for already-imported
tracks. A migration/backfill aid.
- **backfill-buy-url.py** — one-time backfill of buy-link tags across the
library (the scheduled `enrich-buy-url.py` handles new tracks going forward).
- **recover-azuracast-playlists.py** — rebuilds AzuraCast playlist assignments
after a GROUPING-tag loss. Recovery tool, only relevant if you run AzuraCast.
- **import-dj-collection.py** — bulk-imports a personal DJ set collection with
fingerprint dedup against the existing library. A large one-off importer.
These have not all been hardened with `set -euo pipefail`; treat them as
run-and-watch tools rather than unattended automation.
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
backfill-buy-url.py — stamp the Bandcamp purchase URL onto library files for
albums/tracks already imported from a Bandcamp purchase.
The forward path (sync-bandcamp.py) tags new purchases at download time. This
one-shot covers everything bought *before* that change landed. It:
1. Pulls the current Bandcamp collection (cookies auth) and builds an index
keyed by (album_artist, album) -> release URL. (Bandcamp's embedded ALBUM
tag matches the collection item_title exactly, and ALBUMARTIST matches the
band name — verified against the library.)
2. Walks the FLAC library, reads ALBUMARTIST/ALBUM, and where a match exists,
writes the COMMERCIAL_INFORMATION Vorbis comment (AzuraCast maps this to
the buy_url custom field — see sync-bandcamp.py BUY_URL_TAG).
3. Optionally tells AzuraCast to reprocess each touched file so the custom
field repopulates without waiting for the periodic media scan.
Dry-run by default. Stdlib only (mutagen NOT required — uses metaflac).
Usage:
backfill-buy-url.py --cookies F --state F [--library DIR] [--apply]
[--azuracast-key ID:SECRET] [--azuracast-base URL]
[--station N] [--force]
"""
import sys, os, re, json, time, html, argparse, subprocess
import urllib.request
from http.cookiejar import MozillaCookieJar
from pathlib import Path
COLLECTION_ITEMS_URL = "https://bandcamp.com/api/fancollection/1/collection_items"
UA = "Mozilla/5.0 (X11; Linux x86_64) backfill-buy-url/1.0"
BUY_URL_TAG = "COMMERCIAL_INFORMATION"
# ---- Bandcamp collection ---------------------------------------------------
def opener(jar):
return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get_fan_id(username, jar):
req = urllib.request.Request(f"https://bandcamp.com/{username}",
headers={"User-Agent": UA})
page = html.unescape(opener(jar).open(req, timeout=60).read().decode("utf-8", "replace"))
m = re.search(r'"fan_id"\s*:\s*"?(\d+)', page)
if not m:
sys.exit("[backfill] could not find fan_id (cookies expired / wrong user?)")
return int(m.group(1))
def fetch_collection(fan_id, jar):
items = []
tok = "9999999999::a::"
while True:
body = json.dumps({"fan_id": fan_id, "older_than_token": tok, "count": 100}).encode()
req = urllib.request.Request(
COLLECTION_ITEMS_URL, data=body,
headers={"User-Agent": UA, "Content-Type": "application/json",
"Accept": "application/json", "Referer": "https://bandcamp.com/"})
data = json.loads(opener(jar).open(req, timeout=60).read())
page = data.get("items", [])
items.extend(page)
if not data.get("more_available") or not page:
break
nt = data.get("last_token")
if not nt or nt == tok:
break
tok = nt
return items
# ---- matching --------------------------------------------------------------
def norm(s):
return re.sub(r"\s+", " ", (s or "").strip()).casefold()
def build_index(items):
"""(album_artist, album) -> url. Index both the bare title and the
"<title> - Single" form (single-track purchases get that ALBUM tag)."""
idx = {}
for it in items:
url = it.get("item_url")
band = it.get("band_name")
title = it.get("item_title")
if not (url and band and title):
continue
for album in (title, f"{title} - Single"):
idx.setdefault((norm(band), norm(album)), url)
return idx
def flac_tag(path, name):
r = subprocess.run(["metaflac", f"--show-tag={name}", path],
capture_output=True, text=True)
for line in r.stdout.splitlines():
if "=" in line:
return line.split("=", 1)[1]
return ""
def set_flac_tag(path, name, value):
subprocess.run(["metaflac", f"--remove-tag={name}", path], capture_output=True)
return subprocess.run(["metaflac", f"--set-tag={name}={value}", path],
capture_output=True).returncode == 0
# ---- AzuraCast reprocess ---------------------------------------------------
def az_reprocess(base, key, station, host_paths, log):
"""Find each host path in AzuraCast and batch-reprocess so the custom
field repopulates. host_path -> relative AzuraCast path is library-root
stripped."""
root = f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library/"
want = {p[len(root):] for p in host_paths if p.startswith(root)}
headers = {"X-API-Key": key, "Accept": "application/json"}
def get(path):
req = urllib.request.Request(base + path, headers=headers)
return json.loads(urllib.request.urlopen(req, timeout=60).read())
unique_ids, page = [], 1
while True:
d = get(f"/api/station/{station}/files?per_page=500&page={page}")
for row in d["rows"]:
if row["path"] in want:
unique_ids.append(row["unique_id"])
if page >= d["total_pages"]:
break
page += 1
if not unique_ids:
log(" no matching AzuraCast files to reprocess")
return
body = json.dumps({"do": "reprocess", "files": unique_ids}).encode()
req = urllib.request.Request(base + f"/api/station/{station}/files/batch",
data=body, method="PUT",
headers={**headers, "Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=120).read()
log(f" queued reprocess for {len(unique_ids)} AzuraCast file(s)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--cookies", required=True)
ap.add_argument("--state", required=True, help="state.json (for reference/logging)")
ap.add_argument("--library", default=f"{os.environ.get('MUSIC_DATA_DIR', '/data/music')}/Library")
ap.add_argument("--apply", action="store_true")
ap.add_argument("--force", action="store_true",
help="overwrite an existing buy-link tag")
ap.add_argument("--azuracast-key", default=None)
ap.add_argument("--azuracast-base", default=os.environ.get("AZURACAST_BASE", ""))
ap.add_argument("--station", type=int, default=1)
ap.add_argument("--username", default=None,
help="Bandcamp username; default read from config.env if present")
args = ap.parse_args()
user = args.username
if not user:
cfg = f"{os.environ.get('ALEMBIC_CONFIG_DIR', '/config')}/pipeline/bandcamp/config.env"
if os.path.exists(cfg):
for line in Path(cfg).read_text().splitlines():
if line.startswith("BANDCAMP_USERNAME="):
user = line.split("=", 1)[1].strip().strip('"')
if not user:
sys.exit("[backfill] --username required (no config.env BANDCAMP_USERNAME)")
print(f"[backfill] mode={'APPLY' if args.apply else 'DRY RUN'} user={user!r}")
jar = MozillaCookieJar(args.cookies)
jar.load(ignore_discard=True, ignore_expires=True)
fan_id = get_fan_id(user, jar)
items = fetch_collection(fan_id, jar)
idx = build_index(items)
print(f"[backfill] collection: {len(items)} items, {len(idx)} match keys")
flacs = list(Path(args.library).rglob("*.flac"))
print(f"[backfill] scanning {len(flacs)} FLAC files...\n")
matched = tagged = already = unmatched = 0
touched = []
for p in flacs:
sp = str(p)
albumartist = flac_tag(sp, "ALBUMARTIST") or flac_tag(sp, "ARTIST")
album = flac_tag(sp, "ALBUM")
url = idx.get((norm(albumartist), norm(album)))
if not url:
unmatched += 1
continue
matched += 1
existing = flac_tag(sp, BUY_URL_TAG)
if existing and not args.force:
already += 1
continue
rel = sp[len(args.library):].lstrip("/")
print(f" + {rel}\n -> {url}")
if args.apply:
if set_flac_tag(sp, BUY_URL_TAG, url):
tagged += 1
touched.append(sp)
else:
print(" ! metaflac write failed")
else:
tagged += 1
touched.append(sp)
print(f"\n[backfill] matched={matched} {'tagged' if args.apply else 'would-tag'}={tagged}"
f" already-had={already} unmatched-files={unmatched}")
if args.apply and touched and args.azuracast_key:
print("[backfill] telling AzuraCast to reprocess touched files...")
az_reprocess(args.azuracast_base, args.azuracast_key, args.station,
touched, lambda m: print(m))
elif not args.apply:
print("[backfill] re-run with --apply to write (add --azuracast-key to reprocess)")
if __name__ == "__main__":
main()
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# One-shot backfill: rewrite tags from Spotify for every track currently in the
# beets library that has a grouping:<playlist> tag matching one of our configs.
#
# After the rewrite, sync beets DB from file tags, then move files to clean
# paths based on the new tags, then trigger a Navidrome rescan.
set -u
CONFIGS="${ALEMBIC_CONFIG_DIR:-/config}/pipeline"
LOG="${ALEMBIC_CONFIG_DIR:-/config}/logs/backfill-$(date +%Y%m%d-%H%M%S).log"
RETAG="${PIPELINE_DIR:-/app/pipeline}/lib/spotify-retag.py"
mkdir -p "$(dirname "$LOG")"
echo "[$(date -Iseconds)] === Backfill start ===" | tee -a "$LOG"
TOTAL_TRACKS=0
TOTAL_PLAYLISTS=0
for conf in "$CONFIGS"/*.conf; do
[[ "$conf" == "$CONFIGS/_template.conf" ]] && continue
playlist=$(basename "$conf" .conf)
url=$(sed -n 's/^input *= *//p' "$conf" | tr -d ' ')
cid=$(sed -n 's/^spotify-id *= *//p' "$conf" | tr -d ' ')
csec=$(sed -n 's/^spotify-secret *= *//p' "$conf" | tr -d ' ')
if [[ -z "$url" || -z "$cid" || -z "$csec" ]]; then
echo "[$playlist] SKIP: missing url/creds in conf" | tee -a "$LOG"
continue
fi
# beets runs in-process in this same container, so its reported paths
# need no container-to-host translation.
paths=$(beet ls -f '$path' "grouping:$playlist" 2>/dev/null)
count=$(echo -n "$paths" | grep -c '^' || true)
if [[ "$count" -eq 0 ]]; then
echo "[$playlist] no tracks tagged in beets — skip" | tee -a "$LOG"
continue
fi
echo "[$playlist] retagging $count tracks via Spotify" | tee -a "$LOG"
if echo "$paths" | SPOTIFY_CLIENT_ID="$cid" SPOTIFY_CLIENT_SECRET="$csec" \
python3 "$RETAG" "$url" - >> "$LOG" 2>&1; then
echo "[$playlist] retag OK" | tee -a "$LOG"
TOTAL_PLAYLISTS=$((TOTAL_PLAYLISTS + 1))
TOTAL_TRACKS=$((TOTAL_TRACKS + count))
else
echo "[$playlist] retag FAILED — see $LOG" | tee -a "$LOG"
fi
done
echo "[$(date -Iseconds)] === Retagged $TOTAL_TRACKS tracks across $TOTAL_PLAYLISTS playlists ===" | tee -a "$LOG"
echo "[$(date -Iseconds)] Syncing beets DB from file tags (beet update)..." | tee -a "$LOG"
beet update -F path 2>&1 | tail -20 >> "$LOG"
echo "[$(date -Iseconds)] Moving files into clean paths (beet move)..." | tee -a "$LOG"
beet move 2>&1 | tail -5 >> "$LOG"
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
ND_BASE="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-}"
ND_PASS="${ND_PASS:-}"
echo "[$(date -Iseconds)] Triggering Navidrome full scan" | tee -a "$LOG"
curl -s -G "$ND_BASE/rest/startScan.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' \
--data-urlencode 'c=backfill' \
--data-urlencode 'f=json' \
--data-urlencode 'fullScan=true' >> "$LOG"
echo "[$(date -Iseconds)] === Backfill complete. Log: $LOG ===" | tee -a "$LOG"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Convert m4a files in-place: AAC -> MP3 (V0 VBR), ALAC -> FLAC (lossless).
# Preserves metadata + embedded cover art. Deletes original on success.
set -u
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/convert-m4a-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
OK_AAC=0
OK_ALAC=0
FAIL=0
while IFS= read -r -d '' f; do
codec=$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "$f" 2>/dev/null | tr -d ',')
base="${f%.m4a}"
case "$codec" in
aac)
out="${base}.mp3"
if [[ -e "$out" ]]; then log "SKIP-EXISTS: $out"; FAIL=$((FAIL+1)); continue; fi
if ffmpeg -nostdin -hide_banner -loglevel error -i "$f" \
-map 0 -c:v copy -c:a libmp3lame -q:a 0 -id3v2_version 3 \
"$out" 2>>"$LOG"; then
rm -f "$f"
log "AAC→MP3: $out"
OK_AAC=$((OK_AAC+1))
else
log "FAIL-AAC: $f"
rm -f "$out" 2>/dev/null
FAIL=$((FAIL+1))
fi
;;
alac)
out="${base}.flac"
if [[ -e "$out" ]]; then log "SKIP-EXISTS: $out"; FAIL=$((FAIL+1)); continue; fi
if ffmpeg -nostdin -hide_banner -loglevel error -i "$f" \
-map 0 -c:v copy -c:a flac -compression_level 8 \
"$out" 2>>"$LOG"; then
rm -f "$f"
log "ALAC→FLAC: $out"
OK_ALAC=$((OK_ALAC+1))
else
log "FAIL-ALAC: $f"
rm -f "$out" 2>/dev/null
FAIL=$((FAIL+1))
fi
;;
*)
log "SKIP-OTHER ($codec): $f"
;;
esac
done < <(find ${MUSIC_DATA_DIR:-/data/music}/Library -type f -iname "*.m4a" -print0 2>/dev/null)
log ""
log "=== AAC→MP3: $OK_AAC | ALAC→FLAC: $OK_ALAC | Failed: $FAIL ==="
log "Log: $LOG"
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Find audio files in the library that have no ALBUM tag, set ALBUM to
# "{TITLE} - Single" — matches Spotify / Apple Music's convention for
# single-track releases. Otherwise these tracks land as [Unknown Album]
# in Navidrome.
#
# Usage:
# fix-empty-album.sh # dry run (default)
# fix-empty-album.sh --apply # actually rewrite tags
#
# Bandcamp single-track downloads are the most common source of this
# issue (Bandcamp serves single tracks with no ALBUM tag). New downloads
# via sync-bandcamp.py are auto-tagged; this script handles backfill.
set -u
APPLY=0
[[ "${1:-}" == "--apply" ]] && APPLY=1
LOG=${ALEMBIC_CONFIG_DIR:-/config}/logs/fix-empty-album-$(date +%Y%m%d-%H%M%S).log
mkdir -p "$(dirname "$LOG")"
NAVIDROME_ENV="${ALEMBIC_CONFIG_DIR:-/config}/pipeline/navidrome/admin.env"
[ -f "$NAVIDROME_ENV" ] && source "$NAVIDROME_ENV"
ND_BASE="${ND_BASE:-http://navidrome:4533}"
ND_USER="${ND_USER:-}"
ND_PASS="${ND_PASS:-}"
log() { echo "[$(date -Iseconds)] $*" | tee -a "$LOG"; }
if [[ $APPLY -eq 1 ]]; then
log "=== FIX EMPTY ALBUM (APPLY) ==="
else
log "=== FIX EMPTY ALBUM (DRY RUN — pass --apply to write) ==="
fi
FIXED=0
SKIPPED=0
while IFS= read -r -d '' f; do
ext="${f,,}"; ext="${ext##*.}"
album="" ; title=""
case "$ext" in
flac)
album=$(metaflac --show-tag=ALBUM "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
title=$(metaflac --show-tag=TITLE "$f" 2>/dev/null | sed -n 's/^[^=]*=//p' | head -1)
;;
mp3)
album=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TALB[^:]*: //p' | head -1)
title=$(id3v2 -l "$f" 2>/dev/null | sed -n 's/^TIT2[^:]*: //p' | head -1)
;;
*) continue ;;
esac
if [[ -n "$album" ]]; then
continue # already has an album, leave it alone
fi
if [[ -z "$title" ]]; then
log "[skip-no-title] $f"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Some titles have a leading "NN " track-number prefix baked in from a
# bad filename parse (e.g. title="01 Domestic Violence"). If we use the
# title as-is, the album becomes "01 Domestic Violence - Single" which
# is broken. Strip leading 1-3 digits + space, and also fix the title
# tag in place so it isn't shown with the prefix in clients.
clean_title=$(echo "$title" | sed -E 's/^[[:space:]]*[0-9]{1,3}[[:space:]]+//')
if [[ "$clean_title" != "$title" && -n "$clean_title" ]]; then
log " trimmed leading track-number from title: \"$title\" -> \"$clean_title\""
if [[ $APPLY -eq 1 ]]; then
case "$ext" in
flac) metaflac --remove-tag=TITLE --set-tag="TITLE=${clean_title}" "$f" 2>>"$LOG" ;;
mp3) id3v2 --TIT2 "${clean_title}" "$f" 2>>"$LOG" ;;
esac
fi
title="$clean_title"
fi
new_album="${title} - Single"
log "[$( [[ $APPLY -eq 1 ]] && echo SET || echo WOULD-SET )] album=\"${new_album}\" - $f"
if [[ $APPLY -eq 1 ]]; then
case "$ext" in
flac) metaflac --set-tag="ALBUM=${new_album}" "$f" 2>>"$LOG" ;;
mp3) id3v2 --TALB "${new_album}" "$f" 2>>"$LOG" ;;
esac
fi
FIXED=$((FIXED + 1))
done < <(find ${MUSIC_DATA_DIR:-/data/music}/Library -type f \( -iname "*.flac" -o -iname "*.mp3" \) -print0 2>/dev/null)
log ""
log "=== Summary: $FIXED fixed, $SKIPPED skipped (no title) ==="
if [[ $APPLY -eq 1 && $FIXED -gt 0 ]]; then
log "Syncing beets DB from new tags..."
beet update 2>&1 | tail -3 >> "$LOG"
log "Moving files to reflect new album in path templates..."
beet move 2>&1 | tail -3 >> "$LOG"
log "Triggering Navidrome rescan..."
curl -s -G "$ND_BASE/rest/startScan.view" \
--data-urlencode "u=$ND_USER" \
--data-urlencode "p=$ND_PASS" \
--data-urlencode 'v=1.16.0' \
--data-urlencode 'c=fix-empty-album' \
--data-urlencode 'f=json' \
--data-urlencode 'fullScan=true' >> "$LOG" 2>&1
fi
log "=== Done. Log: $LOG ==="
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
#!/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())