fix: replace SpotipyFree with curl_cffi, remove dead Node.js TOTP code

SpotipyFree crashes with KeyError: 'ownerV2' — Spotify changed their
internal API response format and the library is out of date.

curl_cffi presents a real Chrome TLS fingerprint so open.spotify.com/api/
server-time responds (plain Node.js TLS is filtered by Spotify's WAF).
Node.js TOTP fallback always failed on this for the same reason — removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-12 20:17:15 +02:00
1 parent 750089fc8e
commit 5265aa1f33
2 files changed
+110 -347

No files matched your search

+1 -288
View File
@@ -67,254 +67,6 @@ function parseSpotifyUrl(url) {
return { type: m[1], id: m[2] }; 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 ────────────────────────────────────────────────────────────────── // ─── ROUTES ──────────────────────────────────────────────────────────────────
@@ -373,46 +125,7 @@ async function _resolveSpotifyViaSpotdl(url) {
} }
async function _resolveSpotify(type, id, originalUrl) { async function _resolveSpotify(type, id, originalUrl) {
// Primary: SpotipyFree (SpotAPI + tls_client Chrome fingerprint, Spotify internal endpoints) return await _resolveSpotifyViaSpotdl(originalUrl);
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}`);
} }
app.post('/api/spotify/resolve', async (req, res) => { app.post('/api/spotify/resolve', async (req, res) => {
+109 -59
View File
@@ -1,84 +1,135 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Resolves Spotify URLs via SpotipyFree (SpotAPI + tls_client Chrome fingerprint, Resolves Spotify URLs via curl_cffi Chrome TLS impersonation + TOTP anonymous token.
hits Spotify's internal spclient endpoints — not the rate-limited public REST API). 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) 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 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 {} alb = t.get('album') or {}
imgs = alb.get('images') 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 { return {
'trackName': t.get('name',''), 'trackName': t.get('name', ''),
'artistName': artists, 'artistName': artists,
'collectionName': alb.get('name','') or default_album, 'collectionName': alb.get('name', '') or default_album,
'artworkUrl100': art, 'artworkUrl100': art,
'releaseDate': alb.get('release_date',''), 'releaseDate': alb.get('release_date', ''),
'trackNumber': t.get('track_number'), 'trackNumber': t.get('track_number'),
'trackExplicitness': 'explicit' if t.get('explicit') else 'notExplicit', 'trackExplicitness': 'explicit' if t.get('explicit') else 'notExplicit',
'durationMs': t.get('duration_ms'), 'durationMs': t.get('duration_ms'),
} }
def resolve_via_spotipyfree(sp_type, sp_id): def _best_art(imgs):
from SpotipyFree import Spotify as Sp return sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url', '') if imgs else ''
sp = Sp()
eprint('[SpotipyFree] session created') 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': 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 [])) artist = ', '.join(a['name'] for a in (t.get('artists') or []))
imgs = (t.get('album') or {}).get('images') or [] art = _best_art((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,
tr = _fmt_spotipy_track(t) 'tracks': [_fmt_track(t)]}
return {'type':'track','name':t.get('name',''),'artist':artist,'artworkUrl':art,'tracks':[tr]}
if sp_type == 'album': 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 [])) artist = ', '.join(a['name'] for a in (alb.get('artists') or []))
imgs = alb.get('images') or [] art = _best_art(alb.get('images') or [])
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else '' tracks, path = [], f'/albums/{sp_id}/tracks?limit=50'
tracks, offset = [], 0 while path:
while True: pg = _rest(session, path, token)
page = sp.album_tracks(sp_id, limit=50, offset=offset) tracks += [t for t in [_fmt_track(i, alb.get('name', ''), art, artist)
for t in (page.get('items') or []): for i in (pg.get('items') or []) if i and i.get('id')] if t]
tr = _fmt_spotipy_track(t, alb.get('name',''), art, artist) path = pg.get('next')
if tr: tracks.append(tr) if path: time.sleep(0.3)
if not page.get('next'): break eprint(f'[curl_cffi] album ok — {len(tracks)} tracks')
offset += len(page.get('items') or []) return {'type': 'album', 'name': alb.get('name', ''), 'artist': artist,
time.sleep(0.3) 'artworkUrl': art, 'tracks': tracks}
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 = _rest(session, f'/playlists/{sp_id}?fields=name,description,owner,images', token)
pl = sp.playlist(sp_id, fields='name,owner,images') owner = (pl.get('owner') or {}).get('display_name', '')
imgs = pl.get('images') or [] art = _best_art(pl.get('images') or [])
art = sorted(imgs, key=lambda x: -(x.get('width') or 0))[0].get('url','') if imgs else '' tracks, skipped, path = [], 0, f'/playlists/{sp_id}/tracks?limit=100'
owner = (pl.get('owner') or {}).get('display_name','') while path:
tracks, skipped, offset = [], 0, 0 pg = _rest(session, path, token)
while True: for item in (pg.get('items') or []):
page = sp.playlist_items(sp_id, limit=100, offset=offset, t = item.get('track')
additional_types=['track']) if not t or not t.get('id'): continue
for item in (page.get('items') or []): if t.get('is_local'): skipped += 1; continue
t = item.get('track') tr = _fmt_track(t)
if not t or not t.get('id'): continue if tr: tracks.append(tr)
if t.get('is_local'): skipped += 1; continue path = pg.get('next')
tr = _fmt_spotipy_track(t) if path: time.sleep(0.3)
if tr: tracks.append(tr) eprint(f'[curl_cffi] playlist ok — {len(tracks)} tracks')
if not page.get('next'): break return {'type': 'playlist', 'name': pl.get('name', ''), 'artist': owner,
offset += len(page.get('items') or []) 'artworkUrl': art, 'skippedLocal': skipped, 'tracks': tracks}
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(): def main():
if len(sys.argv) < 2: eprint('No URL'); sys.exit(1) 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) if not m: eprint('Invalid URL'); sys.exit(1)
sp_type, sp_id = m.group(1), m.group(2) sp_type, sp_id = m.group(1), m.group(2)
eprint(f'[spotify] resolving {sp_type} {sp_id}') eprint(f'[spotify] resolving {sp_type} {sp_id}')
try: try:
result = resolve_via_spotipyfree(sp_type, sp_id) result = resolve(sp_type, sp_id)
print(json.dumps(result, ensure_ascii=False)) print(json.dumps(result, ensure_ascii=False))
except SystemExit: raise except SystemExit: raise
except Exception as e: except Exception as e:
eprint(f'[SpotipyFree] failed: {type(e).__name__}: {e}') eprint(f'[curl_cffi] failed: {type(e).__name__}: {e}')
sys.exit(1) sys.exit(1)
if __name__ == '__main__': if __name__ == '__main__':