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.
288 lines
14 KiB
JavaScript
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;
|