diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 537229d..bc94573 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -67,254 +67,6 @@ function parseSpotifyUrl(url) { return { type: m[1], id: m[2] }; } -// undici (native fetch) cannot abort a pending TCP connect even with AbortController. -// Use Node's https module with socket.setTimeout() — fires at OS level during connect. -function _fetchTimeout(url, opts = {}, ms = 10000) { - return new Promise((resolve, reject) => { - const u = new URL(url); - const label = u.hostname + u.pathname.slice(0, 40); - const done = (fn) => { if (!done.called) { done.called = true; fn(); } }; - const req = https.request({ - hostname: u.hostname, port: 443, - path: u.pathname + u.search, - method: opts.method || 'GET', - // Force HTTP/1.1 — api.spotify.com negotiates h2 via ALPN but - // https.request uses HTTP/1.1 framing, causing a silent hang. - ALPNProtocols: ['http/1.1'], - headers: { - 'User-Agent': 'NovaSonic/1.0', - 'Accept': 'application/json', - ...opts.headers, - }, - }, res => { - console.log(`[sp-http] ${res.statusCode} ${label}`); - const chunks = []; - res.on('data', c => chunks.push(c)); - res.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - done(() => resolve({ - ok: res.statusCode >= 200 && res.statusCode < 300, - status: res.statusCode, - headers: { get: h => res.headers[h.toLowerCase()] ?? null }, - json: () => { try { return Promise.resolve(JSON.parse(body)); } catch(e) { return Promise.reject(e); } }, - text: () => Promise.resolve(body), - })); - }); - res.on('error', e => done(() => reject(e))); - }); - // Two-layer timeout: req-level (fires after ms of total inactivity) + socket-level - req.setTimeout(ms, () => req.destroy(new Error(`Spotify timeout ${ms}ms: ${label}`))); - req.on('socket', s => s.setTimeout(ms, () => req.destroy(new Error(`Spotify socket timeout ${ms}ms: ${label}`)))); - req.on('error', e => done(() => reject(e))); - if (opts.body) req.write(opts.body); - req.end(); - }); -} - -let _spTokenCache = null; -let _spTokenExpiry = 0; -let _spSecretCache = null; -let _spSecretExp = 0; - -// Dynamically extract Spotify's TOTP secret from their web-player JS bundle. -async function _fetchSpotifySecret() { - if (_spSecretCache && Date.now() < _spSecretExp) return _spSecretCache; - - const htmlRes = await _fetchTimeout('https://open.spotify.com/', { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.9', - 'Accept-Encoding': 'identity', - }, - }, 20000); - if (!htmlRes.ok) throw new Error(`open.spotify.com ${htmlRes.status}`); - const html = await htmlRes.text(); - - // Collect JS bundle URLs referenced by the main page - const seen = new Set(); - const jsUrls = []; - const srcRe = /src="((?:https?:\/\/[^"]*|\/[^"]*?)\.js(?:\?[^"]*)?)"/g; - let m; - while ((m = srcRe.exec(html)) !== null) { - const url = m[1].startsWith('http') ? m[1] : 'https://open.spotify.com' + m[1]; - if (!seen.has(url)) { seen.add(url); jsUrls.push(url); } - } - - // Search each bundle for {secret:"...",version:N} - for (const url of jsUrls.slice(0, 15)) { - try { - const r = await _fetchTimeout(url, { - headers: { 'User-Agent': 'Mozilla/5.0', 'Accept': '*/*', 'Accept-Encoding': 'identity' }, - }, 8000); - if (!r.ok) continue; - const js = await r.text(); - const sm = js.match(/\{secret:\s*["']([^"']+)["']\s*,\s*version:\s*(\d+)\s*\}/); - if (sm) { - const secret = [...sm[1]].map(c => c.charCodeAt(0)); - const version = parseInt(sm[2], 10); - _spSecretCache = { secret, version }; - _spSecretExp = Date.now() + 60 * 60 * 1000; // cache 1h - console.log(`[spotify] TOTP secret v${version} found`); - return _spSecretCache; - } - } catch { /* try next bundle */ } - } - throw new Error('Spotify TOTP-Secret nicht gefunden'); -} - -function _generateTotp(secretArray, serverTimestamp) { - const crypto = require('crypto'); - // XOR transform each byte with ((index % 33) + 9) - const transformed = secretArray.map((b, i) => b ^ ((i % 33) + 9)); - const secretBytes = Buffer.from(Buffer.from(transformed.join(''), 'utf8').toString('hex'), 'hex'); - const counter = Math.floor(serverTimestamp / 30); - const counterBuf = Buffer.alloc(8); - counterBuf.writeBigUInt64BE(BigInt(counter)); - const digest = crypto.createHmac('sha1', secretBytes).update(counterBuf).digest(); - const offset = digest[digest.length - 1] & 0xf; - const code = ( - ((digest[offset] & 0x7f) << 24) | - ((digest[offset + 1] & 0xff) << 16) | - ((digest[offset + 2] & 0xff) << 8) | - (digest[offset + 3] & 0xff) - ) % 1_000_000; - return code.toString().padStart(6, '0'); -} - -async function fetchSpotifyToken() { - if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache; - - // Approach 1: TOTP-based anonymous token — no developer credentials needed, - // uses open.spotify.com/api/token with a computed 6-digit code. - try { - const { secret, version } = await _fetchSpotifySecret(); - - const stRes = await _fetchTimeout('https://open.spotify.com/api/server-time', { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept': 'application/json', - 'Referer': 'https://open.spotify.com/', - }, - }); - if (!stRes.ok) throw new Error(`server-time ${stRes.status}`); - const { serverTime } = await stRes.json(); - - const totp = _generateTotp(secret, serverTime); - const tRes = await _fetchTimeout( - `https://open.spotify.com/api/token?reason=init&productType=web-player&totp=${totp}&totpVer=${version}&totpServer=${totp}`, - { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept': 'application/json', - 'Referer': 'https://open.spotify.com/', - }, - } - ); - if (!tRes.ok) throw new Error(`api/token ${tRes.status}`); - const t = await tRes.json(); - if (!t.accessToken) throw new Error('no accessToken in response'); - - _spTokenCache = t.accessToken; - _spTokenExpiry = (t.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000; - console.log('[spotify] token ok (TOTP)'); - return _spTokenCache; - } catch (e) { - console.log('[spotify] TOTP failed:', e.message); - _spSecretCache = null; // force secret re-extraction next time - } - - throw new Error('Spotify-Token nicht abrufbar (TOTP gescheitert)'); -} - -async function _spApi(path, token) { - const url = path.startsWith('http') ? path : `https://api.spotify.com/v1${path}`; - const r = await _fetchTimeout(url, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Origin': 'https://open.spotify.com', - 'Referer': 'https://open.spotify.com/', - 'app-platform': 'WebPlayer', - 'spotify-app-version': '1.2.46.25.g7f189073', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', - 'Accept': 'application/json', - 'Accept-Language': 'en-US,en;q=0.9', - }, - }); - if (r.status === 429) { - const retryAfter = parseInt(r.headers.get('Retry-After') || '10', 10); - _spTokenCache = null; - _spTokenExpiry = 0; - console.log(`[spotify] 429 Retry-After=${retryAfter}s — failing fast`); - throw new Error(`RATE_LIMITED:${retryAfter}`); - } - if (!r.ok) throw new Error(`Spotify API Fehler ${r.status}: ${path}`); - return r.json(); -} - -async function _spAllPlaylistTracks(id, token) { - const tracks = []; - let skippedLocal = 0; - let path = `/playlists/${id}/tracks?limit=100`; - let pages = 0; - while (path && pages++ < 100) { - const page = await _spApi(path, token); - for (const item of page.items || []) { - const t = item.track; - if (!t || !t.id) continue; - if (t.is_local) { skippedLocal++; continue; } - tracks.push(t); - } - path = page.next || null; - if (path) await new Promise(r => setTimeout(r, 300)); // pace multi-page requests - } - return { tracks, skippedLocal }; -} - -async function _spAllAlbumTracks(id, token) { - const tracks = []; - let path = `/albums/${id}/tracks?limit=50`; - let pages = 0; - while (path && pages++ < 100) { - const page = await _spApi(path, token); - tracks.push(...(page.items || []).filter(t => t?.id)); - path = page.next || null; - } - return tracks; -} - -function _spArtwork(entity) { - // Real API: entity.images[] or entity.album.images[] - const imgs = entity?.images || entity?.album?.images || []; - if (imgs.length) return imgs.sort((a, b) => (b.width || 0) - (a.width || 0))[0]?.url || ''; - return ''; -} - -function _spAllArtists(artistsRaw) { - if (!artistsRaw) return ''; - const arr = Array.isArray(artistsRaw) ? artistsRaw : (artistsRaw.items || []); - return arr.map(a => a?.profile?.name || a?.name || '').filter(Boolean).join(', '); -} - -function _spTrackItem(t, defaultAlbum, defaultArt, defaultAlbumArtist) { - const trackName = t?.name || ''; - const artist = _spAllArtists(t?.artists) || defaultAlbumArtist || ''; - const art = _spArtwork(t) || defaultArt || ''; - const albumObj = t?.album || {}; - const album = albumObj?.name || defaultAlbum || ''; - const date = albumObj?.release_date || ''; - return { - trackName, - artistName: artist, - collectionName: album, - artworkUrl100: art, - releaseDate: date, - trackNumber: t?.track_number, - trackExplicitness: t?.explicit ? 'explicit' : 'notExplicit', - previewUrl: t?.preview_url || null, - durationMs: t?.duration_ms || null, - }; -} // ─── ROUTES ────────────────────────────────────────────────────────────────── @@ -373,46 +125,7 @@ async function _resolveSpotifyViaSpotdl(url) { } async function _resolveSpotify(type, id, originalUrl) { - // Primary: SpotipyFree (SpotAPI + tls_client Chrome fingerprint, Spotify internal endpoints) - try { - console.log('[spotify] trying spotdl resolver'); - return await _resolveSpotifyViaSpotdl(originalUrl); - } catch (e) { - if (e.message.startsWith('RATE_LIMITED:')) throw e; // propagate, don't retry via TOTP - console.log('[spotify] spotdl failed:', e.message, '— falling back to TOTP'); - } - - // Fallback: TOTP anonymous token - const token = await fetchSpotifyToken(); - - if (type === 'track') { - const t = await _spApi(`/tracks/${id}`, token); - const artist = _spAllArtists(t.artists); - const artUrl = _spArtwork(t); - return { type: 'track', name: t.name || '', artist, artworkUrl: artUrl, - tracks: [_spTrackItem(t, t.album?.name, artUrl, artist)] }; - } - - if (type === 'album') { - const album = await _spApi(`/albums/${id}`, token); - const artist = _spAllArtists(album.artists); - const artUrl = _spArtwork(album); - const trackItems = await _spAllAlbumTracks(id, token); - return { type: 'album', name: album.name || '', artist, artworkUrl: artUrl, - tracks: trackItems.map(t => _spTrackItem(t, album.name, artUrl, artist)) }; - } - - if (type === 'playlist') { - const pl = await _spApi(`/playlists/${id}?fields=name,description,owner,images`, token); - const owner = pl.owner?.display_name || ''; - const artUrl = _spArtwork(pl); - const { tracks: trackItems, skippedLocal } = await _spAllPlaylistTracks(id, token); - return { type: 'playlist', name: pl.name || '', artist: owner, artworkUrl: artUrl, - description: pl.description || '', skippedLocal, - tracks: trackItems.map(t => _spTrackItem(t, null, null, null)) }; - } - - throw new Error(`Unbekannter Typ: ${type}`); + return await _resolveSpotifyViaSpotdl(originalUrl); } app.post('/api/spotify/resolve', async (req, res) => { diff --git a/backend/spotify_resolve.py b/backend/spotify_resolve.py index 58342c4..ea3bb41 100644 --- a/backend/spotify_resolve.py +++ b/backend/spotify_resolve.py @@ -1,84 +1,135 @@ #!/usr/bin/env python3 """ -Resolves Spotify URLs via SpotipyFree (SpotAPI + tls_client Chrome fingerprint, -hits Spotify's internal spclient endpoints — not the rate-limited public REST API). +Resolves Spotify URLs via curl_cffi Chrome TLS impersonation + TOTP anonymous token. +curl_cffi presents a real Chrome TLS fingerprint, which gets past Spotify's bot filters +on open.spotify.com/api/server-time (plain Node.js/Python TLS does not). """ -import sys, json, re, time +import sys, json, re, hmac, hashlib, struct, time def eprint(*a): print(*a, file=sys.stderr) -# ─── APPROACH 1: SpotipyFree ───────────────────────────────────────────────── +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 _fmt_spotipy_track(t, default_album='', default_art='', default_artist=''): +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 _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 + 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 + art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url', '') if imgs else default_art return { - 'trackName': t.get('name',''), + 'trackName': t.get('name', ''), 'artistName': artists, - 'collectionName': alb.get('name','') or default_album, + 'collectionName': alb.get('name', '') or default_album, 'artworkUrl100': art, - 'releaseDate': alb.get('release_date',''), + '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') +def _best_art(imgs): + return sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url', '') if imgs else '' + +def resolve(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 = sp.track(sp_id) + t = _rest(session, f'/tracks/{sp_id}', token) 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]} + 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 = sp.album(sp_id) + alb = _rest(session, f'/albums/{sp_id}', token) 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} + 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') + if path: time.sleep(0.3) + eprint(f'[curl_cffi] 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} - -# ─── MAIN ──────────────────────────────────────────────────────────────────── + 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') + if path: time.sleep(0.3) + eprint(f'[curl_cffi] playlist ok — {len(tracks)} tracks') + return {'type': 'playlist', 'name': pl.get('name', ''), 'artist': owner, + 'artworkUrl': art, 'skippedLocal': skipped, 'tracks': tracks} def main(): if len(sys.argv) < 2: eprint('No URL'); sys.exit(1) @@ -87,13 +138,12 @@ def main(): 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: - result = resolve_via_spotipyfree(sp_type, sp_id) + result = resolve(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(f'[curl_cffi] failed: {type(e).__name__}: {e}') sys.exit(1) if __name__ == '__main__':