playback: streams try peers that can actually deliver, and give up on the

dead ones faster

"Da Funk" — hundreds of sources — took over a minute to start. The
quality-first score ranks a format tier at 1000 points and a free upload
slot at 600, so busy lossless peers owned the entire failover list; each
one queued the request and never sent a byte, and the flat 15s-per-
candidate walk burned the minute before a live peer got its turn.

For STREAMING, candidates are now stable-partitioned: peers with a free
upload slot first, best quality within each half. Quality still decides
among peers that can deliver immediately; a busy peer's better file is a
save-time choice, and downloads keep the pure ranking. The album path and
the background fill inherit the same order.

The failover budget drops from 15s flat to 12s for the first candidate
(an indirect handshake can honestly take ~10) and 8s for the rest — a
peer that has not sent a byte in eight seconds is not about to.

Worst case falls from ~60s of dead peers to ~36s, and the common case —
an available peer ranked first — starts in seconds.
This commit is contained in:
KeelrLeek committed 2026-08-03 17:48:16 -05:00
1 parent cef1363acd
commit a949c24677
1 file changed
+36 -6
+36 -6
View File
@@ -3808,10 +3808,20 @@ function songCandidates(
// Filename ends with the title → it IS this song, not a longer // Filename ends with the title → it IS this song, not a longer
// title containing it ("Holiday" vs "Holiday in Cambodia"). // title containing it ("Holiday" vs "Holiday in Cambodia").
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250; if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
out.push({ username: r.username, file: f, score: s }); out.push({ username: r.username, file: f, score: s, slotFree: !!r.has_free_upload_slot });
} }
} }
out.sort((a, b) => b.score - a.score); out.sort((a, b) => b.score - a.score);
// STREAMING needs bytes now: a peer with no free upload slot usually
// queues the request and never starts inside the failover window, and the
// quality-first score let busy lossless peers occupy the whole failover
// list — "Da Funk", with hundreds of sources, spent over a minute walking
// dead candidates (2026-08-03). Stable partition: peers that can deliver
// immediately first, best quality within each half. Downloads keep the
// pure ranking — a save can afford to wait in a queue for the better file.
if (streamOnly) {
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
}
return out; return out;
} }
@@ -3928,9 +3938,21 @@ function albumCandidates(
else if (folderColours.length) s -= 900; else if (folderColours.length) s -= 900;
} }
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
out.push({ username: g.username, folder: g.folder, files: g.files, audio, score: s }); out.push({
username: g.username,
folder: g.folder,
files: g.files,
audio,
score: s,
slotFree: !!g.r.has_free_upload_slot,
});
} }
out.sort((a, b) => b.score - a.score); out.sort((a, b) => b.score - a.score);
// Same availability partition as songCandidates, same reason: a streamed
// album must START; a busy peer's better folder is a save-time choice.
if (streamOnly) {
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
}
return out; return out;
} }
@@ -4300,7 +4322,13 @@ export async function streamSong(artist, title, opts = {}) {
resolver.error = `No streamable sources found for “${title}”`; resolver.error = `No streamable sources found for “${title}”`;
return; return;
} }
for (const c of candidates.slice(0, 4)) { // Failover budget: the first candidate gets 12s (an indirect handshake
// can honestly take ~10), the rest 8s — a candidate that has not sent a
// byte in 8 seconds is not about to. The old flat 15s × 4 walked a full
// minute of dead peers before the music started.
const tryList = candidates.slice(0, 4);
for (let attempt = 0; attempt < tryList.length; attempt++) {
const c = tryList[attempt];
player.queue = [ player.queue = [
{ {
path: c.file.path, path: c.file.path,
@@ -4324,7 +4352,7 @@ export async function streamSong(artist, title, opts = {}) {
]; ];
const queuedItem = player.queue[0]; const queuedItem = player.queue[0];
playQueueIndex(0); playQueueIndex(0);
if (await waitForStreamStart(15000)) { if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) {
// Keep the album going: resolve + pre-buffer the next album // Keep the album going: resolve + pre-buffer the next album
// track in the background, Spotify-style. // track in the background, Spotify-style.
// //
@@ -4570,7 +4598,9 @@ export async function streamCatalogAlbum(album) {
resolver.error = `No streamable album sources found for “${album.title}”`; resolver.error = `No streamable album sources found for “${album.title}”`;
return; return;
} }
for (const folder of candidates.slice(0, 3)) { const folderTries = candidates.slice(0, 3);
for (let attempt = 0; attempt < folderTries.length; attempt++) {
const folder = folderTries[attempt];
player.queue = folder.audio.map((f, i) => { player.queue = folder.audio.map((f, i) => {
const ct = catalogTrackFor(f, i, folder.audio, catalogTracks); const ct = catalogTrackFor(f, i, folder.audio, catalogTracks);
return { return {
@@ -4594,7 +4624,7 @@ export async function streamCatalogAlbum(album) {
for (const it of player.queue) rememberStreamSource(it); for (const it of player.queue) rememberStreamSource(it);
player.catalogRef = { albumId: album.id ?? null, trackId: null }; player.catalogRef = { albumId: album.id ?? null, trackId: null };
playQueueIndex(0); playQueueIndex(0);
if (await waitForStreamStart(15000)) return; if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) return;
} }
stopPlayback(); stopPlayback();
resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`; resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;