/* The phone's resolver: a catalog track or record → a peer's copy, through the desktop's own source rules (src/lib/sources.js — one copy, shared). The order is the desktop's: the library first, then the remembered source if its bytes are still complete in the cache, then the network — the query ladder, the candidates, and up to four peers tried in turn. A record queues every track from the ONE folder that wins. A song played off a record queues the rest of the record behind it as UNRESOLVED entries, each finding its own copy when its turn comes (the player calls hooks.resolveEntry). Failover lives in the player: an entry carries its runner-up peers in `t.alts`, and a stream that has not sent a byte in time moves to the next one. */ import * as api from "../lib/api.js"; import { resolveSources, songCandidates, albumCandidates, simplifyTitle, albumQueryTitle, cachedStreamSourceFor, rememberStreamSource, catalogTrackFor, cleanTrackName, hqCover, qualityOf, refreshSourcePrefs, libraryAlbumIn, libraryTrackIn, } from "../lib/sources.js"; import { player, hooks, playEntries, playAlbum } from "./player.svelte.js"; import { lib, tracksOf } from "./lib.svelte.js"; import { slsk } from "./slsk.svelte.js"; import { yearOf } from "./catalog.svelte.js"; export const resolver = $state({ active: false, label: "", error: "" }); void refreshSourcePrefs(); const online = () => slsk.connection === "online"; const OFFLINE = "Sign in on Community to play from the network."; /* ── shapes ────────────────────────────────────────────────────────── */ /** The record a streamed entry belongs to, as the player's `album`. No * library id: the catalog id rides in `catalogId`, and the cover URL is the * art key (art.svelte.js routes https through the catalog art proxy). */ function albumMeta(o, artist) { return { id: null, catalogId: o.albumId ?? null, title: o.album ?? null, artist: o.albumArtist ?? artist, year: o.year ?? null, art_path: hqCover(o.cover) ?? null, }; } function streamEntry(title, artist, src, o, album) { return { t: { id: o.trackId != null ? "cat:" + o.trackId : "stream:" + src.username + "|" + src.path, title, artist, path: src.path, duration_secs: src.duration ?? o.expectSecs ?? null, stream: { username: src.username, size: src.size }, catalog: { trackId: o.trackId ?? null, albumId: o.albumId ?? null, expectSecs: o.expectSecs ?? null }, alts: [], }, album, }; } /** The rest of a record, unresolved: a path-less entry the player resolves * on arrival. */ function lazyEntries(rest, artist, o, album) { return (rest || []).map((ct) => ({ t: { id: "cat:" + ct.id, title: ct.title, artist: ct.artist || artist, path: null, duration_secs: ct.duration_secs ?? null, stream: null, catalog: { trackId: ct.id ?? null, albumId: o.albumId ?? null, expectSecs: ct.duration_secs ?? null }, alts: [], }, album, })); } const alt = (c) => ({ username: c.username, path: c.file.path, size: c.file.size, duration: c.file.duration ?? null }); function remember(e, quality) { rememberStreamSource({ stream: e.t.stream, path: e.t.path, title: e.t.title, artist: e.t.artist, trackId: e.t.catalog?.trackId ?? null, duration: e.t.duration_secs, quality }); } /* the bar says what is being looked for while the ladder runs */ function placeholder(title, artist, album) { player.track = { id: "resolving", title, artist, path: null, stream: null }; player.album = album; player.position = 0; player.duration = null; player.status = "buffering"; player.phase = "loading"; player.error = ""; } function fail(msg) { resolver.error = msg; player.error = msg; if (player.track?.id === "resolving") { player.track = null; player.status = "idle"; player.phase = "ready"; } } /* ── a song ────────────────────────────────────────────────────────── */ /** Play a catalog track: `o` carries what the row knows — trackId, albumId, * album (title), cover, expectSecs, year, and `rest` (the record's later * tracks, queued behind it). */ export async function streamSong(artist, title, o = {}) { if (resolver.active) return; const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title); if (local) { await playAlbum(local.album, local.tracks, local.index); return; } const album = albumMeta(o, artist); const from = album.title || artist; const cached = await cachedStreamSourceFor(o.trackId ?? null, artist, title); if (cached) { await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from); return; } if (!online()) { fail(OFFLINE); return; } placeholder(title, artist, album); const { responses, artistIncluded } = await resolveSources( `${artist} — ${title}`, [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], (rs) => songCandidates(rs, title, true, o.album, o.expectSecs, artist).length, artist, resolver, ); const cands = songCandidates(responses, title, true, o.album, o.expectSecs, artist, !artistIncluded); if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; } const [best, ...alts] = cands.slice(0, 4); const e = streamEntry(title, artist, alt(best), o, album); e.t.alts = alts.map(alt); remember(e, qualityOf(best.file)); await playEntries([e, ...lazyEntries(o.rest, artist, o, album)], 0, from); } /* ── a record ──────────────────────────────────────────────────────── */ /** Play a catalog record from track `at`: the library's copy if it holds * one, else the best folder on the network, every track from that one * peer, the runner-up folders as the first track's failover. */ export async function streamCatalogAlbum(album, at = 0) { if (resolver.active) return; const local = libraryAlbumIn(lib.albums, album.artist, album.title, album.release_date ?? null); if (local) { const tracks = await tracksOf(local.id).catch(() => []); if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; } } if (!online()) { fail(OFFLINE); return; } const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist); placeholder(album.title, album.artist, meta); const catalogTracks = album.id ? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) : null; const trackCount = catalogTracks?.length || album.track_count || null; if (trackCount === 1) { const t = catalogTracks?.[0]; player.track = null; await streamSong(album.artist, t?.title ?? album.title, { cover: t?.cover ?? album.cover, trackId: t?.id ?? null, albumId: album.id ?? null, album: album.title || null, expectSecs: t?.duration_secs ?? null, year: meta.year, }); return; } const { responses, artistIncluded } = await resolveSources( `${album.artist} — ${album.title}`, [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], (rs) => albumCandidates(rs, album, true, trackCount).length, album.artist, resolver, ); const folders = albumCandidates(responses, album, true, trackCount, !artistIncluded); if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; } const c = folders[0]; const entries = c.audio.map((f, i) => { const ct = catalogTrackFor(f, i, c.audio, catalogTracks); const o = { trackId: ct?.id ?? null, albumId: album.id ?? null, expectSecs: ct?.duration_secs ?? null }; const e = streamEntry(ct?.title ?? cleanTrackName(f.name), album.artist, { username: c.username, path: f.path, size: f.size, duration: f.duration ?? null }, o, meta); remember(e, qualityOf(f)); return e; }); const start = Math.min(Math.max(0, at), entries.length - 1); entries[start].t.alts = folders.slice(1, 4).map((g) => { const f = g.audio[start] || g.audio[0]; return { username: g.username, path: f.path, size: f.size, duration: f.duration ?? null }; }); await playEntries(entries, start, album.title); } /* ── saving ────────────────────────────────────────────────────────── */ /** Fetch a record into the library: the best folder by the DOWNLOAD rules * (quality first, a queue is fine), every file in it — the pipeline files * art and logs beside the finished album and says soulseek:library-changed. * Returns { username, files } or false. */ export async function saveCatalogAlbum(album) { if (resolver.active) return false; if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } const catalogTracks = album.id ? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) : null; const trackCount = catalogTracks?.length || album.track_count || null; if (trackCount === 1) { const t = catalogTracks?.[0]; return saveSong(album.artist, t?.title ?? album.title); } const { responses, artistIncluded } = await resolveSources( `${album.artist} — ${album.title}`, [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], (rs) => albumCandidates(rs, album, false, trackCount).length, album.artist, resolver, ); const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0]; if (!best) { resolver.error = `No album sources found for “${album.title}”`; player.error = resolver.error; return false; } for (const f of best.files) await api.downloadFile(best.username, f.path, f.size); return { username: best.username, files: best.files.length }; } /** One song into the library, by the download rules. */ export async function saveSong(artist, title) { if (resolver.active) return false; if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } const { responses, artistIncluded } = await resolveSources( `${artist} — ${title}`, [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], (rs) => songCandidates(rs, title, false, null, null, artist).length, artist, resolver, ); const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0]; if (!best) { resolver.error = `No sources found for “${title}”`; player.error = resolver.error; return false; } await api.downloadFile(best.username, best.file.path, best.file.size); return { username: best.username, files: 1 }; } /* ── an entry's turn ───────────────────────────────────────────────── */ /** Fill in a path-less catalog entry when the queue reaches it: the * library, the cache, then a search of its own. Returns whether it can * play. Mutates the entry (a queue proxy) in place. */ async function resolveEntry(e, quiet = false) { const { title, artist, catalog } = e.t; const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title); if (local) { const t = local.tracks[local.index]; e.t.path = t.path; e.t.duration_secs = t.duration_secs ?? e.t.duration_secs; e.t.stream = null; return true; } const cached = await cachedStreamSourceFor(catalog?.trackId ?? null, artist, title); if (cached) { e.t.path = cached.path; e.t.stream = { username: cached.username, size: cached.size }; e.t.duration_secs = cached.duration ?? e.t.duration_secs; return true; } if (!online()) return false; const { responses, artistIncluded } = await resolveSources( `${artist} — ${title}`, [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], (rs) => songCandidates(rs, title, true, e.album?.title, catalog?.expectSecs, artist).length, artist, quiet ? null : resolver, ); const cands = songCandidates(responses, title, true, e.album?.title, catalog?.expectSecs, artist, !artistIncluded); if (!cands.length) return false; const [best, ...alts] = cands.slice(0, 4); const src = alt(best); e.t.path = src.path; e.t.stream = { username: src.username, size: src.size }; e.t.duration_secs = src.duration ?? e.t.duration_secs; e.t.alts = alts.map(alt); remember(e, qualityOf(best.file)); return true; } hooks.resolveEntry = resolveEntry;