Files
NovaSonic/backend/spotify_resolve.py
T
HoneyandClaude Sonnet 4.6 c36dbb3c94 fix: use SpotipyFree as primary resolver (SpotAPI + Chrome TLS fingerprint)
SpotipyFree wraps SpotAPI which uses tls_client to impersonate Chrome's
TLS fingerprint and hits Spotify's internal endpoints. This is exactly
what TzurSoffer's spotdl fork uses. Removes the complex GQL hash
extraction (which couldn't find hashes in lazy-loaded webpack chunks).
curl_cffi + TOTP kept as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 17:09:33 +02:00

217 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Resolves Spotify URLs.
Approach 1: SpotipyFree (SpotAPI + tls_client Chrome fingerprint, internal endpoints)
Approach 2: curl_cffi + TOTP anonymous token + partner GraphQL API
Approach 3: curl_cffi + TOTP + REST API (rate-limited fallback)
"""
import sys, json, re, hmac, hashlib, struct, time, urllib.parse
def eprint(*a): print(*a, file=sys.stderr)
# ─── APPROACH 1: SpotipyFree ─────────────────────────────────────────────────
def _fmt_spotipy_track(t, default_album='', default_art='', default_artist=''):
if not t or not t.get('name'): return None
artists = ', '.join(a.get('name','') for a in (t.get('artists') or [])) or default_artist
alb = t.get('album') or {}
imgs = alb.get('images') or []
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else default_art
return {
'trackName': t.get('name',''),
'artistName': artists,
'collectionName': alb.get('name','') or default_album,
'artworkUrl100': art,
'releaseDate': alb.get('release_date',''),
'trackNumber': t.get('track_number'),
'trackExplicitness': 'explicit' if t.get('explicit') else 'notExplicit',
'durationMs': t.get('duration_ms'),
}
def resolve_via_spotipyfree(sp_type, sp_id):
from SpotipyFree import Spotify as Sp
sp = Sp()
eprint('[SpotipyFree] session created')
if sp_type == 'track':
t = sp.track(sp_id)
artist = ', '.join(a['name'] for a in (t.get('artists') or []))
imgs = (t.get('album') or {}).get('images') or []
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
tr = _fmt_spotipy_track(t)
return {'type':'track','name':t.get('name',''),'artist':artist,'artworkUrl':art,'tracks':[tr]}
if sp_type == 'album':
alb = sp.album(sp_id)
artist = ', '.join(a['name'] for a in (alb.get('artists') or []))
imgs = alb.get('images') or []
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
tracks, offset = [], 0
while True:
page = sp.album_tracks(sp_id, limit=50, offset=offset)
for t in (page.get('items') or []):
tr = _fmt_spotipy_track(t, alb.get('name',''), art, artist)
if tr: tracks.append(tr)
if not page.get('next'): break
offset += len(page.get('items') or [])
time.sleep(0.3)
eprint(f'[SpotipyFree] album ok — {len(tracks)} tracks')
return {'type':'album','name':alb.get('name',''),'artist':artist,'artworkUrl':art,'tracks':tracks}
if sp_type == 'playlist':
pl = sp.playlist(sp_id, fields='name,owner,images')
imgs = pl.get('images') or []
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
owner = (pl.get('owner') or {}).get('display_name','')
tracks, skipped, offset = [], 0, 0
while True:
page = sp.playlist_items(sp_id, limit=100, offset=offset,
additional_types=['track'])
for item in (page.get('items') or []):
t = item.get('track')
if not t or not t.get('id'): continue
if t.get('is_local'): skipped += 1; continue
tr = _fmt_spotipy_track(t)
if tr: tracks.append(tr)
if not page.get('next'): break
offset += len(page.get('items') or [])
time.sleep(0.3)
eprint(f'[SpotipyFree] playlist ok — {len(tracks)} tracks')
return {'type':'playlist','name':pl.get('name',''),'artist':owner,
'artworkUrl':art,'skippedLocal':skipped,'tracks':tracks}
# ─── APPROACH 2+3: curl_cffi + TOTP ─────────────────────────────────────────
def _totp(secret_str, server_ts):
secret_bytes = [ord(c) for c in secret_str]
transformed = [b ^ ((i % 33) + 9) for i, b in enumerate(secret_bytes)]
key = bytes.fromhex(''.join(str(b) for b in transformed).encode('utf-8').hex())
counter_b = struct.pack('>Q', server_ts // 30)
digest = hmac.new(key, counter_b, hashlib.sha1).digest()
offset = digest[-1] & 0xf
code = ((digest[offset] & 0x7f) << 24 | (digest[offset+1] & 0xff) << 16 |
(digest[offset+2] & 0xff) << 8 | (digest[offset+3] & 0xff)) % 1_000_000
return str(code).zfill(6)
def _get_token(session):
html = session.get('https://open.spotify.com/', headers={
'Accept': 'text/html,*/*;q=0.8', 'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'identity',
}, timeout=20).text
js_urls, seen = [], set()
for m in re.finditer(r'src="((?:https?://[^"]*|/[^"]*?)\.js(?:\?[^"]*)?)"', html):
u = m.group(1)
if not u.startswith('http'): u = 'https://open.spotify.com' + u
if u not in seen: seen.add(u); js_urls.append(u)
secret = version = None
for u in js_urls[:20]:
if secret: break
try:
js = session.get(u, headers={'Accept':'*/*','Accept-Encoding':'identity'}, timeout=8).text
m = re.search(r'\{secret:\s*["\']([^"\']+)["\']\s*,\s*version:\s*(\d+)\s*\}', js)
if m: secret, version = m.group(1), int(m.group(2))
except: pass
if not secret: raise Exception('TOTP secret not found')
eprint(f'[TOTP] secret v{version} found')
st = session.get('https://open.spotify.com/api/server-time', headers={
'Accept':'application/json','Referer':'https://open.spotify.com/'}, timeout=10).json()
totp = _totp(secret, st['serverTime'])
tok = session.get(
f'https://open.spotify.com/api/token?reason=init&productType=web-player&totp={totp}&totpVer={version}&totpServer={totp}',
headers={'Accept':'application/json','Referer':'https://open.spotify.com/'},timeout=10).json()
if 'accessToken' not in tok: raise Exception(f'No accessToken: {json.dumps(tok)[:100]}')
eprint('[TOTP] token ok')
return tok['accessToken']
def _auth_h(token):
return {'Authorization':f'Bearer {token}','app-platform':'WebPlayer',
'spotify-app-version':'1.2.46.25.g7f189073','Origin':'https://open.spotify.com',
'Referer':'https://open.spotify.com/','Accept':'application/json',
'Accept-Language':'en-US,en;q=0.9'}
def _rest(session, path, token):
url = path if path.startswith('http') else f'https://api.spotify.com/v1{path}'
r = session.get(url, headers=_auth_h(token), timeout=15)
if r.status_code == 429:
retry = int(r.headers.get('Retry-After','30'))
eprint(f'[REST] 429 Retry-After={retry}s')
print(json.dumps({'error':'RATE_LIMITED','retryAfter':retry}))
sys.exit(1)
if not r.ok: raise Exception(f'REST {r.status_code}: {path}')
return r.json()
def _best_art(imgs): return sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else ''
def resolve_via_curl(sp_type, sp_id):
from curl_cffi import requests as cf
session = cf.Session(impersonate='chrome124')
token = _get_token(session)
if sp_type == 'track':
t = _rest(session, f'/tracks/{sp_id}', token)
artist = ', '.join(a['name'] for a in (t.get('artists') or []))
art = _best_art((t.get('album') or {}).get('images') or [])
return {'type':'track','name':t.get('name',''),'artist':artist,'artworkUrl':art,
'tracks':[_fmt_spotipy_track(t)]}
if sp_type == 'album':
alb = _rest(session, f'/albums/{sp_id}', token)
artist = ', '.join(a['name'] for a in (alb.get('artists') or []))
art = _best_art(alb.get('images') or [])
tracks, path = [], f'/albums/{sp_id}/tracks?limit=50'
while path:
pg = _rest(session, path, token)
tracks += [t for t in [_fmt_spotipy_track(i, alb.get('name',''), art, artist)
for i in (pg.get('items') or []) if i and i.get('id')] if t]
path = pg.get('next'); time.sleep(0.3) if path else None
return {'type':'album','name':alb.get('name',''),'artist':artist,'artworkUrl':art,'tracks':tracks}
pl = _rest(session, f'/playlists/{sp_id}?fields=name,description,owner,images', token)
owner = (pl.get('owner') or {}).get('display_name','')
art = _best_art(pl.get('images') or [])
tracks, skipped, path = [], 0, f'/playlists/{sp_id}/tracks?limit=100'
while path:
pg = _rest(session, path, token)
for item in (pg.get('items') or []):
t = item.get('track')
if not t or not t.get('id'): continue
if t.get('is_local'): skipped += 1; continue
tr = _fmt_spotipy_track(t)
if tr: tracks.append(tr)
path = pg.get('next'); time.sleep(0.3) if path else None
return {'type':'playlist','name':pl.get('name',''),'artist':owner,
'artworkUrl':art,'skippedLocal':skipped,'tracks':tracks}
# ─── MAIN ────────────────────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2: eprint('No URL'); sys.exit(1)
url = sys.argv[1]
m = re.search(r'open\.spotify\.com/(?:.*/)?(track|album|playlist)/([A-Za-z0-9]+)', url)
if not m: eprint('Invalid URL'); sys.exit(1)
sp_type, sp_id = m.group(1), m.group(2)
eprint(f'[spotify] resolving {sp_type} {sp_id}')
# Try SpotipyFree first (SpotAPI + tls_client Chrome fingerprint)
try:
result = resolve_via_spotipyfree(sp_type, sp_id)
print(json.dumps(result, ensure_ascii=False))
return
except SystemExit: raise
except Exception as e:
eprint(f'[SpotipyFree] failed: {type(e).__name__}: {e}')
# Fall back to curl_cffi + TOTP
try:
result = resolve_via_curl(sp_type, sp_id)
print(json.dumps(result, ensure_ascii=False))
except SystemExit: raise
except Exception as e:
eprint(f'[curl_cffi] failed: {e}')
sys.exit(1)
if __name__ == '__main__':
try: main()
except SystemExit: raise
except Exception as e: eprint(f'fatal: {e}'); sys.exit(1)