diff --git a/src/lib/server/music/poller.ts b/src/lib/server/music/poller.ts index 22f2b2f..ff9bce6 100644 --- a/src/lib/server/music/poller.ts +++ b/src/lib/server/music/poller.ts @@ -23,10 +23,20 @@ import { getLiveFromDb } from './stats'; // off to 30 s when paused or idle, and honouring 429 Retry-After. Changes (new track, // play/pause, seek) are pushed to browsers via ./live. Recently played (the play log) runs // every minute, plus ~10 s after a track change so a finished track lands quickly. +// Spotify rate-limits endpoints separately (recently-played can be blocked for hours while +// currently-playing still answers), so a 429 on the play log only pauses the play log, for +// the full Retry-After, and never slows the now-playing card down. const LEASE_SECONDS = 20; // renewed every tick; another container takes over 20 s after we stop const RECENT_MS = 60_000; const CATCH_UP_MS = 10_000; const HOLDER = `app-${crypto.randomUUID().slice(0, 8)}`; +const MAX_RETRY_S = 24 * 3600; +const retryMs = (e: SpotifyError, fallback: number) => + Math.min(MAX_RETRY_S, Math.max(5, e.retryAfter ?? fallback)) * 1000; +const rateLimited = (e: unknown): e is SpotifyError => + e instanceof SpotifyError && e.status === 429; +const until = (ms: number) => + new Date(Date.now() + ms).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; let timer: ReturnType | undefined; let stopped = false; @@ -35,6 +45,8 @@ let last: PlayerSample | null = null; let idleTicks = 0; let nextRecentAt = 0; let catchUps = 0; +let recentBlockedUntil = 0; +let recentError: string | null = null; let cache = newCache(); export const spotifyCredentials = () => ({ @@ -86,22 +98,34 @@ export async function tick(): Promise { } last = sample; idleTicks = sample.isPlaying ? 0 : idleTicks + 1; - if (Date.now() >= nextRecentAt) { - const added = await pollRecent(client, access, cache); - // Spotify can take a little while to log a finished track: retry twice, 20 s apart. - if (!added && catchUps > 0) { - catchUps--; - nextRecentAt = Date.now() + 20_000; - } else { + if (Date.now() >= nextRecentAt && Date.now() >= recentBlockedUntil) { + try { + const added = await pollRecent(client, access, cache); + // Spotify can take a little while to log a finished track: retry twice, 20 s apart. + if (!added && catchUps > 0) { + catchUps--; + nextRecentAt = Date.now() + 20_000; + } else { + catchUps = 0; + nextRecentAt = Date.now() + RECENT_MS; + } + } catch (e) { + if (!rateLimited(e)) throw e; + const wait = retryMs(e, 60); + recentBlockedUntil = Date.now() + wait; catchUps = 0; - nextRecentAt = Date.now() + RECENT_MS; + recentError = `${e.message} (play log paused until ${until(wait)})`; } } + if (Date.now() < recentBlockedUntil) error = recentError; return nextDelay(sample.isPlaying, idleTicks); } catch (e) { error = await handlePollError(client, e).catch(() => String(e)); - if (e instanceof SpotifyError && e.status === 429) - return Math.min(600, Math.max(5, e.retryAfter ?? 30)) * 1000; + if (rateLimited(e)) { + const wait = retryMs(e, 30); + error = `${error} (paused until ${until(wait)})`; + return wait; + } return 30_000; } finally { log(error ?? '');