Files
alembic/pipeline/lib/fix-track-metadata.py
T
andrew e5d9c7f043 0.6: Spotify OAuth connect flow, AzuraCast base URL, fingerprint/buy-url fixes
- Add "Connect Spotify" OAuth flow (/connect/spotify) so newly created Spotify
  apps can read playlists: Spotify now requires a user token for playlist
  reads, which client-credentials alone can no longer provide. The stored
  refresh token is rendered as spotify-refresh into each playlist's sldl
  .conf -- sldl's own docs confirm supplying it skips its interactive login
  flow, which is what was causing multi-hour hangs on a stuck sync.
  Grandfathered apps keep working unchanged if no account is connected.
- Fix the connect flow's redirect_uri: request.url_for() reflected the raw
  connection scheme (http) rather than what the reverse proxy actually
  served over, which Spotify's exact-match redirect_uri check rejected.
  Force https rather than trust the unproxied scheme.
- Add an AzuraCast base URL credential field alongside the API key, with a
  keyfile fallback so the scheduled enrich-buy-url.py job can actually reach
  AzuraCast (previously it had no way to receive a base URL at all when run
  from the scheduler, so the reprocess call was silently always skipped).
- Fix build-fingerprint-index.py exiting non-zero on any single fingerprint
  failure instead of only when nothing was written.
- Guard enrich-buy-url.py's AzuraCast reprocess against an empty
  --azuracast-base (raised ValueError since PD2 removed the hardcoded base).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 11:16:55 -06:00

