From 61271bff27a7156c04d63d41c7d5c5e8f3116d1e Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 10 May 2026 18:59:55 +0200 Subject: [PATCH] fix: use anonymous clienttoken as primary Spotify auth, Client Credentials as fallback Developer Client ID is 24h rate-banned. clienttoken.spotify.com uses Spotify's own web-player client ID (separate rate-limit bucket). Also: fast-fail when Retry-After > 60s and invalidate the cached token so the next request immediately fetches a fresh one. Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 71 ++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index b194337..465a270 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -116,11 +116,66 @@ let _spTokenExpiry = 0; async function fetchSpotifyToken() { if (_spTokenCache && Date.now() < _spTokenExpiry) return _spTokenCache; + + const { randomUUID } = require('crypto'); + + // Approach 1: anonymous web-player token via clienttoken.spotify.com + // Uses Spotify's own web-player client ID — separate rate-limit bucket from developer apps. + try { + const ctRes = await _fetchTimeout('https://clienttoken.spotify.com/v1/clienttoken', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_data: { + client_version: '1.2.52.442.g276ab0bb', + client_id: 'd8a5ed958d274c2e8ee717e6a4b0971d', + js_sdk_data: { + device_brand: 'unknown', device_model: 'unknown', + os: 'windows', os_version: 'NT 10.0', + device_id: randomUUID(), device_type: 'computer', + }, + }, + }), + }); + if (ctRes.ok) { + const ct = await ctRes.json(); + const clientToken = ct.granted_token?.token; + if (clientToken) { + const atRes = await _fetchTimeout( + 'https://open.spotify.com/get_access_token?reason=transport&productType=web_player', + { + 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', + 'Cookie': `sp_t=${randomUUID()}`, + 'Client-Token': clientToken, + 'Referer': 'https://open.spotify.com/', + 'Accept': 'application/json', + }, + } + ); + if (atRes.ok) { + const at = await atRes.json(); + if (at.accessToken) { + _spTokenCache = at.accessToken; + _spTokenExpiry = (at.accessTokenExpirationTimestampMs || (Date.now() + 55 * 60 * 1000)) - 30000; + console.log('[spotify] token ok (anonymous)'); + return _spTokenCache; + } + } + console.log('[spotify] get_access_token status:', atRes.status); + } + } + console.log('[spotify] anonymous token failed, trying Client Credentials'); + } catch (e) { + console.log('[spotify] anonymous token error:', e.message); + } + + // Approach 2: official Client Credentials (requires SPOTIFY_CLIENT_ID / SECRET in env) if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) { throw new Error( - 'Spotify-Credentials fehlen. Bitte SPOTIFY_CLIENT_ID und ' + + 'Spotify-Token nicht abrufbar. Bitte SPOTIFY_CLIENT_ID und ' + 'SPOTIFY_CLIENT_SECRET in der Umgebung setzen. ' + - 'Setup-Anleitung: https://developer.spotify.com/dashboard' + 'Setup: https://developer.spotify.com/dashboard' ); } @@ -144,7 +199,7 @@ async function fetchSpotifyToken() { _spTokenCache = d.access_token; _spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000; - console.log('[spotify] token ok'); + console.log('[spotify] token ok (Client Credentials)'); return _spTokenCache; } @@ -154,8 +209,14 @@ async function _spApi(path, token) { const r = await _fetchTimeout(url, { headers: { Authorization: `Bearer ${token}` } }); if (r.status === 429) { const retryAfter = parseInt(r.headers.get('Retry-After') || '5', 10); - const wait = Math.min(retryAfter + 1, 15) * 1000; // cap at 15s per retry - console.log(`[spotify] 429 rate-limited, waiting ${wait / 1000}s (Retry-After: ${retryAfter}s)`); + // Long Retry-After = token is banned — invalidate cache and fail immediately + if (retryAfter > 60) { + _spTokenCache = null; + _spTokenExpiry = 0; + throw new Error(`Spotify-Token für ${Math.round(retryAfter / 3600)}h gesperrt (Rate Limit). Token-Cache geleert — nächster Versuch holt neuen Token.`); + } + const wait = (retryAfter + 1) * 1000; + console.log(`[spotify] 429 waiting ${retryAfter + 1}s`); await new Promise(res => setTimeout(res, wait)); continue; }