diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 1c914ae..2e5f8d6 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -3808,10 +3808,20 @@ function songCandidates( // Filename ends with the title → it IS this song, not a longer // title containing it ("Holiday" vs "Holiday in Cambodia"). 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); + // 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; } @@ -3928,9 +3938,21 @@ function albumCandidates( else if (folderColours.length) s -= 900; } 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); + // 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; } @@ -4300,7 +4322,13 @@ export async function streamSong(artist, title, opts = {}) { resolver.error = `No streamable sources found for “${title}”`; 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 = [ { path: c.file.path, @@ -4324,7 +4352,7 @@ export async function streamSong(artist, title, opts = {}) { ]; const queuedItem = player.queue[0]; playQueueIndex(0); - if (await waitForStreamStart(15000)) { + if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) { // Keep the album going: resolve + pre-buffer the next album // 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}”`; 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) => { const ct = catalogTrackFor(f, i, folder.audio, catalogTracks); return { @@ -4594,7 +4624,7 @@ export async function streamCatalogAlbum(album) { for (const it of player.queue) rememberStreamSource(it); player.catalogRef = { albumId: album.id ?? null, trackId: null }; playQueueIndex(0); - if (await waitForStreamStart(15000)) return; + if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) return; } stopPlayback(); resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;