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:
1 parent
750089fc8e
commit
5265aa1f33
2 files changed
+110
-347
No files matched your search
+1
-288
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user