forked from Keelrleek/SoulStream
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:
1 parent
8cd56a433e
commit
cef1363acd
1 file changed
+50
-4
+50
-4
@@ -3584,7 +3584,19 @@ function sanitizeSearchQuery(query) {
|
|||||||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||||||
* a workable set at 1800ms, and the full window only runs when results
|
* a workable set at 1800ms, and the full window only runs when results
|
||||||
* are genuinely sparse. */
|
* 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) {
|
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 token = await api.startSearch(query);
|
||||||
const responses = [];
|
const responses = [];
|
||||||
let unlisten = null;
|
let unlisten = null;
|
||||||
@@ -4256,7 +4268,7 @@ export async function streamSong(artist, title, opts = {}) {
|
|||||||
tracks: null,
|
tracks: null,
|
||||||
pending: false,
|
pending: false,
|
||||||
};
|
};
|
||||||
maybeExtendAlbumQueue();
|
scheduleAlbumFill();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4330,7 +4342,7 @@ export async function streamSong(artist, title, opts = {}) {
|
|||||||
tracks: null,
|
tracks: null,
|
||||||
pending: false,
|
pending: false,
|
||||||
};
|
};
|
||||||
maybeExtendAlbumQueue();
|
scheduleAlbumFill();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5279,6 +5291,15 @@ export async function autoConnect() {
|
|||||||
} catch {
|
} catch {
|
||||||
// status unavailable — fall through to the normal connect path
|
// 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 {
|
try {
|
||||||
const creds = await api.getCredentials();
|
const creds = await api.getCredentials();
|
||||||
if (!creds.has_password) return;
|
if (!creds.has_password) return;
|
||||||
@@ -5297,9 +5318,12 @@ export async function autoConnect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
lastAutoConnectFailAt = performance.now();
|
||||||
app.connection = "offline";
|
app.connection = "offline";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const AUTO_CONNECT_BACKOFF_MS = 3 * 60 * 1000;
|
||||||
|
let lastAutoConnectFailAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
export async function downloadFile(username, file) {
|
export async function downloadFile(username, file) {
|
||||||
await api.downloadFile(username, file.path, file.size);
|
await api.downloadFile(username, file.path, file.size);
|
||||||
@@ -5400,7 +5424,7 @@ function playQueueIndex(i) {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
player.error = String(e);
|
player.error = String(e);
|
||||||
});
|
});
|
||||||
maybeExtendAlbumQueue();
|
scheduleAlbumFill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
player.streamTotal = 0;
|
player.streamTotal = 0;
|
||||||
@@ -5515,7 +5539,7 @@ function advanceGaplessly() {
|
|||||||
loadStreamWaveform(item);
|
loadStreamWaveform(item);
|
||||||
preloadNextStream();
|
preloadNextStream();
|
||||||
tryAppendNext();
|
tryAppendNext();
|
||||||
maybeExtendAlbumQueue();
|
scheduleAlbumFill();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
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.
|
// over the following minute instead of never.
|
||||||
let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending }
|
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() {
|
async function maybeExtendAlbumQueue() {
|
||||||
const ctx = autoAlbum;
|
const ctx = autoAlbum;
|
||||||
if (!ctx || ctx.pending) return;
|
if (!ctx || ctx.pending) return;
|
||||||
@@ -5829,6 +5873,8 @@ export function cycleRepeat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopPlayback() {
|
function stopPlayback() {
|
||||||
|
clearTimeout(albumFillTimer);
|
||||||
|
albumFillTimer = null;
|
||||||
player.status = "idle";
|
player.status = "idle";
|
||||||
player.track = null;
|
player.track = null;
|
||||||
player.position = 0;
|
player.position = 0;
|
||||||
|
|||||||
Reference in new issue
Block a user