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

275 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""
spotify-retag.py — rewrite tags on downloaded files using Spotify as source of truth.
Reads a Spotify playlist's tracks via Client Credentials, matches each audio file
in <scan_dir> to a Spotify track, and overwrites:
ARTIST — semicolon-joined list of all credited artists
ALBUMARTIST — primary (first) artist only — kills ghost combined artists
ALBUM
TITLE
TRACKNUMBER
DISCNUMBER
DATE — release year
Tags we leave alone:
GROUPING — set by run-playlist.sh for playlist-membership tracking
Anything else not in the list above
Usage:
spotify-retag.py <playlist_url> <scan_dir> # walk a directory tree
spotify-retag.py <playlist_url> - # read newline-separated paths from stdin
Credentials are read from env vars SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET.
SPOTIFY_REFRESH_TOKEN, if set, mints a user token instead of client-credentials
(required for newly created Spotify apps -- see _spotify_auth.get_token).
Matching strategy (per file):
1. Parse "Artist - Title.ext" from filename. If multiple " - " separators,
try every possible split position.
2. Normalize title (lowercase, strip parens/brackets/feat./punct).
3. Look up by normalized title in the playlist index. If exactly one hit,
use it. If multiple, score each by artist-string overlap and pick the
best. If nothing close, leave the file alone.
"""
import sys, os, re, json, urllib.request, urllib.parse, subprocess
from pathlib import Path
from difflib import SequenceMatcher
from _spotify_auth import get_token
API = "https://api.spotify.com/v1"
def fetch_playlist(url: str, token: str) -> list[dict]:
m = re.search(r"playlist[/:]([A-Za-z0-9]+)", url)
if not m:
sys.exit(f"Could not parse playlist ID from {url!r}")
pid = m.group(1)
tracks = []
# /items (not the removed /tracks) -- see February 2026 Spotify API changes.
next_url = f"{API}/playlists/{pid}/items?limit=100"
while next_url:
req = urllib.request.Request(
next_url, headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=15) 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
tracks.append(
{
"id": t["id"],
"title": t["name"],
"artists": [a["name"] for a in t["artists"]],
"album": t["album"]["name"],
"track_number": t.get("track_number") or 0,
"disc_number": t.get("disc_number") or 1,
"year": (t["album"].get("release_date") or "").split("-")[0],
}
)
next_url = data.get("next")
return tracks
def normalize(s: str) -> str:
s = s.lower()
s = re.sub(r"\s*\([^)]*\)", "", s) # strip ( ... )
s = re.sub(r"\s*\[[^\]]*\]", "", s) # strip [ ... ]
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) # strip "feat. X" trailing
s = re.sub(r"\s*ft\.?\s.*", "", s)
s = re.sub(r"[^a-z0-9]+", "", s)
return s
def index_by_title(tracks: list[dict]) -> dict[str, list[dict]]:
idx: dict[str, list[dict]] = {}
for t in tracks:
key = normalize(t["title"])
# Fully non-Latin titles (Japanese/Korean/Cyrillic) normalize to "" —
# indexing them would dump every such track into one shared bucket and
# cross-match unrelated songs (their artists also normalize to "", so
# SequenceMatcher scores them 1.0). Leave them unmatched instead; the
# file keeps its sldl-supplied tags rather than gaining wrong ones.
if not key:
continue
idx.setdefault(key, []).append(t)
return idx
def candidate_splits(stem: str) -> list[tuple[str, str]]:
"""For 'A - B - C', return both forward and reversed splits, e.g.
[('A','B - C'), ('A - B','C'), # forward: artist - title
('B - C','A'), ('C','A - B')] # reversed: title - artist
Some Soulseek uploaders write 'Title - Artist' instead of 'Artist - Title'.
"""
parts = stem.split(" - ")
if len(parts) < 2:
return []
forward = [
(" - ".join(parts[:i]), " - ".join(parts[i:])) for i in range(1, len(parts))
]
reversed_ = [(b, a) for a, b in forward]
return forward + reversed_
def best_match(file_artist_raw: str, candidates: list[dict]) -> dict | None:
"""Pick the candidate whose artist-string overlaps best with the filename's."""
if len(candidates) == 1:
return candidates[0]
fnorm = normalize(file_artist_raw)
best, best_score = None, 0.0
for c in candidates:
# Compare against the joined artist string
joined = "; ".join(c["artists"])
score = SequenceMatcher(None, fnorm, normalize(joined)).ratio()
if score > best_score:
best, best_score = c, score
return best if best_score >= 0.4 else None
def match_file(path: Path, idx: dict[str, list[dict]]) -> dict | None:
stem = path.stem
for artist_guess, title_guess in candidate_splits(stem):
hits = idx.get(normalize(title_guess))
if hits:
picked = best_match(artist_guess, hits)
if picked:
return picked
# Last resort: try the whole stem as title (e.g. "Title.flac" with no " - ")
hits = idx.get(normalize(stem))
if hits and len(hits) == 1:
return hits[0]
return None
def write_flac_tags(path: str, t: dict, log) -> None:
artists_joined = "; ".join(t["artists"])
primary = t["artists"][0]
pairs = {
"ARTIST": artists_joined,
"ALBUMARTIST": primary,
"ALBUM": t["album"],
"TITLE": t["title"],
"TRACKNUMBER": str(t["track_number"]),
"DISCNUMBER": str(t["disc_number"]),
}
if t["year"]:
pairs["DATE"] = t["year"]
# Strip "release identification" tags from MB-tagged Soulseek uploads.
# Navidrome's BFR scanner uses these (alongside MB IDs) to differentiate
# releases of the same album, so leftover RELEASE*/BARCODE/CATALOGNUMBER/
# LABEL/MEDIA tags on one track will split it off into its own album card.
# Saw this with Danny Brown's Atrocity Exhibition where Pneumonia had MB
# IDs AND a CATALOGNUMBER and split off twice in succession before we
# broadened the strip list. Keep this list in sync with strip-mb-tags.sh.
stale_mb_tags = [
# MusicBrainz IDs
"MUSICBRAINZ_ALBUMID", "MUSICBRAINZ_RELEASEGROUPID",
"MUSICBRAINZ_RELEASETRACKID", "MUSICBRAINZ_ALBUMARTISTID",
"MUSICBRAINZ_ARTISTID", "MUSICBRAINZ_ALBUMSTATUS",
"MUSICBRAINZ_ALBUMTYPE", "MUSICBRAINZ_TRACKID", "MUSICBRAINZ_WORKID",
# Release identification
"RELEASESTATUS", "RELEASETYPE", "RELEASECOUNTRY",
"BARCODE", "CATALOGNUMBER", "LABEL", "PUBLISHER",
"MEDIA", "ORIGINALDATE", "ASIN", "ISRC", "SCRIPT", "LANGUAGE",
# Duplicate / sort variants of artist fields (we use canonical ARTIST/ALBUMARTIST)
"ALBUM ARTIST", "ALBUM_ARTIST",
"ALBUMARTIST_CREDIT", "ALBUMARTISTSORT", "ALBUMARTISTS",
"ALBUM_ARTISTS", "ALBUMARTISTS_CREDIT", "ALBUMARTISTS_SORT",
"ARTIST_CREDIT", "ARTISTSORT", "ARTISTS",
"ARTISTS_CREDIT", "ARTISTS_SORT", "COMPOSERSORT",
# Misc cruft
"ACOUSTID_ID", "ACOUSTID_FINGERPRINT", "COMPILATION",
"YEAR", # duplicate of DATE
"DISCSUBTITLE", "DISCC", "TOTALDISCS", "TRACKC", "TOTALTRACKS",
# Kept in sync with mb-tags.sh (was missing these four)
"MUSICBRAINZ_ALBUMCOMMENT", "ALBUMCOMMENT",
"MUSICBRAINZ_DISCID", "MUSICBRAINZ_TRMID",
]
# One metaflac invocation: options are applied in order and the file is
# rewritten once, so a failure leaves the original tags intact — the old
# per-tag loop (~65 subprocesses) could die between remove and set, leaving
# a file stripped of ARTIST/TITLE.
args = ["metaflac"]
args += [f"--remove-tag={tag}" for tag in list(pairs) + stale_mb_tags]
args += [f"--set-tag={tag}={val}" for tag, val in pairs.items()]
args.append(path)
subprocess.run(args, check=True, stderr=log)
def write_mp3_tags(path: str, t: dict, log) -> None:
artists_joined = "; ".join(t["artists"])
args = [
"id3v2",
"--TPE1", artists_joined,
"--TPE2", t["artists"][0],
"--TALB", t["album"],
"--TIT2", t["title"],
"--TRCK", str(t["track_number"]),
"--TPOS", str(t["disc_number"]),
]
if t["year"]:
args += ["--TYER", t["year"]]
args.append(path)
subprocess.run(args, check=True, stderr=log)
def main() -> int:
if len(sys.argv) != 3:
sys.exit(f"Usage: {sys.argv[0]} <playlist_url> <scan_dir|->")
url, target = sys.argv[1], sys.argv[2]
cid = os.environ.get("SPOTIFY_CLIENT_ID")
csec = os.environ.get("SPOTIFY_CLIENT_SECRET")
refresh = os.environ.get("SPOTIFY_REFRESH_TOKEN") or None
if not cid or not csec:
sys.exit("SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET must be set")
if target == "-":
files_iter = (Path(line.strip()) for line in sys.stdin if line.strip())
else:
files_iter = Path(target).rglob("*")
print(f"[spotify-retag] Fetching playlist {url}")
token = get_token(cid, csec, refresh)
tracks = fetch_playlist(url, token)
print(f"[spotify-retag] Got {len(tracks)} tracks from Spotify")
idx = index_by_title(tracks)
matched = unmatched = failed = 0
unmatched_names: list[str] = []
for f in sorted(files_iter):
if not f.is_file() or f.suffix.lower() not in (".flac", ".mp3"):
continue
match = match_file(f, idx)
if not match:
unmatched += 1
unmatched_names.append(f.name)
continue
try:
if f.suffix.lower() == ".flac":
write_flac_tags(str(f), match, sys.stderr)
else:
write_mp3_tags(str(f), match, sys.stderr)
matched += 1
print(f" OK {match['artists'][0]}{match['title']} ({f.name})")
except subprocess.CalledProcessError as e:
failed += 1
print(f" FAIL {f.name}: {e}", file=sys.stderr)
print(f"\n[spotify-retag] {matched} matched, {unmatched} unmatched, {failed} failed")
for u in unmatched_names[:25]:
print(f" unmatched: {u}")
if len(unmatched_names) > 25:
print(f" ...and {len(unmatched_names) - 25} more")
return 0
if __name__ == "__main__":
sys.exit(main())