feat: TOTP-based anonymous Spotify token (no developer credentials needed)
Dynamically extracts Spotify's TOTP secret from their web-player JS bundle, computes a 6-digit HMAC-SHA1 code synced to open.spotify.com/api/server-time, and requests a token from open.spotify.com/api/token — completely bypassing the rate-limited Client Credentials token. Client Credentials kept as fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
61271bff27
commit
fbb00baa11
1 file changed
+97
-51
+97
-51
@@ -113,90 +113,136 @@ function _fetchTimeout(url, opts = {}, ms = 10000) {
|
||||
|
||||
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;
|
||||
|
||||
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.
|
||||
// Approach 1: TOTP-based anonymous token — no developer credentials needed,
|
||||
// uses open.spotify.com/api/token with a computed 6-digit code.
|
||||
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',
|
||||
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 (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',
|
||||
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',
|
||||
'Cookie': `sp_t=${randomUUID()}`,
|
||||
'Client-Token': clientToken,
|
||||
'Referer': 'https://open.spotify.com/',
|
||||
'Accept': 'application/json',
|
||||
'Referer': 'https://open.spotify.com/',
|
||||
},
|
||||
}
|
||||
);
|
||||
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)');
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
console.log('[spotify] TOTP failed:', e.message);
|
||||
_spSecretCache = null; // force secret re-extraction next time
|
||||
}
|
||||
|
||||
// Approach 2: official Client Credentials (requires SPOTIFY_CLIENT_ID / SECRET in env)
|
||||
// Approach 2: Client Credentials fallback (requires SPOTIFY_CLIENT_ID/SECRET in env)
|
||||
if (!CONFIG.spotifyClientId || !CONFIG.spotifyClientSecret) {
|
||||
throw new Error(
|
||||
'Spotify-Token nicht abrufbar. Bitte SPOTIFY_CLIENT_ID und ' +
|
||||
'SPOTIFY_CLIENT_SECRET in der Umgebung setzen. ' +
|
||||
'Setup: https://developer.spotify.com/dashboard'
|
||||
'Spotify-Token nicht abrufbar (TOTP gescheitert). ' +
|
||||
'SPOTIFY_CLIENT_ID und SPOTIFY_CLIENT_SECRET setzen als Fallback.'
|
||||
);
|
||||
}
|
||||
|
||||
const basic = Buffer.from(`${CONFIG.spotifyClientId}:${CONFIG.spotifyClientSecret}`).toString('base64');
|
||||
const r = await _fetchTimeout('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${basic}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
headers: { 'Authorization': `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'grant_type=client_credentials',
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const errBody = await r.text().catch(() => '');
|
||||
throw new Error(`Spotify-Token-Fehler (${r.status}): ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const d = await r.json();
|
||||
if (!d.access_token) throw new Error('Kein access_token von Spotify erhalten');
|
||||
|
||||
_spTokenCache = d.access_token;
|
||||
_spTokenExpiry = Date.now() + (d.expires_in * 1000) - 30000;
|
||||
console.log('[spotify] token ok (Client Credentials)');
|
||||
|
||||
Reference in new issue
Block a user