From cef1363acd4c465df54fff5ffde7dc34716e860e Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:36:27 -0500 Subject: [PATCH] playback: stop flooding the Soulseek server from the album fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking rapidly through a record's tracks kept dropping the server session and arming its reconnect throttle — a fresh login died inside ~90 seconds of a click-storm. Cached plays themselves were innocent (play_stream's cached branch never touches the network, preloads cache-check first); the traffic was the album FILL, which every play restarted immediately and which fires a server search for every remaining track the source memory does not know. Three protections, layered: The fill now starts only after the queue has sat still for four seconds, and a new play cancels the pending start — stress-clicking sends nothing at all, while normal listening fills the album four seconds into a track, hours before it matters. Searches are globally paced: never two inside 1.5 seconds, whoever asks. A user-triggered resolve still feels instant — the first search never waits — only bursts are spread. And a failed AUTOMATIC connect arms a three-minute backoff. Every dev-reload of the page runs autoConnect, and an auto-dial during an outage only extends the server's cooldown; the user's own login button still dials whenever pressed. --- src/lib/state.svelte.js | 54 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 9fea2dd..1c914ae 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -3584,7 +3584,19 @@ function sanitizeSearchQuery(query) { * Thresholds are adaptive — a flood of good candidates releases at 900ms, * a workable set at 1800ms, and the full window only runs when results * are genuinely sparse. */ +/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER + * traffic, and the server drops sessions that flood it — measured + * 2026-08-03: a fresh login was killed inside ~90 seconds while background + * album fills re-searched on every rapid track click, and the reconnect + * throttle then locked the account out. User-triggered resolves feel + * instant (the first search never waits); only bursts are spread. */ +const SEARCH_MIN_GAP_MS = 1500; +let lastSearchStartedAt = 0; + async function collectSources(query, maxMs, goodCount) { + const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now(); + if (gap > 0) await new Promise((r) => setTimeout(r, gap)); + lastSearchStartedAt = performance.now(); const token = await api.startSearch(query); const responses = []; let unlisten = null; @@ -4256,7 +4268,7 @@ export async function streamSong(artist, title, opts = {}) { tracks: null, pending: false, }; - maybeExtendAlbumQueue(); + scheduleAlbumFill(); } return; } @@ -4330,7 +4342,7 @@ export async function streamSong(artist, title, opts = {}) { tracks: null, pending: false, }; - maybeExtendAlbumQueue(); + scheduleAlbumFill(); } return; } @@ -5279,6 +5291,15 @@ export async function autoConnect() { } catch { // status unavailable — fall through to the normal connect path } + // A failed automatic attempt arms a backoff: the server throttles rapid + // reconnects into "timed out" silence, and every dev-reload of the page + // lands here — an auto-dial during an outage only extends the cooldown + // (paid for on 2026-08-03, twice). The USER can always dial through the + // login modal; only the automatic path waits. + if (performance.now() - lastAutoConnectFailAt < AUTO_CONNECT_BACKOFF_MS) { + app.connection = "offline"; + return; + } try { const creds = await api.getCredentials(); if (!creds.has_password) return; @@ -5297,9 +5318,12 @@ export async function autoConnect() { return; } } catch {} + lastAutoConnectFailAt = performance.now(); app.connection = "offline"; } } +const AUTO_CONNECT_BACKOFF_MS = 3 * 60 * 1000; +let lastAutoConnectFailAt = Number.NEGATIVE_INFINITY; export async function downloadFile(username, file) { await api.downloadFile(username, file.path, file.size); @@ -5400,7 +5424,7 @@ function playQueueIndex(i) { .catch((e) => { player.error = String(e); }); - maybeExtendAlbumQueue(); + scheduleAlbumFill(); return; } player.streamTotal = 0; @@ -5515,7 +5539,7 @@ function advanceGaplessly() { loadStreamWaveform(item); preloadNextStream(); tryAppendNext(); - maybeExtendAlbumQueue(); + scheduleAlbumFill(); return; } player.track = { title: item.title, sub: item.sub, source: "local", path: item.path }; @@ -5537,6 +5561,26 @@ function advanceGaplessly() { // over the following minute instead of never. let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending } +/** Start the album fill only after the queue has sat still for a moment. + * + * The fill fires a Soulseek SEARCH for every remaining track the source + * memory does not know, and each play used to start it immediately — so + * stress-clicking through a record restarted it on every click and flooded + * the server with searches (2026-08-03: a fresh login was dropped inside + * ~90 seconds of exactly that, and the reconnect throttle followed). A new + * play cancels the pending start, so rapid track-hopping sends NOTHING; + * normal listening starts the fill four seconds into the track, hours + * before it is needed. */ +const ALBUM_FILL_DELAY_MS = 4000; +let albumFillTimer = null; +function scheduleAlbumFill() { + clearTimeout(albumFillTimer); + albumFillTimer = setTimeout(() => { + albumFillTimer = null; + void maybeExtendAlbumQueue(); + }, ALBUM_FILL_DELAY_MS); +} + async function maybeExtendAlbumQueue() { const ctx = autoAlbum; if (!ctx || ctx.pending) return; @@ -5829,6 +5873,8 @@ export function cycleRepeat() { } function stopPlayback() { + clearTimeout(albumFillTimer); + albumFillTimer = null; player.status = "idle"; player.track = null; player.position = 0;