Files
alembic/pipeline/lib/spotify-genre.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

356 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
"""
spotify-genre.py — write GENRE tags onto every audio file in the library
using Spotify's per-artist genre data, filtered against a whitelist.
Spotify only has genre at the ARTIST level, but their taxonomy is curated
and clean (e.g. "tropical house", "tech house", "synthwave"). We match
each artist's reported genres against our whitelist to pick up to 3
canonical display strings, then write to every track by that artist.
Whitelist matching is fuzzy-normalized: lowercased, dashes/spaces stripped.
So Spotify's "tropical house" matches our whitelist's "Tropical House"
(or rolls into "House" if Tropical House isn't in the list).
Per-artist caching: each unique artist is looked up once, even if they
have 100 tracks in the library.
Falls back gracefully:
- Artist not on Spotify -> no change
- Found but no whitelist match -> no change
Usage:
spotify-genre.py # dry run, show plan
spotify-genre.py --apply # write tags
spotify-genre.py --apply --force # overwrite existing GENRE tags
spotify-genre.py --apply --query '...' # subset by beets query
"""
import sys, os, re, json, argparse, urllib.parse, urllib.request, subprocess
from pathlib import Path
from _spotify_auth import get_token
from mutagen import File as MFile
from mutagen.id3 import ID3, ID3NoHeaderError, TCON
from mutagen.flac import FLAC
from mutagen.mp4 import MP4
from mutagen.oggopus import OggOpus
from mutagen.oggvorbis import OggVorbis
# OGG/OPUS use Vorbis-comment-style tags (lowercase keys, multi-value), same
# shape as FLAC. Treat them as a family so the read/write paths are shared.
VORBIS_LIKE = (FLAC, OggOpus, OggVorbis)
ALEMBIC_CONFIG_DIR = os.environ.get("ALEMBIC_CONFIG_DIR", "/config")
def _load_spotify_creds():
"""Spotify creds, rendered by alembic's credential_service to _spotify.env."""
path = f"{ALEMBIC_CONFIG_DIR}/pipeline/_spotify.env"
env = {}
with open(path) 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 env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env.get("SPOTIFY_REFRESH_TOKEN") or None
SPOTIFY_CID, SPOTIFY_CSEC, SPOTIFY_REFRESH = _load_spotify_creds()
WHITELIST_FILE = f"{ALEMBIC_CONFIG_DIR}/pipeline/genres-whitelist.txt"
MAX_GENRES = 3
SEPARATOR = "; "
def load_whitelist():
"""Load whitelist and build a normalized→display mapping for fuzzy matching."""
out = {}
with open(WHITELIST_FILE) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
normalized = re.sub(r"[\s\-_&/.]+", "", line).lower()
out[normalized] = line
return out
def _spotify_token():
return get_token(SPOTIFY_CID, SPOTIFY_CSEC, SPOTIFY_REFRESH)
# Cache: artist_name → list[str] of Spotify genres (or None if not found)
_artist_genre_cache = {}
def spotify_artist_genres(artist_name):
"""Return Spotify's genre list for the artist, or None if not found.
Cached per-artist so 1500 tracks for ~300 artists = ~300 API calls."""
if artist_name in _artist_genre_cache:
return _artist_genre_cache[artist_name]
# Search artist by name (limit=1, take best match)
qs = urllib.parse.urlencode({
"q": f'artist:"{artist_name}"',
"type": "artist",
"limit": 1,
})
url = f"https://api.spotify.com/v1/search?{qs}"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {_spotify_token()}"})
try:
with urllib.request.urlopen(req, timeout=15) as r:
data = json.loads(r.read())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, TimeoutError):
_artist_genre_cache[artist_name] = None
return None
items = data.get("artists", {}).get("items", [])
if not items:
_artist_genre_cache[artist_name] = None
return None
# Validate: Spotify's fuzzy search will silently match short/ambiguous
# names to the wrong artist (e.g. "G Jones" → "George Jones" the country
# singer, "T78" → some random T78). Reject the result unless the returned
# name matches what we asked for after normalizing whitespace/punct.
def _norm(s):
return re.sub(r"[\s\-_&/.()\[\]'\"]+", "", s or "").lower()
requested = _norm(artist_name)
returned = _norm(items[0].get("name", ""))
if requested and returned and requested != returned:
_artist_genre_cache[artist_name] = None
return None
genres = items[0].get("genres") or []
_artist_genre_cache[artist_name] = genres
return genres
# Map Spotify-style spellings to the normalized form of their whitelist entry.
# Spotify uses "drum and bass" / "rock and roll" / "r&b" — our whitelist uses
# "Drum & Bass" / "Rock & Roll" / "R&B". The "and"/"&" difference prevents the
# roll-up match below, so we explicitly resolve common variants first.
ALIASES = {
"drumandbass": "drumbass",
"rockandroll": "rockroll",
"randb": "rb",
"rnb": "rb",
"hiphop": "hiphop",
"ukgarage": "ukgarage",
}
def match_whitelist(genres, whitelist):
"""Pick up to MAX_GENRES whitelist entries from Spotify's genre list,
in Spotify's reported order (most-relevant first). Returns list of
display strings.
For specific genres like 'tropical house' that aren't in the whitelist,
we also check if any whitelist entry's normalized form is contained
within the genre's normalized form — so 'tropical house' rolls up to
'House' if Tropical House isn't a separate whitelist entry."""
out = []
seen = set()
for g in genres or []:
norm = re.sub(r"[\s\-_&/.]+", "", g).lower()
# Resolve known aliases (e.g. "drum and bass" → "drumbass") before lookup
norm = ALIASES.get(norm, norm)
# Direct hit
display = whitelist.get(norm)
if display:
if display not in seen:
out.append(display); seen.add(display)
continue
# Roll-up: e.g. "tropicalhouse" contains "house"
for wl_norm, wl_display in whitelist.items():
if wl_norm and wl_norm in norm and len(wl_norm) >= 4:
if wl_display not in seen:
out.append(wl_display); seen.add(wl_display)
break
if len(out) >= MAX_GENRES:
break
return out[:MAX_GENRES]
def is_genre_locked(filepath):
"""Return True if the file has GENRE_LOCK=1, meaning manual genre should not be overwritten."""
try:
m = MFile(filepath)
except Exception:
return False
if not m or not m.tags:
return False
try:
if isinstance(m, VORBIS_LIKE):
val = (m.tags.get("genre_lock") or m.tags.get("GENRE_LOCK") or [None])[0]
return val == "1"
else:
# MP3: check TXXX:GENRE_LOCK
t = getattr(m, "tags", None)
if t is not None:
for frame in t.getall("TXXX"):
if frame.desc.upper() == "GENRE_LOCK" and frame.text and frame.text[0] == "1":
return True
except (AttributeError, IndexError, TypeError):
pass
return False
def get_track_meta(filepath):
"""Read (artist, title, current_genre) from the file. Returns (None,None,None) on failure."""
try:
m = MFile(filepath)
except Exception:
return None, None, None
if not m or not m.tags:
return None, None, None
artist = title = genre = None
try:
if isinstance(m, VORBIS_LIKE):
# FLAC/OGG/OPUS share Vorbis-comment-style tags
artist = (m.tags.get("artist") or [None])[0]
title = (m.tags.get("title") or [None])[0]
genre = (m.tags.get("genre") or [None])[0]
elif isinstance(m, MP4):
artist = (m.tags.get("©ART") or [None])[0]
title = (m.tags.get("©nam") or [None])[0]
genre = (m.tags.get("©gen") or [None])[0]
else:
# MP3 ID3v2 — read via .text[0] of the frame to avoid str() returning frame metadata
t = getattr(m, "tags", None)
if t is not None:
if "TPE1" in t and t["TPE1"].text: artist = str(t["TPE1"].text[0])
if "TIT2" in t and t["TIT2"].text: title = str(t["TIT2"].text[0])
if "TCON" in t and t["TCON"].text: genre = str(t["TCON"].text[0])
except (AttributeError, IndexError, TypeError):
pass
return artist, title, genre
def write_genre(filepath, value):
"""Write GENRE tag on the file."""
try:
m = MFile(filepath)
except Exception:
return False
if isinstance(m, VORBIS_LIKE):
m.tags["GENRE"] = value # mutagen Vorbis-style tags are case-insensitive
m.save()
elif isinstance(m, MP4):
m.tags["©gen"] = value
m.save()
else:
try:
tags = ID3(filepath)
except ID3NoHeaderError:
tags = ID3()
tags.delall("TCON")
tags.add(TCON(encoding=3, text=value))
tags.save(filepath)
return True
def list_tracks(query=None):
"""Use beets (in-process, same container) to list tracks; paths are
already valid in this container's filesystem — no translation needed."""
args = ["beet", "ls", "-f", "$path"]
if query:
args.append(query)
r = subprocess.run(args, capture_output=True, text=True, timeout=120)
return [line for line in r.stdout.splitlines() if line.strip()]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="Actually write tags")
ap.add_argument("--force", action="store_true",
help="Overwrite existing GENRE tags (default: only fill empty)")
ap.add_argument("--query", help="Beets query to limit which tracks are processed")
ap.add_argument("--limit", type=int, help="Stop after N tracks (debug)")
ap.add_argument("--json", action="store_true",
help="Additionally emit one JSON line per candidate change to stdout, "
"for genre_review_service to parse into genre_runs/genre_candidates. "
"Purely additive -- normal output is unchanged.")
args = ap.parse_args()
whitelist = load_whitelist()
print(f"[spotify-genre] whitelist has {len(whitelist)} entries")
paths = list_tracks(args.query)
if args.limit:
paths = paths[:args.limit]
print(f"[spotify-genre] {len(paths)} tracks to scan")
written = unchanged = no_match = no_meta = 0
for i, p in enumerate(paths, 1):
if i % 100 == 0:
print(f" ...{i}/{len(paths)}", flush=True)
if not os.path.exists(p):
continue
artist, title, current_genre = get_track_meta(p)
if not artist or not title:
no_meta += 1
continue
# Skip if manually genre-locked (set via fix-genre.sh or direct GENRE_LOCK tag)
if is_genre_locked(p):
unchanged += 1
continue
# Skip if already has whitelist-canonical genre and we're not forcing
if current_genre and not args.force:
cur_parts = [g.strip() for g in re.split(r"[;,]", current_genre)]
cur_normed = {re.sub(r"[\s\-_&/.]+", "", g).lower() for g in cur_parts}
if cur_normed & set(whitelist.keys()):
unchanged += 1
continue
# Multi-artist "X; Y" → use first as primary
primary_artist = re.split(r"[;,/&]", artist)[0].strip()
genres = spotify_artist_genres(primary_artist)
if genres is None or not genres:
no_match += 1
continue
matched = match_whitelist(genres, whitelist)
if not matched:
no_match += 1
continue
new_value = SEPARATOR.join(matched)
if current_genre == new_value:
unchanged += 1
continue
if args.apply:
ok = write_genre(p, new_value)
if ok:
written += 1
if args.json:
print(json.dumps({
"artist": primary_artist, "title": title,
"old_genre": current_genre, "new_genre": new_value,
}))
# Quiet — only print every 50th
if written % 50 == 0:
print(f" [{written}] {primary_artist} - {title}: {new_value}", flush=True)
else:
print(f" WOULD-SET {primary_artist} - {title}{new_value}")
if args.json:
print(json.dumps({
"artist": primary_artist, "title": title,
"old_genre": current_genre, "new_genre": new_value,
}))
written += 1 # count as 'planned'
print(f"\n[spotify-genre] {('written' if args.apply else 'plan')}: {written}, "
f"unchanged: {unchanged}, no-match: {no_match}, no-meta: {no_meta}")
if args.apply:
print("[spotify-genre] now run 'beet update' to sync the DB")
if __name__ == "__main__":
sys.exit(main())