Files
alembic/pipeline/lib/backfill-buy-url.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

216 lines
8.6 KiB
Python
Executable File

#!/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()