diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 140c04d..629e6c0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3900,6 +3900,26 @@ pub async fn stream_waveform( result } +/// Is this exact stream source already fully cached on disk? +/// +/// The catalog's play paths ask BEFORE running a Soulseek resolve: a complete +/// cache file can play immediately — even offline — and the search would only +/// rediscover the peer that filled it. Anything less than a byte-complete +/// file answers false, so eviction and half-finished transfers fall back to +/// the normal resolver on their own. +#[tauri::command] +pub fn stream_cache_ready( + app: tauri::AppHandle, + username: String, + path: String, + size: u64, +) -> bool { + let Some(cache) = playback::cache_path(&app, &username, &path) else { + return false; + }; + size > 0 && matches!(std::fs::metadata(&cache), Ok(md) if md.len() == size) +} + /// A waveform plus how much of the track it actually describes. `coverage` /// is the byte share of the transfer the peaks were decoded from (1.0 for a /// complete file); the peaks span exactly that prefix of the track and diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee4efeb..496833c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,7 @@ pub fn run() { commands::preload_stream, commands::preload_cached_stream, commands::stream_waveform, + commands::stream_cache_ready, preview::preview_resolve, preview::preview_prewarm, preview::preview_load, diff --git a/src/lib/api.js b/src/lib/api.js index 670c5aa..def73f7 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -182,6 +182,8 @@ export const preloadCachedStream = (username, path, size, duration) => invoke("preload_cached_stream", { username, path, size, duration }); export const streamWaveform = (username, path, size, buckets) => invoke("stream_waveform", { username, path, size, buckets }); +export const streamCacheReady = (username, path, size) => + invoke("stream_cache_ready", { username, path, size }); // Preview tracks (scrollable-track-previews): a fully separate audio path. // previewResolve returns { ok, lease | failure, retryable } — the signed URL diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 32681e0..1ddcb9e 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -3392,6 +3392,82 @@ export async function refreshSourcePrefs() { } } +// ---- remembered stream sources ------------------------------------------ +// The last source each catalog track streamed from, persisted so a track can +// play again WITHOUT a Soulseek search: its bytes are already in the stream +// cache, and the resolve would only rediscover the peer that put them there. +// The memory is a HINT, not a promise — before any search is skipped the +// backend confirms the exact file is byte-complete on disk, so eviction, a +// half-finished transfer, or a source that never delivered all fall back to +// the normal resolver on their own. +const STREAM_SOURCE_STORE = "soulstream.streamSources.v1"; +const STREAM_SOURCE_CAP = 400; +let streamSources = null; // Map key → {username, path, size, duration, quality} + +function streamSourceKeys(trackId, artist, title) { + const keys = []; + if (trackId != null) keys.push(`t:${trackId}`); + if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase()); + return keys; +} +function loadStreamSources() { + if (streamSources) return streamSources; + streamSources = new Map(); + try { + for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) { + streamSources.set(k, v); + } + } catch { + // corrupt store — start over; losing it only costs one search per track + } + return streamSources; +} +function saveStreamSources() { + try { + const entries = [...streamSources.entries()]; + localStorage.setItem( + STREAM_SOURCE_STORE, + JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)), + ); + } catch { + // quota — same story, the memory is only a shortcut + } +} +/** Called every time a stream item is queued to PLAY (not when it proves + * itself) — eager on purpose: a source that never delivers leaves no complete + * cache file, so the probe rejects it later and nothing was lost. Insertion + * order doubles as the LRU for the cap in saveStreamSources. */ +function rememberStreamSource(item) { + if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return; + const map = loadStreamSources(); + const record = { + username: item.stream.username, + path: item.path, + size: item.stream.size, + duration: item.duration ?? null, + quality: item.quality ?? null, + }; + for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) { + map.delete(key); + map.set(key, record); + } + saveStreamSources(); +} +/** The remembered source for this track, but ONLY if its cache file is + * byte-complete right now. Anything else is null: resolve normally. */ +async function cachedStreamSourceFor(trackId, artist, title) { + const map = loadStreamSources(); + for (const key of streamSourceKeys(trackId, artist, title)) { + const src = map.get(key); + if (!src) continue; + try { + if (await api.streamCacheReady(src.username, src.path, src.size)) return src; + } catch { + // backend unavailable — resolve normally + } + } + return null; +} void refreshSourcePrefs(); /** Whether a peer file satisfies the user's quality window for this mode. @@ -4146,6 +4222,44 @@ export async function streamSong(artist, title, opts = {}) { player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; return; } + // Streamed before and still byte-complete in the cache? Play those bytes + // now — no search, no resolver popup, no network requirement. This is the + // second-long "finding best source…" beat that used to sit between songs + // of an already-cached album. Checked before `requireOnline` on purpose: + // a cached track is playable offline. + const cached = await cachedStreamSourceFor(opts.trackId ?? null, artist, title); + if (cached) { + autoAlbum = null; + player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; + player.queue = [ + { + path: cached.path, + title, + sub: opts.album ? `${artist} — ${opts.album}` : artist, + artist, + album: opts.album ?? null, + duration: cached.duration ?? opts.expectSecs ?? null, + art: null, + artUrl: hqCover(opts.cover) ?? null, + trackId: opts.trackId ?? null, + stream: { username: cached.username, size: cached.size }, + quality: cached.quality ?? null, + }, + ]; + playQueueIndex(0); + // Keep the album going exactly like the resolved path does. + if (opts.albumId) { + autoAlbum = { + albumId: opts.albumId, + albumTitle: opts.album ?? null, + cover: hqCover(opts.cover) ?? null, + tracks: null, + pending: false, + }; + maybeExtendAlbumQueue(); + } + return; + } if (!requireOnline()) return; autoAlbum = null; player.track = { title, sub: "finding best source…", source: "stream" }; @@ -5221,6 +5335,7 @@ function playQueueIndex(i) { player.duration = item.duration; if (item.stream) { // Remote track streamed from a peer while it downloads to cache. + rememberStreamSource(item); player.streamReceived = 0; player.streamTotal = item.stream.size; // A fresh track must not inherit the previous one's refresh clock —