From d4c41a9f92c0e50ee19bf0ab5fdb4b07d41c9142 Mon Sep 17 00:00:00 2001 From: Honey Date: Tue, 12 May 2026 20:22:10 +0200 Subject: [PATCH] fix: enforce Spotify rate limit cooldown server- and client-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server tracks _spRateLimitUntil and rejects retries instantly without hitting Spotify, returning the remaining wait time. Retrying before the window expired was resetting Spotify's timer (19s → 7s → 48s). Frontend now shows a live countdown with a note not to retry early. Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 17 +++++++++++++---- src/spotify.js | 28 +++++++++++++++++++++------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index bc94573..9953dac 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -124,13 +124,21 @@ async function _resolveSpotifyViaSpotdl(url) { }); } -async function _resolveSpotify(type, id, originalUrl) { - return await _resolveSpotifyViaSpotdl(originalUrl); -} +// Tracks when the Spotify rate limit window expires so retries never reach Spotify. +let _spRateLimitUntil = 0; app.post('/api/spotify/resolve', async (req, res) => { const { url } = req.body; if (!url) return res.status(400).json({ error: 'url required' }); + + // Block retries until the cooldown the Spotify told us has actually elapsed. + const now = Date.now(); + if (_spRateLimitUntil > now) { + const secs = Math.ceil((_spRateLimitUntil - now) / 1000); + console.log(`[spotify/resolve] cooldown active — ${secs}s remaining`); + return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); + } + try { const { type, id } = parseSpotifyUrl(url); console.log(`[spotify/resolve] ${type} ${id}`); @@ -138,7 +146,7 @@ app.post('/api/spotify/resolve', async (req, res) => { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('Zeitüberschreitung — Spotify API antwortet nicht (>120s)')), 120000) ); - const data = await Promise.race([_resolveSpotify(type, id, url), timeout]); + const data = await Promise.race([_resolveSpotifyViaSpotdl(url), timeout]); res.json(data); } catch(e) { console.error('[spotify/resolve]', e.message); @@ -147,6 +155,7 @@ app.post('/api/spotify/resolve', async (req, res) => { } if (e.message.startsWith('RATE_LIMITED:')) { const secs = parseInt(e.message.split(':')[1], 10) || 30; + _spRateLimitUntil = Date.now() + secs * 1000; return res.status(429).json({ error: 'RATE_LIMITED', retryAfter: secs }); } res.status(500).json({ error: e.message }); diff --git a/src/spotify.js b/src/spotify.js index 6f85fa4..059d726 100644 --- a/src/spotify.js +++ b/src/spotify.js @@ -63,13 +63,7 @@ export async function resolveSpotifyLink() { } if (e.message.startsWith('RATE_LIMITED:')) { const secs = parseInt(e.message.split(':')[1], 10) || 30; - const mins = secs >= 60 ? `${Math.ceil(secs / 60)} Min.` : `${secs}s`; - inner.innerHTML = `
-
⏳ Spotify Rate Limit
-
- Spotify hat zu viele Anfragen erhalten. Bitte in ca. ${mins} erneut versuchen. -
-
`; + _startRateLimitCountdown(inner, secs); return; } if (e.message === 'EDITORIAL_PLAYLIST') { @@ -232,6 +226,26 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) { } } +function _startRateLimitCountdown(inner, totalSecs) { + const endMs = Date.now() + totalSecs * 1000; + const render = (remaining) => { + const s = Math.max(0, Math.ceil(remaining / 1000)); + const label = s >= 60 ? `${Math.ceil(s / 60)} Min.` : `${s}s`; + inner.innerHTML = `
+
⏳ Spotify Rate Limit
+
+ Bitte warte noch ${label} — nicht erneut versuchen, das setzt den Timer zurück. +
+
`; + }; + render(totalSecs * 1000); + const iv = setInterval(() => { + const remaining = endMs - Date.now(); + if (remaining <= 0) { clearInterval(iv); render(0); return; } + render(remaining); + }, 1000); +} + function _setArt(el, url) { if (!el) return; const img = document.createElement('img');