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] };
}
// 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) => {