From c8f82ceba5bf1682022a95369647a0614ac96827 Mon Sep 17 00:00:00 2001 From: Honey Date: Wed, 13 May 2026 01:48:37 +0200 Subject: [PATCH] fix: scrape Spotify OG tags as fallback for single track resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit song_info returns GenericError for unauthenticated track lookups. Fall back to scraping og:title/og:description/og:image from the public Spotify track page — description format is "Song · Artist · Album · Year". Co-Authored-By: Claude Sonnet 4.6 --- backend/spotify_resolve.py | 65 ++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/backend/spotify_resolve.py b/backend/spotify_resolve.py index 7b264fb..850a359 100644 --- a/backend/spotify_resolve.py +++ b/backend/spotify_resolve.py @@ -4,7 +4,7 @@ Resolves Spotify URLs via the Spotify partner GraphQL API (api-partner.spotify.c This is the same internal API the Spotify web player uses — separate rate-limit bucket from the public api.spotify.com/v1/ which gets throttled on server IPs. """ -import sys, json, re, time +import sys, json, re, time, html as _html, urllib.request def eprint(*a): print(*a, file=sys.stderr) @@ -65,34 +65,57 @@ def resolve(sp_type, sp_id): if sp_type == 'track': pub = sp_mod.Public() - # Try URI first, then full URL - raw = None + tr = None + # Try partner API first (works for some tracks) for arg in [f'spotify:track:{sp_id}', url]: try: r = pub.song_info(arg) tu_type = ((r.get('data') or {}).get('trackUnion') or {}).get('__typename', '') if tu_type == 'GenericError': - eprint(f'[spotapi] song_info({arg!r}) → GenericError, trying next') + eprint(f'[spotapi] song_info({arg!r}) → GenericError') continue - raw = r - break + d = (r.get('data') or {}) + tu = d.get('trackUnion') or d + tr = _parse_track({'itemV2': {'data': {'trackUnion': tu}}}) + if tr and tr.get('trackName'): + eprint(f'[spotapi] track resolved via song_info') + break + tr = None except Exception as e: eprint(f'[spotapi] song_info({arg!r}) failed: {e}') - if raw is None: - raise Exception(f'song_info returned GenericError for all inputs — track may require auth or be region-locked') - eprint(f'[spotapi] track raw keys={list(raw.keys())}') - d = (raw.get('data') or {}) if isinstance(raw, dict) else {} - tu = d.get('trackUnion') or d - tr = _parse_track({'itemV2': {'data': {'trackUnion': tu}}}) or { - 'trackName': raw.get('name', ''), - 'artistName': raw.get('artist', ''), - 'collectionName': raw.get('album', ''), - 'artworkUrl100': raw.get('image', ''), - 'releaseDate': raw.get('release_date', ''), - 'trackNumber': raw.get('track_number'), - 'trackExplicitness': 'notExplicit', - 'durationMs': raw.get('duration_ms'), - } + + # Fallback: scrape Open Graph tags from the public Spotify track page + if not tr: + eprint(f'[spotapi] falling back to OG scrape for track {sp_id}') + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req, timeout=10) as resp: + page = resp.read().decode('utf-8', errors='replace') + og = {m.group(1): _html.unescape(m.group(2)) + for m in re.finditer(r' 1 else '' + album_name = desc_parts[2] if len(desc_parts) > 2 else '' + art_url = og.get('image', '') + eprint(f'[spotapi] OG scrape: name={track_name!r} artist={artist_name!r} album={album_name!r}') + if track_name: + tr = { + 'trackName': track_name, + 'artistName': artist_name, + 'collectionName': album_name, + 'artworkUrl100': art_url, + 'releaseDate': '', + 'trackNumber': None, + 'trackExplicitness': 'notExplicit', + 'durationMs': None, + } + except Exception as e: + eprint(f'[spotapi] OG scrape failed: {e}') + + if not tr: + raise Exception(f'Track resolution failed: song_info returned GenericError and OG scrape found no title') return {'type': 'track', 'name': tr.get('trackName', ''), 'artist': tr.get('artistName', ''), 'artworkUrl': tr.get('artworkUrl100', ''), 'tracks': [tr]}