Files
SoulStream/src/mobile/resolve.svelte.js
T
KeelrLeek 052e15302b android: what the phone says when things go wrong
Fifty-nine places put a raw Rust string into a field a person reads, and
the red toast drew it verbatim — a phone in a lift showed "could not reach
server: io: failed to lookup address information" under the password box.
One translator, src/mobile/trouble.js, called where an error is DRAWN
rather than where it is caught: the stores keep what Rust said and only
the sentence on screen is ours, so every sink is fixed at once. Five
screens that drew a failed read as an empty answer stop lying — Tracks no
longer reports a broken database as an empty library, a playlist that
could not be read is not an empty playlist, the lyrics face says a lookup
failed instead of "no lyrics" and finally carries the ⋯ that reaches Paste
lyrics, Gaps says why it could not reach the catalogue, and Search signed
out says so where it used to render nothing at all. The room list, which
is fire-and-forget over the wire, gets a twelve-second end and a Try
again.

rescan_library ran prune_missing() first, which deletes every track row
whose file does not exist — so one tap on You → Rescan library with the
folder unreachable emptied the whole index and returned Ok(()). It now
makes the root if it is merely not there yet and refuses if it cannot be
read, and You draws the path with what to do about it.

The mock grew a weather system so the walk can drive all of this: fail,
answer, offline and clear, checked before dispatch so a plugin command
arms like any other. Nine mock steps and one on the phone — the first
steps in thirty rounds that ever made a command reject or saw the client
signed out — and the last of them puts the weather away and leaves the app
as it found it.
2026-09-14 01:23:26 -05:00

288 lines
14 KiB
JavaScript

/* 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";
import { downloadsAllowed } from "./network.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 = "";
player.retry = null;
}
function fail(msg, retry = null) {
resolver.error = msg;
player.error = msg;
/* a Try again on the toast, tied to THIS message: the ladder is worth
another run when the network simply had nothing this time. Signed out it
is not, and the toast offers Community there instead. */
player.retry = retry ? { for: msg, fn: () => { void retry().catch((e) => { player.error = String(e); }); } } : null;
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}”`, () => streamSong(artist, title, o)); 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}”`, () => streamCatalogAlbum(album, at)); 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 (!downloadsAllowed()) return false;
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 (!downloadsAllowed()) 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;