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>
216 lines
8.6 KiB
Python
Executable File
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()
|