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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-10 18:59:55 +02:00
1 parent 3633b4c5fa
commit 61271bff27
1 file changed
+66 -5
+66 -5
View File
@@ -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;
}