#!/usr/bin/env python3 """ Resolves Spotify URLs. - SPOTIFY_PROXY set: curl_cffi + TOTP + REST API routed through proxy (bypasses IP ban) - No proxy: SpotipyFree (SpotAPI + tls_client, internal spclient endpoints) """ import sys, json, re, hmac, hashlib, struct, time, os, urllib.parse PROXY = os.environ.get('SPOTIFY_PROXY', '').strip() def eprint(*a): print(*a, file=sys.stderr) # ─── FORMAT HELPERS ─────────────────────────────────────────────────────────── def _fmt_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 _best_art(imgs): return sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else '' # ─── APPROACH 1: curl_cffi + TOTP (used when SPOTIFY_PROXY is set) ─────────── 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 resolve_via_curl(sp_type, sp_id, proxy): from curl_cffi import requests as cf proxies = {'https': proxy, 'http': proxy} if proxy else {} session = cf.Session(impersonate='chrome124', proxies=proxies) if proxy: eprint(f'[curl_cffi] using proxy') 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_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_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 eprint(f'[curl_cffi] album ok — {len(tracks)} tracks') 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_track(t) if tr: tracks.append(tr) path = pg.get('next'); time.sleep(0.3) if path else None eprint(f'[curl_cffi] playlist ok — {len(tracks)} tracks') return {'type':'playlist','name':pl.get('name',''),'artist':owner, 'artworkUrl':art,'skippedLocal':skipped,'tracks':tracks} # ─── APPROACH 2: SpotipyFree (used when no proxy, non-banned IPs) ───────────── 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 '' return {'type':'track','name':t.get('name',''),'artist':artist,'artworkUrl':art, 'tracks':[_fmt_track(t)]} 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_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_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} # ─── 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}') if PROXY: # Proxy set: route all Spotify API calls through it (bypasses server IP ban) eprint(f'[spotify] proxy mode') result = resolve_via_curl(sp_type, sp_id, PROXY) print(json.dumps(result, ensure_ascii=False)) return # No proxy: try SpotipyFree (uses internal Spotify endpoints, not the public REST API) try: result = resolve_via_spotipyfree(sp_type, sp_id) print(json.dumps(result, ensure_ascii=False)) except SystemExit: raise except Exception as e: eprint(f'[SpotipyFree] failed: {type(e).__name__}: {e}') eprint('[spotify] tip: set SPOTIFY_PROXY=socks5://host:port or http://host:port to bypass IP restrictions') sys.exit(1) if __name__ == '__main__': try: main() except SystemExit: raise except Exception as e: eprint(f'fatal: {e}'); sys.exit(1)