511 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""
fix-track-metadata.py — interactively rewrite a single track's metadata
from a Spotify or iTunes URL.
Prompts for an audio file path and a Spotify/iTunes URL, then:
1. Fetches canonical metadata (artist, album, title, track#, date, cover).
2. Writes tags to the file (artist semicolon-joined, albumartist primary
only — matches the rest of the booksandtunes pipeline).
3. Strips SoundCloud / upload-junk frames (WWWAUDIOFILE, comment,
compatible_brands, major_brand, minor_version).
4. Replaces embedded cover with the URL's artwork and drops a cover.jpg
sidecar in the album folder.
5. Runs `beet update` so beets's DB matches.
6. Runs `beet move` to relocate the file under
/music/<albumartist>/<album>/<title>.<ext>.
7. Strips GENRE and runs spotify-genre.py to refill from the artist's
Spotify per-artist genres (use --keep-genre to skip this).
8. Triggers a Navidrome rescan via the Subsonic API.
Usage:
./fix-track-metadata.py # interactive
./fix-track-metadata.py <file> <url> # non-interactive
./fix-track-metadata.py --keep-genre <f> <u> # leave existing GENRE alone
The <file> arg accepts any of:
- absolute host path: $MUSIC_DATA_DIR/Library/Foo/Bar.flac
- beets container path: /music/Foo/Bar.flac
- library-relative path: Foo/Bar.flac
(resolved against $MUSIC_DATA_DIR/Library/)
Supported URLs:
https://open.spotify.com/track/<id>...
https://open.spotify.com/album/<id>... (single-track albums only)
https://music.apple.com/.../id<col>?i=<track> (iTunes track URL)
"""
import sys, os, re, json, base64, argparse, subprocess, unicodedata, urllib.parse, urllib.request
from _spotify_auth import get_token
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TPE1, TPE2, TALB, TIT2, TRCK, TDRC, TCON, APIC
from mutagen.flac import FLAC, Picture
from mutagen.mp4 import MP4, MP4Cover
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
_ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
_MUSIC_DATA_DIR = os.environ.get("MUSIC_DATA_DIR", "/data/music")
LIBRARY = f"{_MUSIC_DATA_DIR}/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.
CONTAINER_PREFIX = "/music"
SPOTIFY_ENV = f"{_ALEMBIC_CONFIG_DIR}/pipeline/_spotify.env"
SPOTIFY_GENRE = f"{os.environ.get('PIPELINE_DIR', '/app/pipeline')}/lib/spotify-genre.py"
def _load_navidrome_env():
path = f"{_ALEMBIC_CONFIG_DIR}/pipeline/navidrome/admin.env"
env = {}
if os.path.exists(path):
for line in open(path):
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return env
_nd_env = _load_navidrome_env()
NAVIDROME_URL = f"{_nd_env.get('ND_BASE', 'http://navidrome:4533')}/rest/startScan.view"
NAVIDROME_USER = _nd_env.get("ND_USER", "")
NAVIDROME_PASS = _nd_env.get("ND_PASS", "")
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
JUNK_TXXX = {"WWWAUDIOFILE", "comment", "compatible_brands",
"major_brand", "minor_version"}
# ---------- Spotify ----------
def _spotify_token():
env = {}
with open(SPOTIFY_ENV) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
env[k] = v.strip().strip("'").strip('"')
return get_token(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"],
env.get("SPOTIFY_REFRESH_TOKEN") or None)
def _spotify_get(path, token):
req = urllib.request.Request(f"https://api.spotify.com/v1{path}",
headers={"Authorization": f"Bearer {token}"})
return json.loads(urllib.request.urlopen(req, timeout=15).read())
def fetch_spotify_track(track_id):
tok = _spotify_token()
t = _spotify_get(f"/tracks/{track_id}", tok)
a = t["album"]
return {
"title": t["name"],
"artists": [ar["name"] for ar in t["artists"]],
"album": a["name"],
"albumartists": [ar["name"] for ar in a["artists"]],
"date": a["release_date"],
"track": t["track_number"],
"tracktotal": a["total_tracks"],
"cover_url": a["images"][0]["url"] if a["images"] else None,
}
def fetch_spotify_album(album_id):
tok = _spotify_token()
a = _spotify_get(f"/albums/{album_id}", tok)
if a["total_tracks"] != 1:
raise SystemExit(f"album has {a['total_tracks']} tracks — pass a track URL instead")
t = a["tracks"]["items"][0]
return {
"title": t["name"],
"artists": [ar["name"] for ar in t["artists"]],
"album": a["name"],
"albumartists": [ar["name"] for ar in a["artists"]],
"date": a["release_date"],
"track": 1,
"tracktotal": 1,
"cover_url": a["images"][0]["url"] if a["images"] else None,
}
# ---------- iTunes ----------
def fetch_itunes_track(track_id):
url = f"https://itunes.apple.com/lookup?id={track_id}&entity=song"
data = json.loads(urllib.request.urlopen(url, timeout=15).read())
items = [r for r in data.get("results", []) if r.get("wrapperType") == "track"]
if not items:
raise SystemExit(f"iTunes lookup returned no track for id={track_id}")
r = items[0]
# iTunes only gives one artist string — the project convention is
# semicolon-joined for ARTIST. The user can split manually after if needed.
artists = [r["artistName"]]
cover = r.get("artworkUrl100", "")
if cover:
# 100x100 → 600x600 by URL substitution (well-known iTunes hack)
cover = re.sub(r"/\d+x\d+bb\.jpg$", "/600x600bb.jpg", cover)
return {
"title": r["trackName"],
"artists": artists,
"album": r["collectionName"],
"albumartists": artists,
"date": (r.get("releaseDate") or "")[:10],
"track": r.get("trackNumber", 1),
"tracktotal": r.get("trackCount", 1),
"cover_url": cover or None,
}
# ---------- URL dispatch ----------
def fetch_metadata(url):
m = re.search(r"open\.spotify\.com/track/([A-Za-z0-9]+)", url)
if m:
return fetch_spotify_track(m.group(1))
m = re.search(r"open\.spotify\.com/album/([A-Za-z0-9]+)", url)
if m:
return fetch_spotify_album(m.group(1))
m = re.search(r"music\.apple\.com/.+?[?&]i=(\d+)", url)
if m:
return fetch_itunes_track(m.group(1))
m = re.search(r"music\.apple\.com/.+?/song/[^/]+/(\d+)", url)
if m:
return fetch_itunes_track(m.group(1))
raise SystemExit(f"unrecognized URL: {url}")
# ---------- Tagging ----------
def write_tags(filepath, meta):
"""Write artist/albumartist/album/title/track/date and embedded cover.
Drops watermark/junk TXXX frames as a side effect.
"""
artist_joined = "; ".join(meta["artists"])
albumartist = meta["albumartists"][0] if meta["albumartists"] else meta["artists"][0]
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
date = meta["date"] # YYYY-MM-DD or YYYY
m = MFile(filepath)
if m is None:
raise SystemExit(f"unsupported audio format: {filepath}")
if isinstance(m, VORBIS_LIKE):
_write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes)
elif isinstance(m, MP4):
_write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes)
else:
_write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes)
def _write_vorbis(m, artist_joined, albumartist, meta, date, cover_bytes):
m.tags["ARTIST"] = artist_joined
m.tags["ALBUMARTIST"] = albumartist
m.tags["ALBUM"] = meta["album"]
m.tags["TITLE"] = meta["title"]
m.tags["TRACKNUMBER"] = str(meta["track"])
m.tags["TRACKTOTAL"] = str(meta["tracktotal"])
m.tags["DATE"] = date
if cover_bytes and isinstance(m, FLAC):
m.clear_pictures()
pic = Picture()
pic.type = 3 # Cover (front)
pic.mime = "image/jpeg"
pic.data = cover_bytes
m.add_picture(pic)
m.save()
def _write_mp4(m, artist_joined, albumartist, meta, date, cover_bytes):
m.tags["\xa9ART"] = artist_joined
m.tags["aART"] = albumartist
m.tags["\xa9alb"] = meta["album"]
m.tags["\xa9nam"] = meta["title"]
m.tags["\xa9day"] = date
m.tags["trkn"] = [(int(meta["track"]), int(meta["tracktotal"]))]
if cover_bytes:
m.tags["covr"] = [MP4Cover(cover_bytes, imageformat=MP4Cover.FORMAT_JPEG)]
m.save()
def _write_id3(filepath, artist_joined, albumartist, meta, date, cover_bytes):
try:
t = ID3(filepath)
except ID3NoHeaderError:
t = ID3()
t.delall("TPE1"); t.add(TPE1(encoding=3, text=artist_joined))
t.delall("TPE2"); t.add(TPE2(encoding=3, text=albumartist))
t.delall("TALB"); t.add(TALB(encoding=3, text=meta["album"]))
t.delall("TIT2"); t.add(TIT2(encoding=3, text=meta["title"]))
t.delall("TRCK"); t.add(TRCK(encoding=3, text=f"{meta['track']}/{meta['tracktotal']}"))
t.delall("TDRC"); t.add(TDRC(encoding=3, text=date))
# Strip junk TXXX frames
for frame in list(t.getall("TXXX")):
if frame.desc in JUNK_TXXX:
t.delall(f"TXXX:{frame.desc}")
if cover_bytes:
t.delall("APIC")
t.add(APIC(encoding=3, mime="image/jpeg", type=3, desc="Cover", data=cover_bytes))
t.save(filepath)
def _download_cover(url):
if not url:
return None
return urllib.request.urlopen(url, timeout=20).read()
# ---------- Beets / Navidrome ----------
def host_to_container(host_path):
if host_path.startswith(LIBRARY):
return CONTAINER_PREFIX + host_path[len(LIBRARY):]
return host_path
def container_to_host(cp):
if cp.startswith(CONTAINER_PREFIX + "/"):
return LIBRARY + cp[len(CONTAINER_PREFIX):]
return cp
def resolve_input_path(arg):
"""Accept a host path, container path, or library-relative path; return
the absolute host path that actually exists on disk.
Falls back through NFC/NFD Unicode normalizations because terminal pastes
of Japanese/Korean/etc. paths often come in decomposed form even when the
filesystem stores them composed (or vice versa)."""
arg = arg.strip().strip('"').strip("'")
if arg.startswith(CONTAINER_PREFIX + "/"):
candidate = container_to_host(arg)
elif os.path.isabs(arg):
candidate = arg
else:
candidate = os.path.join(LIBRARY, arg)
if os.path.exists(candidate):
return candidate
for form in ("NFC", "NFD", "NFKC", "NFKD"):
norm = unicodedata.normalize(form, candidate)
if os.path.exists(norm):
return norm
# Last resort: walk component by component, picking the entry whose NFC
# form matches our NFC form. Handles the common case where one directory
# in the path differs in normalization but the rest is fine.
if not candidate.startswith("/"):
return candidate
parts = candidate.lstrip("/").split("/")
cur = "/"
for want in parts:
if not os.path.isdir(cur):
return candidate
want_nfc = unicodedata.normalize("NFC", want)
match = next(
(n for n in os.listdir(cur)
if unicodedata.normalize("NFC", n) == want_nfc),
None)
if match is None:
return candidate
cur = os.path.join(cur, match)
return cur
def beets_id_for(host_path):
cp = host_to_container(host_path)
r = subprocess.run(
["beet", "ls", "-f", "$id|||$path", f"path:{cp}"],
capture_output=True, text=True, check=True)
for line in r.stdout.splitlines():
if "|||" in line:
id_str, p = line.split("|||", 1)
if p == cp:
return int(id_str)
return None
def beet_update(beets_id):
# `beet update` prompts y/n on each diff; `yes y` auto-confirms.
subprocess.run(
f"yes y | beet update id:{beets_id}",
shell=True, check=False)
def beet_move(beets_id):
"""Beets path-format relocates the file under LIBRARY/<albumartist>/<album>/."""
subprocess.run(["beet", "move", f"id:{beets_id}"], check=False)
def beet_path(beets_id):
r = subprocess.run(
["beet", "ls", "-f", "$path", f"id:{beets_id}"],
capture_output=True, text=True, check=True)
cp = r.stdout.strip()
return container_to_host(cp) if cp else None
def strip_genre(filepath):
"""Blank GENRE so spotify-genre.py will refill it (skip-existing logic)."""
m = MFile(filepath)
if m is None or m.tags is None:
return
if isinstance(m, VORBIS_LIKE):
if "genre" in m.tags:
del m.tags["genre"]
m.save()
elif isinstance(m, MP4):
if "\xa9gen" in m.tags:
del m.tags["\xa9gen"]
m.save()
else:
try:
t = ID3(filepath)
t.delall("TCON")
t.save(filepath)
except ID3NoHeaderError:
pass
def refill_genre(beets_id):
subprocess.run(
[SPOTIFY_GENRE, "--apply", "--force", "--query", f"id:{beets_id}"],
check=False)
def write_cover_sidecar(filepath, cover_bytes):
if not cover_bytes:
return
sidecar = os.path.join(os.path.dirname(filepath), "cover.jpg")
with open(sidecar, "wb") as f:
f.write(cover_bytes)
def navidrome_scan():
qs = urllib.parse.urlencode({
"u": NAVIDROME_USER, "p": NAVIDROME_PASS,
"v": "1.16.0", "c": "fix-track-metadata", "f": "json",
})
try:
urllib.request.urlopen(f"{NAVIDROME_URL}?{qs}", timeout=10).read()
print("[navidrome] rescan triggered")
except Exception as e:
print(f"[navidrome] rescan failed: {e}")
# ---------- Main ----------
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("file", nargs="?",
help="audio file: host path, /music/... container path, "
"or library-relative path (Foo/Bar.flac)")
ap.add_argument("url", nargs="?", help="Spotify or iTunes URL")
ap.add_argument("--keep-genre", action="store_true",
help="don't strip + refill GENRE (default: refill via spotify-genre.py)")
args = ap.parse_args()
# Tab-completion on the file prompt — pasting unicode paths is finicky,
# tab-completing them is not.
try:
import readline
readline.set_completer_delims(" \t\n;")
def _path_complete(text, state):
base = text if text.startswith("/") else os.path.join(LIBRARY, text)
d, prefix = os.path.split(base)
if not os.path.isdir(d):
return None
matches = [n for n in os.listdir(d) if n.startswith(prefix)]
matches.sort()
if state >= len(matches):
return None
full = os.path.join(d, matches[state])
# Strip the LIBRARY prefix back off if user was typing relative
display = full[len(LIBRARY) + 1:] if (
not text.startswith("/") and full.startswith(LIBRARY + "/")) else full
return display + ("/" if os.path.isdir(full) else "")
readline.set_completer(_path_complete)
readline.parse_and_bind("tab: complete")
except ImportError:
pass
raw_file = args.file or input("file path: ").strip()
url = args.url or input("spotify/itunes URL: ").strip()
filepath = resolve_input_path(raw_file)
if not os.path.isfile(filepath):
# Diagnostics: show the bytes we tried + what's actually in the parent.
print(f"[error] not a file: {filepath}", file=sys.stderr)
print(f"[error] input bytes: {raw_file.encode('utf-8')!r}", file=sys.stderr)
print(f"[error] resolved bytes: {filepath.encode('utf-8')!r}", file=sys.stderr)
parent = os.path.dirname(filepath)
if os.path.isdir(parent):
print(f"[error] parent dir contents:", file=sys.stderr)
for n in sorted(os.listdir(parent)):
print(f" {n!r} ({n.encode('utf-8')!r})", file=sys.stderr)
else:
# Walk up to the deepest existing ancestor and list it
anc = parent
while anc and anc != "/" and not os.path.isdir(anc):
anc = os.path.dirname(anc)
if anc and os.path.isdir(anc):
print(f"[error] deepest existing dir: {anc}", file=sys.stderr)
print(f"[error] its contents:", file=sys.stderr)
for n in sorted(os.listdir(anc)):
print(f" {n!r}", file=sys.stderr)
raise SystemExit(1)
print(f"[fetch] {url}")
meta = fetch_metadata(url)
print(f" title: {meta['title']}")
print(f" artists: {'; '.join(meta['artists'])}")
print(f" album: {meta['album']}")
print(f" albumartist: {meta['albumartists'][0] if meta['albumartists'] else '?'}")
print(f" date: {meta['date']}")
print(f" track: {meta['track']}/{meta['tracktotal']}")
print(f" cover: {meta['cover_url'] or '(none)'}")
if not args.file:
ok = input("apply? [y/N] ").strip().lower()
if ok != "y":
print("aborted")
return
print("[tags] writing")
cover_bytes = _download_cover(meta["cover_url"]) if meta["cover_url"] else None
write_tags(filepath, meta)
write_cover_sidecar(filepath, cover_bytes)
bid = beets_id_for(filepath)
if bid is None:
print("[beets] file not in beets DB — skipping update/move/genre")
else:
print(f"[beets] update id:{bid}")
beet_update(bid)
print(f"[beets] move id:{bid}")
beet_move(bid)
new_path = beet_path(bid)
if new_path:
filepath = new_path
# Re-drop sidecar in the (possibly new) folder
write_cover_sidecar(filepath, cover_bytes)
print(f"[beets] now at {filepath}")
if not args.keep_genre:
print("[genre] stripping + refilling via spotify-genre.py")
strip_genre(filepath)
beet_update(bid)
refill_genre(bid)
beet_update(bid)
navidrome_scan()
print("[done]")
if __name__ == "__main__":
main()