playback: stop flooding the Soulseek server from the album fill

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.
This commit is contained in:
KeelrLeek committed 2026-08-03 17:36:27 -05:00
1 parent 8cd56a433e
commit cef1363acd
1 file changed
+50 -4
+50 -4
View File
@@ -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;