7794 lines
331 KiB
JavaScript
7794 lines
331 KiB
JavaScript
// Shared reactive app state (Svelte 5 runes module).
|
||
import * as api from "./api.js";
|
||
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||
import {
|
||
nav,
|
||
recordVisit,
|
||
refreshVisit,
|
||
captureScroll,
|
||
restoreScroll,
|
||
goBack,
|
||
goForward,
|
||
goToCrumb,
|
||
} from "./navigation.svelte.js";
|
||
import {
|
||
initPreviewBridge,
|
||
bumpMainRevision,
|
||
notePlaybackGeneration,
|
||
} from "./preview/previewSession.svelte.js";
|
||
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
|
||
|
||
export const app = $state({
|
||
connection: "offline", // offline | connecting | online
|
||
username: "",
|
||
loginError: "",
|
||
showLogin: false,
|
||
});
|
||
|
||
/** Now Playing panel width: the default the magnet snaps back to, and the
|
||
* bounds the drag handle clamps between. Exported so the panel and the grid
|
||
* can never disagree about them. */
|
||
export const NP_WIDTH_DEFAULT = 340;
|
||
export const NP_WIDTH_MIN = 272;
|
||
export const NP_WIDTH_MAX = 560;
|
||
const NP_WIDTH_KEY = "soulstream.nowPlayingWidth";
|
||
|
||
function readPanelWidth() {
|
||
try {
|
||
const raw = Number(localStorage.getItem(NP_WIDTH_KEY));
|
||
if (!Number.isFinite(raw) || raw <= 0) return NP_WIDTH_DEFAULT;
|
||
return Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, raw));
|
||
} catch {
|
||
return NP_WIDTH_DEFAULT;
|
||
}
|
||
}
|
||
|
||
/** Every panel-shape flag, remembered across a reload AND a restart. The
|
||
* reasoning that already persists `nowPlayingWidth` applies to all of them: a
|
||
* shape you set by hand and lost on every launch is worse than not being able
|
||
* to set it. One key holding all three, so the three toggles cannot end up
|
||
* half-migrated against each other. */
|
||
const PANEL_KEY = "soulstream.panels.v1";
|
||
const PANEL_DEFAULTS = { nowPlaying: true, sidebarCollapsed: false, playerExpanded: false };
|
||
|
||
function readPanels() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(PANEL_KEY) || "null");
|
||
if (!raw || typeof raw !== "object") return { ...PANEL_DEFAULTS };
|
||
const out = { ...PANEL_DEFAULTS };
|
||
// Per-key, so a stored blob written by an older build (or a hand-edited
|
||
// one) contributes the flags it does have and defaults the rest.
|
||
for (const k of Object.keys(PANEL_DEFAULTS)) {
|
||
if (typeof raw[k] === "boolean") out[k] = raw[k];
|
||
}
|
||
return out;
|
||
} catch {
|
||
return { ...PANEL_DEFAULTS };
|
||
}
|
||
}
|
||
const panels0 = readPanels();
|
||
|
||
function writePanels() {
|
||
try {
|
||
localStorage.setItem(
|
||
PANEL_KEY,
|
||
JSON.stringify({
|
||
nowPlaying: ui.nowPlaying,
|
||
sidebarCollapsed: ui.sidebarCollapsed,
|
||
playerExpanded: ui.playerExpanded,
|
||
}),
|
||
);
|
||
} catch {
|
||
// A blocked storage quota must not break the toggle for the session.
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------- followed artists
|
||
* Persisted in the library db (followed_artists — id, name, portrait,
|
||
* followed_at) so the future home redesign can build "new releases by artists
|
||
* you follow" from this list alone. The UI truth is this store; the artist
|
||
* page's chip reads it and never keeps its own copy.
|
||
*/
|
||
export const follows = $state({
|
||
loaded: false,
|
||
/** artistId -> true. A plain object, not a Set: $state proxies object
|
||
* property changes, which is exactly the granularity the chip needs. */
|
||
ids: {},
|
||
});
|
||
|
||
export async function loadFollows() {
|
||
if (follows.loaded) return;
|
||
try {
|
||
const list = await api.followedArtists();
|
||
follows.ids = Object.fromEntries(list.map((f) => [f.artist_id, true]));
|
||
follows.loaded = true;
|
||
} catch {
|
||
// Backend unavailable — the chip renders unfollowed and stays usable;
|
||
// the next call retries because `loaded` never flipped.
|
||
}
|
||
}
|
||
|
||
export const isFollowed = (artistId) => artistId != null && !!follows.ids[artistId];
|
||
|
||
/** Optimistic: the chip flips immediately, the db write follows, and a write
|
||
* failure flips it back rather than leaving the UI lying. */
|
||
export async function setFollowed(artist, on) {
|
||
const id = artist?.id;
|
||
if (id == null) return;
|
||
const prev = !!follows.ids[id];
|
||
if (on) follows.ids[id] = true;
|
||
else delete follows.ids[id];
|
||
try {
|
||
if (on) await api.followArtist(id, artist.name ?? "", artist.picture ?? null);
|
||
else await api.unfollowArtist(id);
|
||
} catch {
|
||
if (prev) follows.ids[id] = true;
|
||
else delete follows.ids[id];
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------- home page
|
||
* Raw material for the landing page, one slot per followed artist. The
|
||
* page derives everything visible (the deduped feed, the windows, the news
|
||
* filter) exactly as the artist page derives its tabs — the store holds
|
||
* payloads, not conclusions.
|
||
*
|
||
* Assembly is cheap by construction: home_warm walks the follows through
|
||
* the same metadata entry points in the background (48s cold, measured),
|
||
* and these reads then land in warmed caches. Progressive on purpose — a
|
||
* slot appears as its artist resolves rather than after the last one.
|
||
*/
|
||
export const homeStore = $state({
|
||
started: false,
|
||
follows: [],
|
||
/** artistId -> { id, name, picture, albums, mb, related, error } */
|
||
slots: {},
|
||
order: [],
|
||
recents: [],
|
||
/** [{ artist, title, url, source, date(unix secs) }] newest first */
|
||
news: [],
|
||
newsLoaded: false,
|
||
});
|
||
|
||
async function loadHomeArtist(f) {
|
||
const slot = { id: f.artist_id, name: f.name, picture: f.portrait ?? null, albums: [], mb: [], related: [] };
|
||
try {
|
||
const page = await api.catalogArtist(f.artist_id);
|
||
slot.name = page?.artist?.name ?? f.name;
|
||
slot.picture = page?.artist?.picture ?? slot.picture;
|
||
slot.albums = (page?.albums ?? []).filter((a) => !a.appears_on);
|
||
} catch {
|
||
slot.error = true;
|
||
}
|
||
try {
|
||
const identity = await api.catalogArtistIdentity(f.artist_id, slot.name);
|
||
slot.mb = identity?.albums ?? [];
|
||
} catch {}
|
||
try {
|
||
slot.related = ((await api.catalogRelated(f.artist_id)) ?? []).slice(0, 6);
|
||
} catch {}
|
||
homeStore.slots[f.artist_id] = slot;
|
||
}
|
||
|
||
/** Bring the landing page's material in. Safe to call repeatedly — the
|
||
* backend caches make a re-walk cost milliseconds once warm. */
|
||
export async function loadHome(force = false) {
|
||
if (homeStore.started && !force) return;
|
||
homeStore.started = true;
|
||
// The warm task first: it fills the backend caches this walk reads, and
|
||
// emits soulseek:home-warmed per artist so late slots refresh themselves.
|
||
void api.homeWarm().catch(() => {});
|
||
try {
|
||
const follows = await api.followedArtists();
|
||
homeStore.follows = follows;
|
||
homeStore.order = follows.map((f) => f.artist_id);
|
||
} catch {
|
||
return;
|
||
}
|
||
try {
|
||
homeStore.recents = (await api.recentAlbums(14, null)) ?? [];
|
||
} catch {}
|
||
for (const f of homeStore.follows) {
|
||
if (force || !homeStore.slots[f.artist_id]) await loadHomeArtist(f);
|
||
}
|
||
if (!homeStore.newsLoaded || force) {
|
||
try {
|
||
const byArtist = await api.homeArtistNews(homeStore.follows.map((f) => f.name));
|
||
homeStore.news = Object.entries(byArtist)
|
||
.flatMap(([artist, items]) => (items ?? []).map((n) => ({ ...n, artist })))
|
||
.sort((a, b) => b.date - a.date);
|
||
homeStore.newsLoaded = true;
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
/** The bottom toast (approved T2: portrait pill). `seq` is the trigger — every
|
||
* announcement bumps it, so following twice in a row still re-shows. */
|
||
export const followToast = $state({ seq: 0, on: false, name: "", portrait: null });
|
||
|
||
export function announceFollow(on, artist) {
|
||
followToast.seq += 1;
|
||
followToast.on = on;
|
||
followToast.name = artist?.name ?? "";
|
||
followToast.portrait = artist?.picture ?? null;
|
||
}
|
||
|
||
/** Someone on the network is taking a file from us (UploadToast). A burst
|
||
* from one user — an album grab — counts up inside one pill instead of
|
||
* stacking twelve: within the window, same user increments `count` and the
|
||
* toast extends its hold rather than replaying its entrance. */
|
||
export const uploadToast = $state({ seq: 0, username: "", name: "", count: 0 });
|
||
const UPLOAD_TOAST_WINDOW_MS = 8000;
|
||
let uploadToastAt = 0;
|
||
|
||
export function announceUpload(username, name) {
|
||
const now = Date.now();
|
||
if (uploadToast.username === username && now - uploadToastAt < UPLOAD_TOAST_WINDOW_MS) {
|
||
uploadToast.count += 1;
|
||
} else {
|
||
uploadToast.username = username;
|
||
uploadToast.count = 1;
|
||
}
|
||
uploadToast.name = name;
|
||
uploadToastAt = now;
|
||
uploadToast.seq += 1;
|
||
}
|
||
|
||
/** Background-import status, read by ImportToast. The import window keeps
|
||
* scanning/copying after it is closed (the component stays mounted, only
|
||
* its template goes), and closed must never mean invisible: while work
|
||
* runs, the toast narrates it and clicks back into the window. Terminal
|
||
* phases (scanned/applied/error) are only entered when the window is
|
||
* CLOSED at that moment — an open window narrates its own completions —
|
||
* and they stick until the window is reopened, which acknowledges them. */
|
||
export const importStatus = $state({
|
||
phase: "idle", // idle | scanning | scanned | applying | applied | error
|
||
done: 0, // scanning/applying: progress; scanned/applied: final count
|
||
total: 0,
|
||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||
skipped: 0, // applied: records nothing was written for — the toast must
|
||
// say so, because "Imported 0 records" on its own reads as a dead end
|
||
error: "", // error: what went wrong, in words. "The import hit a problem"
|
||
// is not a reason, and a silent failure is not even that.
|
||
});
|
||
|
||
/** A paused import's presence on the Library page: the Import button wears
|
||
* a percentage badge while a saved review session exists. Fed by
|
||
* ImportWindow (boot restore, every persist, cleared on completion). */
|
||
export const importResume = $state({ active: false, pct: 0 });
|
||
|
||
export const ui = $state({
|
||
view: "home",
|
||
albumId: null, // selected album in Library view
|
||
playlistId: null, // selected playlist in Playlist view
|
||
// The Now Playing panel is EXPANDED vs COLLAPSED, never gone: collapsing it
|
||
// leaves the rail (see NowPlaying.svelte), the same way the left nav
|
||
// collapses to an icon rail rather than disappearing.
|
||
nowPlaying: panels0.nowPlaying,
|
||
sidebarCollapsed: panels0.sidebarCollapsed, // left nav collapsed to an icon rail
|
||
// The player bar grows UPWARD into the content — the transport centres and
|
||
// the lane drops to its own row beneath it. See PlayerBar.svelte.
|
||
playerExpanded: panels0.playerExpanded,
|
||
// User-dragged width of the expanded panel. Persisted, unlike the other ui
|
||
// flags: a width you set by hand and lost on every launch would be worse
|
||
// than not being able to set it at all.
|
||
nowPlayingWidth: readPanelWidth(),
|
||
// True only while the grip is held. The panel's width and the grid column
|
||
// it sits in share one clock (see App.svelte), so BOTH have to leave the
|
||
// 0.28s ease for a drag — the panel alone would outgrow its own column.
|
||
nowPlayingDragging: false,
|
||
// Full-window artwork lightbox. `null` when closed; otherwise a descriptor
|
||
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||
// surface can open it, not only the playing track.
|
||
artwork: null,
|
||
// The import tool's window. `false` when closed; `true` opens it (resuming
|
||
// any saved session); `{ root }` opens it with a source folder pre-picked.
|
||
// Lives on ui rather than the Library page because importing is not a
|
||
// first-run event — another drive can arrive any day.
|
||
importOpen: false,
|
||
// WebView2 keeps document.visibilityState="visible" while the native
|
||
// window is minimized. This is the authoritative shared activity gate for
|
||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||
windowActive: true,
|
||
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
|
||
lyricsFull: false,
|
||
// Bumped on every jump to a hub id (search/downloads) so the Community
|
||
// page switches its pill even when ui.view did not change.
|
||
hubJump: 0,
|
||
});
|
||
|
||
export function toggleNowPlaying() {
|
||
ui.nowPlaying = !ui.nowPlaying;
|
||
writePanels();
|
||
}
|
||
|
||
/** Reveal the panel (the queue lives in it). Goes through here rather than
|
||
* assigning `ui.nowPlaying` directly so the shape is still remembered. */
|
||
export function showNowPlaying() {
|
||
if (ui.nowPlaying) return;
|
||
ui.nowPlaying = true;
|
||
writePanels();
|
||
}
|
||
|
||
/** Grow the player bar upward, or put it back. */
|
||
export function togglePlayerExpanded() {
|
||
ui.playerExpanded = !ui.playerExpanded;
|
||
writePanels();
|
||
}
|
||
|
||
/** Set the expanded panel's width, clamped and remembered. */
|
||
export function setNowPlayingWidth(px) {
|
||
const w = Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, Math.round(px)));
|
||
ui.nowPlayingWidth = w;
|
||
try {
|
||
localStorage.setItem(NP_WIDTH_KEY, String(w));
|
||
} catch {
|
||
// A blocked storage quota must not break resizing for the session.
|
||
}
|
||
}
|
||
|
||
export function toggleSidebar() {
|
||
ui.sidebarCollapsed = !ui.sidebarCollapsed;
|
||
writePanels();
|
||
}
|
||
|
||
export const libraryStore = $state({
|
||
albums: [],
|
||
loaded: false,
|
||
});
|
||
|
||
export const playlistStore = $state({
|
||
playlists: [],
|
||
loaded: false,
|
||
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
|
||
// mutated playlist (new rows → new mosaic) refetches while an untouched
|
||
// one costs nothing on re-render.
|
||
covers: {},
|
||
});
|
||
|
||
// revision the cached cover was composed against, and in-flight dedupe.
|
||
const playlistCoverRev = new Map();
|
||
const playlistCoverInflight = new Map();
|
||
|
||
export async function refreshPlaylists() {
|
||
try {
|
||
playlistStore.playlists = await api.listPlaylists();
|
||
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||
} catch {
|
||
// backend not available
|
||
} finally {
|
||
playlistStore.loaded = true;
|
||
}
|
||
}
|
||
|
||
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
|
||
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
|
||
/// gradient fallback".
|
||
export async function playlistCoverFor(pl, maxEdge = 512) {
|
||
const id = pl.id;
|
||
const cached = playlistStore.covers[id];
|
||
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
|
||
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
|
||
const req = api
|
||
.playlistCover(id, maxEdge)
|
||
.then((url) => {
|
||
playlistStore.covers[id] = url ?? null;
|
||
playlistCoverRev.set(id, pl.revision);
|
||
return url ?? null;
|
||
})
|
||
.catch(() => null)
|
||
.finally(() => playlistCoverInflight.delete(id));
|
||
playlistCoverInflight.set(id, req);
|
||
return req;
|
||
}
|
||
|
||
// ---- recently played (the sidebar's list) ----
|
||
// A play row is opened after ~1s of audio, so without a floor this list is a
|
||
// log of skips. 45s is the floor the live table was measured against.
|
||
export const RECENTS_FLOOR_SECS = 45;
|
||
const RECENTS_LIMIT = 20;
|
||
|
||
export const recentsStore = $state({
|
||
albums: [], // [{ album_id, artist, title, art_path, last_played_at }]
|
||
loaded: false,
|
||
});
|
||
|
||
export async function refreshRecents() {
|
||
try {
|
||
recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS);
|
||
} catch {
|
||
// backend not available
|
||
} finally {
|
||
recentsStore.loaded = true;
|
||
}
|
||
}
|
||
|
||
const MIB = 1024 * 1024;
|
||
|
||
function isPending(value) {
|
||
return value && typeof value.then === "function";
|
||
}
|
||
|
||
function touchCache(cache, key) {
|
||
if (!cache.has(key)) return undefined;
|
||
const value = cache.get(key);
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
return value;
|
||
}
|
||
|
||
function cacheValueChars(value) {
|
||
if (typeof value === "string") return value.length;
|
||
if (typeof value?.data_url === "string") return value.data_url.length;
|
||
if (Number.isFinite(value?.byteLength)) return value.byteLength;
|
||
return 0;
|
||
}
|
||
|
||
/** Keep resolved base64 payloads on a byte-like character budget while never
|
||
* evicting in-flight promises. Visible <img> elements retain their own src,
|
||
* so dropping a cold cache reference cannot degrade an already-painted image. */
|
||
function trimResolvedCache(cache, maxEntries, maxChars) {
|
||
let entries = 0;
|
||
let chars = 0;
|
||
for (const value of cache.values()) {
|
||
if (isPending(value)) continue;
|
||
entries += 1;
|
||
chars += cacheValueChars(value);
|
||
}
|
||
if (entries <= maxEntries && chars <= maxChars) return;
|
||
for (const [key, value] of cache) {
|
||
if (isPending(value)) continue;
|
||
cache.delete(key);
|
||
entries -= 1;
|
||
chars -= cacheValueChars(value);
|
||
if (entries <= maxEntries && chars <= maxChars) break;
|
||
}
|
||
}
|
||
|
||
function setResolvedCache(cache, key, value, maxEntries, maxChars) {
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
trimResolvedCache(cache, maxEntries, maxChars);
|
||
}
|
||
|
||
function setBoundedMap(cache, key, value, maxEntries) {
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
|
||
}
|
||
|
||
// path -> data URL, bounded so browsing a large library cannot retain every
|
||
// base64 cover for the entire WebView lifetime.
|
||
const artCache = new Map();
|
||
const ART_CACHE_MAX = 128;
|
||
const ART_CACHE_MAX_CHARS = 32 * MIB;
|
||
|
||
export function evictArt(path) {
|
||
artCache.delete(path);
|
||
// The thumbnails are keyed by edge as well, so a replaced cover has to drop
|
||
// every size it was cached at — otherwise refreshing art repaints the tiles
|
||
// from the old picture while the viewer shows the new one.
|
||
for (const key of [...artThumbCache.keys()]) {
|
||
if (key.endsWith(`|${path}`)) artThumbCache.delete(key);
|
||
}
|
||
}
|
||
|
||
/** The art FILE, at whatever resolution it was stored — masters here run past
|
||
* 4000px. Only for surfaces that must show true pixels: the fullscreen viewer
|
||
* and the import picker's A/B comparison and loupe.
|
||
*
|
||
* Everything else wants `getArt`. Painting a master into a tile makes the
|
||
* webview reduce it in one step, which is what made photographic covers look
|
||
* aliased, and it carries megabytes of base64 to draw a 132px square. */
|
||
export async function getArtMaster(path) {
|
||
if (!path) return null;
|
||
if (artCache.has(path)) return touchCache(artCache, path);
|
||
// PlayerBar, NowPlaying, and accent extraction can all request the same
|
||
// cover during one track change. Cache the in-flight promise so one native
|
||
// read/base64 conversion satisfies every consumer.
|
||
const pending = api.albumArt(path).then(
|
||
(url) => {
|
||
if (artCache.get(path) === pending) {
|
||
setResolvedCache(
|
||
artCache,
|
||
path,
|
||
url,
|
||
ART_CACHE_MAX,
|
||
ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return url;
|
||
},
|
||
() => {
|
||
if (artCache.get(path) === pending) {
|
||
setResolvedCache(
|
||
artCache,
|
||
path,
|
||
null,
|
||
ART_CACHE_MAX,
|
||
ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
artCache.set(path, pending);
|
||
return pending;
|
||
}
|
||
|
||
// `${edge}|${path}` -> data URL. Tile-sized art, resampled by Rust instead of
|
||
// by the compositor: a 2000px master squeezed into a 132px card is a 15x
|
||
// single-step reduction, which aliases badly on photographic covers. Held
|
||
// separately from `artCache` because the two answer different questions —
|
||
// this one is what a card should paint, that one is the actual file, and the
|
||
// viewer and the import picker must keep getting the actual file.
|
||
//
|
||
// The entry budget is higher and the byte budget lower than the full-size
|
||
// cache on purpose: thumbnails are ~50KB where masters run to megabytes, so
|
||
// far more of them fit in far less memory.
|
||
const artThumbCache = new Map();
|
||
const ART_THUMB_CACHE_MAX = 512;
|
||
const ART_THUMB_CACHE_MAX_CHARS = 16 * MIB;
|
||
|
||
/** How big a cover comes back when nobody says otherwise. Covers the largest
|
||
* thing that paints one short of the viewer — the library expander's ~300px
|
||
* rail — with room for a 2x display, and never upscales anything smaller. */
|
||
export const ART_EDGE_DEFAULT = 512;
|
||
|
||
/** Album art as a data URL, bounded to `edge` px on the long side.
|
||
*
|
||
* This is the DEFAULT path on purpose. Asking for the master has to be the
|
||
* deliberate act (`getArtMaster`), because there are ~60 places in this app
|
||
* that paint a cover and only two of them want real pixels — with the polarity
|
||
* the other way round, every surface anyone forgets is silently wrong.
|
||
*
|
||
* Aspect ratio is preserved, so a non-square cover arrives non-square and the
|
||
* tile's own `object-fit` still decides the crop. Sources already within
|
||
* `edge` come back untouched rather than re-encoded. Pass `null` to opt out
|
||
* and get the master. */
|
||
// ---- art fetch gate ---------------------------------------------------
|
||
// A page mounting a wall of tiles fires its art requests as fast as the
|
||
// observer sees them; each answer is a base64 payload crossing IPC onto
|
||
// the main thread. Six in flight keeps the pipe busy without the burst —
|
||
// the backend bounds the DECODES separately (2 at a time); this bounds
|
||
// the traffic. Masters (the player-bar hero) bypass the gate: rare, and
|
||
// they should never wait behind a hundred tiles.
|
||
const ART_FETCH_LIMIT = 6;
|
||
let artFetchActive = 0;
|
||
const artFetchQueue = [];
|
||
function gatedArtFetch(fn) {
|
||
return new Promise((resolve, reject) => {
|
||
const run = () => {
|
||
artFetchActive += 1;
|
||
fn()
|
||
.then(resolve, reject)
|
||
.finally(() => {
|
||
artFetchActive -= 1;
|
||
// Newest first: on a fast scroll the queue's tail is the
|
||
// tiles on screen RIGHT NOW and its head is the screens
|
||
// scrolled past. FIFO served the wake behind the viewer;
|
||
// the passed-by tiles still drain once the burst settles.
|
||
const next = artFetchQueue.pop();
|
||
if (next) next();
|
||
});
|
||
};
|
||
if (artFetchActive < ART_FETCH_LIMIT) run();
|
||
else artFetchQueue.push(run);
|
||
});
|
||
}
|
||
|
||
export async function getArt(path, edge = ART_EDGE_DEFAULT) {
|
||
if (!path) return null;
|
||
if (edge == null) return getArtMaster(path);
|
||
const key = `${edge}|${path}`;
|
||
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
|
||
const pending = gatedArtFetch(() => api.albumArt(path, edge)).then(
|
||
(url) => {
|
||
if (artThumbCache.get(key) === pending) {
|
||
setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
||
}
|
||
return url;
|
||
},
|
||
() => {
|
||
if (artThumbCache.get(key) === pending) {
|
||
setResolvedCache(artThumbCache, key, null, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
artThumbCache.set(key, pending);
|
||
return pending;
|
||
}
|
||
|
||
// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs).
|
||
const catalogArtCache = new Map();
|
||
const CATALOG_ART_CACHE_MAX = 160;
|
||
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB;
|
||
|
||
/** The proxied data URL for `url` if it is ALREADY resolved, synchronously.
|
||
*
|
||
* Exists so a second consumer can paint art the first one has already fetched
|
||
* without a round trip through a promise — one microtask is still a frame with
|
||
* the raw CDN url in `src`, which is a network request for a file we already
|
||
* hold. Returns null while a fetch is in flight (the cache holds the promise
|
||
* then) and null for anything unproxied. */
|
||
export function peekCatalogArt(url) {
|
||
if (!url) return null;
|
||
if (url.startsWith("data:")) return url;
|
||
const hit = catalogArtCache.get(url);
|
||
return typeof hit === "string" ? hit : null;
|
||
}
|
||
|
||
export async function getCatalogArt(url) {
|
||
if (!url) return null;
|
||
// Already-inline art (e.g. a cover-derived logo) has nothing to proxy.
|
||
if (url.startsWith("data:")) return url;
|
||
if (catalogArtCache.has(url)) return touchCache(catalogArtCache, url);
|
||
// Cache the promise immediately, not only its result. Multiple components
|
||
// often request the same art during one render; one IPC/base64 conversion
|
||
// should satisfy every waiter.
|
||
const pending = api.catalogArt(url).then(
|
||
(dataUrl) => {
|
||
if (catalogArtCache.get(url) === pending) {
|
||
setResolvedCache(
|
||
catalogArtCache,
|
||
url,
|
||
dataUrl,
|
||
CATALOG_ART_CACHE_MAX,
|
||
CATALOG_ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return dataUrl;
|
||
},
|
||
() => {
|
||
// CDN/provider failures are transient; allow the next visit to retry.
|
||
if (catalogArtCache.get(url) === pending) catalogArtCache.delete(url);
|
||
return null;
|
||
},
|
||
);
|
||
catalogArtCache.set(url, pending);
|
||
return pending;
|
||
}
|
||
|
||
// Remote catalog cover URLs that failed to load — a dead CDN link, a Cover Art
|
||
// Archive 404 (MusicBrainz-supplemented cards are given a `front-500` URL
|
||
// whether or not CAA actually holds art for that release group), or bytes that
|
||
// won't decode. Cards read `artBroken[url]` and fall back to their disc
|
||
// placeholder, so a dead provider URL renders the placeholder instead of the
|
||
// browser's broken-image glyph. Session-scoped and keyed by URL (a dead URL
|
||
// stays dead); bounded so a marathon browse can't retain unbounded strings.
|
||
export const artBroken = $state({});
|
||
|
||
export function markArtBroken(url) {
|
||
if (!url || artBroken[url]) return;
|
||
const keys = Object.keys(artBroken);
|
||
if (keys.length > 2000) for (const k of keys) delete artBroken[k];
|
||
artBroken[url] = true;
|
||
// Remote art hosts flake transiently (Cover Art Archive redirects into
|
||
// archive.org, which routinely times out under load) — one failed load
|
||
// must not pin the placeholder for the whole session. Re-probe off-DOM
|
||
// and un-mark on success; cards restore reactively.
|
||
if (/^https?:/i.test(url) && !artRetriesStarted.has(url)) {
|
||
artRetriesStarted.add(url);
|
||
retryBrokenArt(url, 0);
|
||
}
|
||
}
|
||
|
||
// Cover Art Archive redirects into archive.org, which can be down for minutes
|
||
// at a time, so the old two-shot 4s/20s ladder simply exhausted itself inside
|
||
// half a minute and pinned the placeholder for the rest of the session. Retry
|
||
// further out, and — crucially — RELEASE the url from `artRetriesStarted` when
|
||
// the ladder ends, so revisiting the artist later starts a fresh cycle instead
|
||
// of being permanently locked out.
|
||
const ART_RETRY_DELAYS = [3_000, 10_000, 30_000, 90_000];
|
||
const artRetriesStarted = new Set();
|
||
|
||
function retryBrokenArt(url, attempt) {
|
||
if (attempt >= ART_RETRY_DELAYS.length) {
|
||
artRetriesStarted.delete(url);
|
||
return;
|
||
}
|
||
setTimeout(() => {
|
||
if (!artBroken[url]) {
|
||
artRetriesStarted.delete(url);
|
||
return;
|
||
}
|
||
const probe = new Image();
|
||
probe.onload = () => {
|
||
delete artBroken[url];
|
||
artRetriesStarted.delete(url);
|
||
};
|
||
probe.onerror = () => retryBrokenArt(url, attempt + 1);
|
||
probe.src = url;
|
||
}, ART_RETRY_DELAYS[attempt]);
|
||
}
|
||
|
||
// Catalog cover upgrades: original cover URL -> a verified higher-resolution
|
||
// URL from the no-MusicBrainz backend resolver (Apple / Deezer native max /,
|
||
// for MusicBrainz-supplemented cards only, Cover Art Archive). The grid paints
|
||
// the instant Deezer cover, then swaps to the upgrade when it resolves. Keyed
|
||
// by URL so a shared cover resolves once; bounded for a long browse.
|
||
export const coverUpgrades = $state({});
|
||
const coverUpgradeInflight = new Map(); // identity + original url -> Promise
|
||
const coverUpgradeResults = new Map(); // bounded LRU: request key -> result context
|
||
const coverUpgradeContexts = new Map(); // original url -> disk-cache producer
|
||
const coverUpgradeMisses = new Map(); // original url -> attempted-at
|
||
const coverUpgradeQueue = [];
|
||
let coverUpgradeActive = 0;
|
||
let coverUpgradeScope = 0;
|
||
let coverUpgradeArtistId = null;
|
||
const COVER_UPGRADE_CONCURRENCY = 4;
|
||
const COVER_UPGRADE_MAX = 400;
|
||
const COVER_UPGRADE_MISS_TTL_MS = 2 * 60 * 1000;
|
||
|
||
function advanceCoverUpgradeScope(artistId) {
|
||
coverUpgradeArtistId = artistCacheKey(artistId) || null;
|
||
coverUpgradeScope += 1;
|
||
// Queued work has not crossed IPC yet, so it is safe to cancel when its
|
||
// artist is no longer visible. At most four already-running jobs finish;
|
||
// the new artist never sits behind an arbitrarily long stale backlog.
|
||
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
|
||
const job = coverUpgradeQueue[index];
|
||
if (job.sticky || job.scope === coverUpgradeScope) continue;
|
||
coverUpgradeQueue.splice(index, 1);
|
||
if (coverUpgradeInflight.get(job.key) === job.promise) {
|
||
coverUpgradeInflight.delete(job.key);
|
||
}
|
||
job.resolve(null);
|
||
}
|
||
}
|
||
|
||
function activateCoverUpgradeScope(artistId) {
|
||
if (sameArtistId(coverUpgradeArtistId, artistId)) return;
|
||
advanceCoverUpgradeScope(artistId);
|
||
}
|
||
|
||
/** Cancel artist-grid jobs that have not crossed IPC when that view leaves.
|
||
* Sticky currently-playing art and the four already-running calls are kept. */
|
||
export function leaveCoverUpgradeScope(artistId) {
|
||
if (!sameArtistId(coverUpgradeArtistId, artistId)) return;
|
||
advanceCoverUpgradeScope(null);
|
||
}
|
||
|
||
function coverUpgradeRequestKey(artistId, url) {
|
||
return `${artistCacheKey(artistId)}\u0000${url}`;
|
||
}
|
||
|
||
function rememberCoverUpgrade(key, url, best, artistId, artist, title) {
|
||
const result = { key, url, best, artistId, artist, title };
|
||
coverUpgradeResults.delete(key);
|
||
coverUpgradeResults.set(key, result);
|
||
coverUpgradeContexts.set(url, result);
|
||
coverUpgrades[url] = best;
|
||
while (coverUpgradeResults.size > COVER_UPGRADE_MAX) {
|
||
const oldest = coverUpgradeResults.keys().next().value;
|
||
const removed = coverUpgradeResults.get(oldest);
|
||
coverUpgradeResults.delete(oldest);
|
||
if (coverUpgradeContexts.get(removed?.url)?.key === oldest) {
|
||
coverUpgradeContexts.delete(removed.url);
|
||
if (coverUpgrades[removed.url] === removed.best) delete coverUpgrades[removed.url];
|
||
}
|
||
}
|
||
}
|
||
|
||
function rememberCoverUpgradeMiss(key) {
|
||
coverUpgradeMisses.delete(key);
|
||
coverUpgradeMisses.set(key, Date.now());
|
||
while (coverUpgradeMisses.size > COVER_UPGRADE_MAX) {
|
||
coverUpgradeMisses.delete(coverUpgradeMisses.keys().next().value);
|
||
}
|
||
}
|
||
|
||
function pumpCoverUpgradeQueue() {
|
||
while (coverUpgradeActive < COVER_UPGRADE_CONCURRENCY && coverUpgradeQueue.length) {
|
||
// Current-page viewport work wins over retained player/album jobs.
|
||
const currentIndex = coverUpgradeQueue.findIndex(
|
||
(candidate) => !candidate.sticky && candidate.scope === coverUpgradeScope,
|
||
);
|
||
const job = currentIndex > 0
|
||
? coverUpgradeQueue.splice(currentIndex, 1)[0]
|
||
: coverUpgradeQueue.shift();
|
||
coverUpgradeActive += 1;
|
||
api.catalogCover(job.artistId, job.artist, job.title, job.url)
|
||
.then((best) => {
|
||
if (best && best !== job.url) {
|
||
coverUpgradeMisses.delete(job.key);
|
||
rememberCoverUpgrade(
|
||
job.key,
|
||
job.url,
|
||
best,
|
||
job.artistId,
|
||
job.artist,
|
||
job.title,
|
||
);
|
||
} else {
|
||
rememberCoverUpgradeMiss(job.key);
|
||
}
|
||
job.resolve(best ?? null);
|
||
})
|
||
.catch(() => {
|
||
rememberCoverUpgradeMiss(job.key);
|
||
job.resolve(null);
|
||
})
|
||
.finally(() => {
|
||
if (coverUpgradeInflight.get(job.key) === job.promise) {
|
||
coverUpgradeInflight.delete(job.key);
|
||
}
|
||
coverUpgradeActive -= 1;
|
||
pumpCoverUpgradeQueue();
|
||
});
|
||
}
|
||
}
|
||
|
||
function keepOnlyLatestStickyCoverJob(url) {
|
||
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
|
||
const job = coverUpgradeQueue[index];
|
||
if (!job.sticky || job.url === url) continue;
|
||
coverUpgradeQueue.splice(index, 1);
|
||
if (coverUpgradeInflight.get(job.key) === job.promise) {
|
||
coverUpgradeInflight.delete(job.key);
|
||
}
|
||
job.resolve(null);
|
||
}
|
||
}
|
||
|
||
function scheduleCoverUpgrade(artistId, artist, title, url, sticky = false) {
|
||
const key = coverUpgradeRequestKey(artistId, url);
|
||
// Player changes supersede older queued player-art polish. Running calls
|
||
// finish safely, but rapid track skipping cannot accumulate an immortal
|
||
// sticky backlog across every artist scope.
|
||
if (sticky) keepOnlyLatestStickyCoverJob(url);
|
||
if (coverUpgradeResults.has(key)) {
|
||
const result = touchCache(coverUpgradeResults, key);
|
||
coverUpgradeContexts.set(url, result);
|
||
if (coverUpgrades[url] !== result.best) coverUpgrades[url] = result.best;
|
||
return Promise.resolve(result.best);
|
||
}
|
||
const published = coverUpgradeContexts.get(url);
|
||
if (published?.key === key && coverUpgrades[url]) {
|
||
return Promise.resolve(coverUpgrades[url]);
|
||
}
|
||
const attemptedAt = coverUpgradeMisses.get(key);
|
||
if (attemptedAt && Date.now() - attemptedAt < COVER_UPGRADE_MISS_TTL_MS) {
|
||
return Promise.resolve(null);
|
||
}
|
||
if (coverUpgradeInflight.has(key)) {
|
||
const existing = coverUpgradeInflight.get(key);
|
||
if (sticky) {
|
||
const index = coverUpgradeQueue.findIndex((job) => job.promise === existing);
|
||
if (index >= 0) {
|
||
const [job] = coverUpgradeQueue.splice(index, 1);
|
||
job.sticky = true;
|
||
coverUpgradeQueue.unshift(job);
|
||
}
|
||
}
|
||
return existing;
|
||
}
|
||
|
||
let complete;
|
||
const promise = new Promise((resolve) => { complete = resolve; });
|
||
const job = {
|
||
key,
|
||
artistId: artistId ?? null,
|
||
artist: artist ?? "",
|
||
title: title ?? "",
|
||
url,
|
||
resolve: complete,
|
||
promise,
|
||
sticky,
|
||
scope: coverUpgradeScope,
|
||
};
|
||
// Register before enqueueing: staged overview/full-page calls can no longer
|
||
// create separate worker pools containing the same not-yet-started URL.
|
||
coverUpgradeInflight.set(key, promise);
|
||
if (sticky) coverUpgradeQueue.unshift(job);
|
||
else coverUpgradeQueue.push(job);
|
||
pumpCoverUpgradeQueue();
|
||
return promise;
|
||
}
|
||
|
||
/** Resolve higher-resolution covers for catalog albums, in parallel with a
|
||
* concurrency cap. The backend does NO MusicBrainz lookup, so this fans out
|
||
* freely. Idempotent — already-resolved or in-flight covers are skipped. */
|
||
export async function resolveCovers(
|
||
albums,
|
||
artist,
|
||
{ sticky = false, artistId = null } = {},
|
||
) {
|
||
const requests = [];
|
||
const seen = new Set();
|
||
for (const a of albums ?? []) {
|
||
const url = a?.cover;
|
||
if (!url || seen.has(url)) continue;
|
||
seen.add(url);
|
||
requests.push(scheduleCoverUpgrade(artistId, artist, a.title ?? "", url, sticky));
|
||
}
|
||
await Promise.all(requests);
|
||
}
|
||
|
||
/** A catalog cover img failed to load. If it was an upgraded URL, revert to the
|
||
* instant Deezer cover; if the original itself failed, show the placeholder. */
|
||
export function coverLoadError(
|
||
originalCover,
|
||
failedUrl = null,
|
||
artist = "",
|
||
title = "",
|
||
artistId = null,
|
||
) {
|
||
if (!originalCover) return null;
|
||
if (coverUpgrades[originalCover] && coverUpgrades[originalCover] !== originalCover) {
|
||
const producer = coverUpgradeContexts.get(originalCover);
|
||
delete coverUpgrades[originalCover];
|
||
// Do not let the bounded LRU immediately republish the URL that just
|
||
// failed in an <img>. A short miss window keeps the working original
|
||
// in place while still allowing transient CDN failures to retry later.
|
||
const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover);
|
||
coverUpgradeResults.delete(requestKey);
|
||
if (coverUpgradeContexts.get(originalCover)?.key === requestKey) {
|
||
coverUpgradeContexts.delete(originalCover);
|
||
}
|
||
rememberCoverUpgradeMiss(requestKey);
|
||
const cacheArtistId = producer?.artistId ?? artistId;
|
||
const cacheArtist = producer?.artist ?? artist;
|
||
const cacheTitle = producer?.title ?? title;
|
||
if (cacheArtist && cacheTitle) {
|
||
void api
|
||
.invalidateCatalogCover(
|
||
cacheArtistId,
|
||
cacheArtist,
|
||
cacheTitle,
|
||
originalCover,
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
return originalCover;
|
||
} else {
|
||
// A card intentionally requests a 500px CDN derivative. If only that
|
||
// resized route failed, retry the known-good canonical source before
|
||
// declaring the album itself broken.
|
||
if (failedUrl) {
|
||
try {
|
||
if (new URL(failedUrl).href !== new URL(originalCover).href) {
|
||
return originalCover;
|
||
}
|
||
} catch {
|
||
if (failedUrl !== originalCover) return originalCover;
|
||
}
|
||
}
|
||
markArtBroken(originalCover);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cover URL to display for a currently-playing catalog item — the record's OWN
|
||
* asset, at the largest size that asset comes in.
|
||
*
|
||
* It used to also kick off a cover "upgrade", which resolves a better image by
|
||
* ARTIST + TITLE. That is the identity trap: an artist can have several records
|
||
* under one title — every self-titled Weezer album is "Weezer" — and the
|
||
* resolver hands back whichever one the provider answers with, so all seven
|
||
* played back showing the Green Album's art. The upgrade map is keyed by the
|
||
* original cover URL and was innocent; the value put in it was wrong.
|
||
*
|
||
* A name cannot identify a record, so the substitution is gone. `hqCover`
|
||
* rewrites the same Deezer asset to its 1000x1000 variant, which is a genuine
|
||
* quality win with no identity risk, and is more than the panel needs even
|
||
* dragged to its widest.
|
||
*/
|
||
export function playerArtSource(item) {
|
||
const url = item?.artUrl;
|
||
if (!url) return null;
|
||
return hqCover(url);
|
||
}
|
||
|
||
// Artist heroes have their own proxy because provider fallbacks may be finished
|
||
// as baked 4K JPEGs. Never share this cache with covers/logos.
|
||
const catalogHeroPreviewCache = new Map();
|
||
const catalogHeroArtCache = new Map();
|
||
const catalogHeroGpuCache = new Map();
|
||
const CATALOG_HERO_PREVIEW_CACHE_MAX = 16;
|
||
const CATALOG_HERO_ART_CACHE_MAX = 10;
|
||
// Sized for the ROTATION WORKING SET, not just the current+next pair: at 2
|
||
// entries the 20s carousel over 3+ banners missed on every tick, and the
|
||
// 24MiB entry cap excluded every raw 4K payload (3840×2160×4 ≈ 31.6MiB —
|
||
// the baked-4K provider fallback), so those re-paid the backend decode and
|
||
// a fresh multi-MB raw-RGBA IPC transfer on every single revisit. Worst
|
||
// case here is 64MiB of pinned ArrayBuffers while an artist page rotates —
|
||
// revisit in the RAM pass if that bites on 8GB machines.
|
||
const CATALOG_HERO_GPU_CACHE_MAX = 4;
|
||
const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB;
|
||
const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB;
|
||
const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 64 * MIB;
|
||
const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 32 * MIB;
|
||
let catalogHeroArtEpoch = 0;
|
||
let heroWarmMode = null;
|
||
let heroWarmModeRequest = null;
|
||
let heroWarmModeEpoch = 0;
|
||
|
||
function getHeroWarmMode() {
|
||
if (heroWarmMode) return Promise.resolve(heroWarmMode);
|
||
if (heroWarmModeRequest) return heroWarmModeRequest;
|
||
const epoch = heroWarmModeEpoch;
|
||
const pending = api.getSettings().then(
|
||
(settings) => settings?.artist_hero_upscaler ?? "live_gpu",
|
||
() => "live_gpu",
|
||
).then((mode) => {
|
||
if (epoch === heroWarmModeEpoch) heroWarmMode = mode;
|
||
return mode;
|
||
}).finally(() => {
|
||
if (heroWarmModeRequest === pending) heroWarmModeRequest = null;
|
||
});
|
||
heroWarmModeRequest = pending;
|
||
return pending;
|
||
}
|
||
|
||
function setBoundedHeroCache(cache, key, value, maxEntries, maxChars) {
|
||
// Refresh insertion order so frequently revisited heroes survive eviction.
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
trimResolvedCache(cache, maxEntries, maxChars);
|
||
}
|
||
|
||
export function clearCatalogHeroArtCache() {
|
||
catalogHeroArtEpoch += 1;
|
||
// Settings calls this after changing upscalers. Refresh the warm-path hint
|
||
// so the next artist prefetch targets the newly selected pipeline.
|
||
heroWarmModeEpoch += 1;
|
||
heroWarmMode = null;
|
||
heroWarmModeRequest = null;
|
||
catalogHeroPreviewCache.clear();
|
||
catalogHeroArtCache.clear();
|
||
catalogHeroGpuCache.clear();
|
||
}
|
||
|
||
/** Return the fast hero raster together with the immutable content-aware crop
|
||
* plan that produced it. Cache the whole pair so the deferred 4K finisher can
|
||
* apply the exact same source window without running detection a second time. */
|
||
export async function getCatalogHeroPreview(url) {
|
||
if (!url) return null;
|
||
if (catalogHeroPreviewCache.has(url)) {
|
||
return touchCache(catalogHeroPreviewCache, url);
|
||
}
|
||
const epoch = catalogHeroArtEpoch;
|
||
const pending = api.catalogHeroPreview(url).then(
|
||
(result) => {
|
||
const preview = result?.data_url ? result : null;
|
||
if (
|
||
epoch === catalogHeroArtEpoch
|
||
&& catalogHeroPreviewCache.get(url) === pending
|
||
) {
|
||
if (preview) {
|
||
setBoundedHeroCache(
|
||
catalogHeroPreviewCache,
|
||
url,
|
||
preview,
|
||
CATALOG_HERO_PREVIEW_CACHE_MAX,
|
||
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
else catalogHeroPreviewCache.delete(url);
|
||
}
|
||
return epoch === catalogHeroArtEpoch ? preview : null;
|
||
},
|
||
() => {
|
||
if (catalogHeroPreviewCache.get(url) === pending) {
|
||
catalogHeroPreviewCache.delete(url);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
setBoundedHeroCache(
|
||
catalogHeroPreviewCache,
|
||
url,
|
||
pending,
|
||
CATALOG_HERO_PREVIEW_CACHE_MAX,
|
||
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
|
||
);
|
||
return pending;
|
||
}
|
||
|
||
/** Share the expensive source decode/crop/raw-RGBA IPC across provisional→final
|
||
* promotion and rapid carousel/remount races. Large sources remain single-flight
|
||
* while pending but are dropped after use so two 4K payloads cannot pin RAM. */
|
||
export async function getCatalogHeroGpuSource(url, plan = null) {
|
||
if (!url) return null;
|
||
if (catalogHeroGpuCache.has(url)) return touchCache(catalogHeroGpuCache, url);
|
||
const epoch = catalogHeroArtEpoch;
|
||
const pending = api.catalogHeroGpuSource(url, plan).then(
|
||
(payload) => {
|
||
if (epoch !== catalogHeroArtEpoch) return null;
|
||
if (catalogHeroGpuCache.get(url) === pending) {
|
||
if (payload?.byteLength <= CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES) {
|
||
setBoundedHeroCache(
|
||
catalogHeroGpuCache,
|
||
url,
|
||
payload,
|
||
CATALOG_HERO_GPU_CACHE_MAX,
|
||
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
|
||
);
|
||
} else {
|
||
catalogHeroGpuCache.delete(url);
|
||
}
|
||
}
|
||
return payload;
|
||
},
|
||
() => {
|
||
if (catalogHeroGpuCache.get(url) === pending) catalogHeroGpuCache.delete(url);
|
||
return null;
|
||
},
|
||
);
|
||
setBoundedHeroCache(
|
||
catalogHeroGpuCache,
|
||
url,
|
||
pending,
|
||
CATALOG_HERO_GPU_CACHE_MAX,
|
||
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
|
||
);
|
||
return pending;
|
||
}
|
||
|
||
/** Return only an already-finished persistent hero, without starting an
|
||
* upscale on a cache miss. This lets repeat visits race disk against the HD
|
||
* preview while keeping first-ever processing behind the first painted frame. */
|
||
export async function getCachedCatalogHeroArt(url) {
|
||
if (!url) return null;
|
||
const existing = catalogHeroArtCache.get(url);
|
||
// A full upscale already in progress is not a cache hit. Probe disk and
|
||
// continue scanning other candidates instead of waiting behind that job.
|
||
if (typeof existing === "string") {
|
||
touchCache(catalogHeroArtCache, url);
|
||
return existing;
|
||
}
|
||
const epoch = catalogHeroArtEpoch;
|
||
try {
|
||
const dataUrl = await api.catalogCachedHeroArt(url);
|
||
if (epoch !== catalogHeroArtEpoch) return null;
|
||
if (dataUrl) {
|
||
setBoundedHeroCache(
|
||
catalogHeroArtCache,
|
||
url,
|
||
dataUrl,
|
||
CATALOG_HERO_ART_CACHE_MAX,
|
||
CATALOG_HERO_ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return dataUrl;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function getCatalogHeroArt(url, plan = null) {
|
||
if (!url) return null;
|
||
if (catalogHeroArtCache.has(url)) return touchCache(catalogHeroArtCache, url);
|
||
const pending = api.catalogHeroArt(url, plan).then(
|
||
(result) => {
|
||
const dataUrl = result?.data_url ?? result;
|
||
const cacheable = result?.cacheable !== false;
|
||
// A manual-art refresh may clear the map while this request is in
|
||
// flight. Do not let an obsolete completion repopulate it.
|
||
if (catalogHeroArtCache.get(url) === pending) {
|
||
if (cacheable) {
|
||
setBoundedHeroCache(
|
||
catalogHeroArtCache,
|
||
url,
|
||
dataUrl,
|
||
CATALOG_HERO_ART_CACHE_MAX,
|
||
CATALOG_HERO_ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
else catalogHeroArtCache.delete(url);
|
||
}
|
||
return dataUrl;
|
||
},
|
||
() => {
|
||
if (catalogHeroArtCache.get(url) === pending) {
|
||
catalogHeroArtCache.delete(url);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
setBoundedHeroCache(
|
||
catalogHeroArtCache,
|
||
url,
|
||
pending,
|
||
CATALOG_HERO_ART_CACHE_MAX,
|
||
CATALOG_HERO_ART_CACHE_MAX_CHARS,
|
||
);
|
||
return pending;
|
||
}
|
||
|
||
export async function refreshLibrary() {
|
||
try {
|
||
// Artist/art refreshes replace files at the same cached path. Drop
|
||
// data-URL entries so the webview fetches the newly resized image.
|
||
artCache.clear();
|
||
// Saving a song adds tracks to an album this map has already answered
|
||
// for; a stale answer would leave the new SAVED badge off until reload.
|
||
libraryAlbumTitles.clear();
|
||
libraryStore.albums = await api.libraryAlbums();
|
||
libraryStore.loaded = true;
|
||
window.__libErr = null;
|
||
scheduleArtPrewarm();
|
||
} catch (e) {
|
||
// not connected to backend (browser preview) — leave empty
|
||
window.__libErr = String(e);
|
||
}
|
||
}
|
||
|
||
// ---- thumbnail pre-warm ----
|
||
// The disk cache makes a REVISITED cover a 1.5ms read, but the first-ever
|
||
// visit still decoded on scroll — so a section you had never scrolled to
|
||
// showed placeholders while its masters decoded two at a time. Warm the whole
|
||
// library once in the background instead (the Library's tile edge first, then
|
||
// the queue-card size) and every scroll lands on the warm path. Re-runs are
|
||
// ~one stat per cover, so this simply re-fires after every library change to
|
||
// pick up new arrivals; the guard coalesces bursts.
|
||
let artPrewarmRunning = false;
|
||
let artPrewarmAgain = false;
|
||
const ART_PREWARM_DELAY_MS = 4000;
|
||
let artPrewarmTimer = null;
|
||
function scheduleArtPrewarm() {
|
||
// Delay past the boot/refresh paint so the first screen's own art
|
||
// requests hit the decode permits before the background walk does.
|
||
clearTimeout(artPrewarmTimer);
|
||
artPrewarmTimer = setTimeout(() => {
|
||
artPrewarmTimer = null;
|
||
void runArtPrewarm();
|
||
}, ART_PREWARM_DELAY_MS);
|
||
}
|
||
async function runArtPrewarm() {
|
||
if (artPrewarmRunning) {
|
||
artPrewarmAgain = true;
|
||
return;
|
||
}
|
||
artPrewarmRunning = true;
|
||
try {
|
||
await api.libraryArtPrewarm([ART_EDGE_DEFAULT, 96]);
|
||
} catch {
|
||
// browser preview / old backend — tiles still load on demand
|
||
} finally {
|
||
artPrewarmRunning = false;
|
||
}
|
||
if (artPrewarmAgain) {
|
||
artPrewarmAgain = false;
|
||
scheduleArtPrewarm();
|
||
}
|
||
}
|
||
|
||
// ---- catalog browser (Deezer-backed streaming-style search) ----
|
||
|
||
export const catalog = $state({
|
||
query: "",
|
||
loading: false,
|
||
results: null, // { artists, albums, tracks }
|
||
artistLoading: false,
|
||
artistStageError: "",
|
||
artistPage: null, // { artist, albums }
|
||
// Bumped once per openCatalogArtist call, INCLUDING a re-open of the artist
|
||
// already on screen. The view opens itself at the top off this rather than
|
||
// off the artist's id: the id cannot tell a re-open from no navigation at
|
||
// all, and it cannot tell the cold path's stage-by-stage replacement of
|
||
// artistPage (same id, new object, several times a second) from a real one
|
||
// — keying on that would drag a reader back to the top mid-read.
|
||
artistOpenSeq: 0,
|
||
// The artist page's OWN computed discography tabs, published by it as it
|
||
// derives them: `{ artistId, tabs }`. The discography page renders these
|
||
// rather than classifying the release list a second time.
|
||
//
|
||
// It has to work this way. The filing depends on `descHints` — editorial
|
||
// release-type evidence the artist page harvests in the background while
|
||
// you look at it — and on the MusicBrainz type hints from the deferred
|
||
// identity pass. Neither is derivable from the payload alone, so a second
|
||
// classifier would read neither and would file some releases differently
|
||
// from the page that linked to it. One list, computed once, handed over.
|
||
discoTabs: null,
|
||
albumLoading: false,
|
||
albumPage: null, // { album, tracks }
|
||
// What the thing you clicked already knew about the record — cover, title,
|
||
// artist, kind. You reach an album page from a card that was ALREADY
|
||
// showing the artwork, so there is no reason for the page to be colourless
|
||
// while the request is out: the skeleton paints this immediately and the
|
||
// fetch only has to fill in the words. Null when nothing was known (a
|
||
// history restore of a record whose art was never stored, say), and the
|
||
// skeleton falls back to a placeholder block.
|
||
albumSeed: null, // { cover, title, artist, kind } | null
|
||
pendingTrackId: null, // a track to scroll to/flash on the album page
|
||
error: "",
|
||
// ---- search page supplements ------------------------------------------
|
||
// Three things the results page shows that a Deezer search does not
|
||
// return. Each lands on its own schedule and each is additive: the page
|
||
// is complete without any of them, and none of them ever re-orders or
|
||
// removes a row that is already on screen.
|
||
//
|
||
// Records only MusicBrainz knows about (Death Grips' Exmilitary, MBV's
|
||
// Loveless). Merged into Albums with no badge and no section of their
|
||
// own — see `catalogSearchIdentityAlbums`.
|
||
searchAlbumExtras: [],
|
||
// { [albumId]: "YYYY-MM-DD" }. Deezer's album SEARCH carries no release
|
||
// dates at all — 0 of 12 on a live "panda bear" — while the MusicBrainz
|
||
// supplement carries one for every row. Sorting the merged list by date
|
||
// without this therefore did the exact opposite of what it was for: every
|
||
// supplemented record floated above every result Deezer actually returned,
|
||
// and provenance decided position again. The dates come from the artist
|
||
// OVERVIEW, which `warmCatalogArtist` already fetches on every search, so
|
||
// this costs nothing.
|
||
searchAlbumDates: {},
|
||
// { [trackId]: listeners } — Last.fm counts for the top artist's songs,
|
||
// lifted from the same command the artist page's Popular list uses.
|
||
searchListeners: {},
|
||
// { [trackId]: true } for result songs already saved in the library.
|
||
searchOwned: {},
|
||
// { listeners, plays, tags } for the artist the hero is about.
|
||
searchArtistProfile: null,
|
||
// The hero album's own page — its real tracklist and headline totals. Only
|
||
// fetched when an ALBUM is the top result, which is the only time the hero
|
||
// has anything to do with it.
|
||
searchAlbumPage: null,
|
||
});
|
||
let artistPageRequest = 0;
|
||
let artistWarmGeneration = 0;
|
||
let catalogSearchRequest = 0;
|
||
let albumPageRequest = 0;
|
||
let albumResolveRequest = 0;
|
||
const ARTIST_REQUEST_CACHE_MAX = 24;
|
||
const ARTIST_IDENTITY_CACHE_MAX = 128;
|
||
const ARTIST_LOGO_CACHE_MAX = 64;
|
||
const artistIdCache = new Map(); // normalized name → Deezer id (or null)
|
||
const artistSeedCache = new Map(); // normalized name → search-result seed
|
||
const artistSeedById = new Map(); // Deezer id → latest known artist seed
|
||
const catalogArtistOverviewCache = new Map();
|
||
const catalogArtistHeroSeedCache = new Map();
|
||
const catalogArtistArtDataCache = new Map();
|
||
const catalogArtistLogoDataCache = new Map();
|
||
const catalogArtistPageCache = new Map();
|
||
|
||
// One canonical representation for all frontend artist cache keys. Route ids
|
||
// can arrive as strings while Tauri payload ids are numbers; treating those as
|
||
// separate keys wastes work and makes otherwise-correct late-result guards fail.
|
||
const artistCacheKey = (id) => (id == null ? "" : String(id).trim());
|
||
const albumCacheKey = (id) => (id == null ? "" : String(id).trim());
|
||
const sameArtistId = (left, right) => {
|
||
const leftKey = artistCacheKey(left);
|
||
return !!leftKey && leftKey === artistCacheKey(right);
|
||
};
|
||
const sameAlbumId = (left, right) => {
|
||
const leftKey = albumCacheKey(left);
|
||
return !!leftKey && leftKey === albumCacheKey(right);
|
||
};
|
||
|
||
const artistIdentityKey = (value) => {
|
||
const text = typeof value === "string" ? value : value?.name;
|
||
return (text ?? "")
|
||
.normalize("NFKD")
|
||
.replace(/\p{M}/gu, "")
|
||
.toLocaleLowerCase()
|
||
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||
};
|
||
|
||
/** Accept an exact artist credit or an explicit collaboration credit containing
|
||
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
|
||
* "Crazy Penis"). Missing credit metadata is retained because there is no
|
||
* identity signal to evaluate. */
|
||
function recordBelongsToArtist(record, artistName) {
|
||
const credited = typeof record?.artist === "string"
|
||
? record.artist
|
||
: record?.artist?.name;
|
||
if (!credited) return true;
|
||
const expected = artistIdentityKey(artistName);
|
||
if (!expected) return true;
|
||
if (artistIdentityKey(credited) === expected) return true;
|
||
return credited
|
||
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
|
||
.some((part) => artistIdentityKey(part) === expected);
|
||
}
|
||
|
||
function artistScopedRecords(records, artistName) {
|
||
return Array.isArray(records)
|
||
? records.filter((record) => recordBelongsToArtist(record, artistName))
|
||
: [];
|
||
}
|
||
|
||
/** Sanitize every staged/full artist payload before it enters a shared cache.
|
||
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
|
||
* second line of defense for additive provider records such as Apple/MB data. */
|
||
function artistScopedPayload(payload) {
|
||
const artistName = payload?.artist?.name ?? "";
|
||
return {
|
||
...payload,
|
||
...(Object.hasOwn(payload ?? {}, "albums")
|
||
? { albums: artistScopedRecords(payload.albums, artistName) }
|
||
: {}),
|
||
...(Object.hasOwn(payload ?? {}, "popular_tracks")
|
||
? { popular_tracks: artistScopedRecords(payload.popular_tracks, artistName) }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
function checkedArtistPayload(artistId, payload, stage) {
|
||
if (!sameArtistId(payload?.artist?.id, artistId)) {
|
||
throw new Error(`${stage} returned a mismatched artist identity`);
|
||
}
|
||
return artistScopedPayload(payload);
|
||
}
|
||
|
||
function rememberArtistSeed(seed) {
|
||
if (!seed?.id) return null;
|
||
const idKey = artistCacheKey(seed.id);
|
||
const prior = artistSeedById.get(idKey);
|
||
const remembered = {
|
||
id: seed.id,
|
||
name: seed.name ?? prior?.name ?? "",
|
||
picture: seed.picture ?? prior?.picture ?? null,
|
||
fans: seed.fans ?? prior?.fans ?? null,
|
||
album_count:
|
||
seed.album_count ?? seed.albumCount ?? prior?.album_count ?? null,
|
||
};
|
||
setBoundedMap(
|
||
artistSeedById,
|
||
idKey,
|
||
remembered,
|
||
ARTIST_IDENTITY_CACHE_MAX,
|
||
);
|
||
// If this IS the artist being viewed, its crumb gains the real name and
|
||
// portrait it was opened without. Guarded inside refreshVisit, so seeds
|
||
// remembered for rows in a list cannot rewrite where you are.
|
||
refreshVisit({
|
||
view: "catalogArtist",
|
||
label: remembered.name,
|
||
artistId: remembered.id,
|
||
art: remembered.picture ?? null,
|
||
round: true,
|
||
});
|
||
const norm = remembered.name.trim().toLowerCase();
|
||
if (norm) {
|
||
const existing = artistSeedCache.get(norm);
|
||
const sameIdentity = sameArtistId(existing?.id, remembered.id);
|
||
const moreCanonical = (remembered.fans ?? -1) > (existing?.fans ?? -1);
|
||
// Deezer can return several exact-name matches. Preserve the most-
|
||
// followed canonical seed instead of letting the last obscure namesake
|
||
// in a search response poison all future name-only navigation.
|
||
if (!existing || sameIdentity || moreCanonical) {
|
||
setBoundedMap(
|
||
artistSeedCache,
|
||
norm,
|
||
remembered,
|
||
ARTIST_IDENTITY_CACHE_MAX,
|
||
);
|
||
setBoundedMap(
|
||
artistIdCache,
|
||
norm,
|
||
remembered.id,
|
||
ARTIST_IDENTITY_CACHE_MAX,
|
||
);
|
||
}
|
||
}
|
||
return remembered;
|
||
}
|
||
|
||
function trimArtistRequestCache(cache) {
|
||
let resolved = 0;
|
||
for (const value of cache.values()) {
|
||
if (!isPending(value)) resolved += 1;
|
||
}
|
||
for (const [key, value] of cache) {
|
||
if (resolved <= ARTIST_REQUEST_CACHE_MAX) break;
|
||
if (isPending(value)) continue;
|
||
cache.delete(key);
|
||
resolved -= 1;
|
||
}
|
||
}
|
||
|
||
function cachedArtistRequest(cache, artistId, load) {
|
||
const key = artistCacheKey(artistId);
|
||
if (!key) return Promise.reject(new Error("artist id is required"));
|
||
if (cache.has(key)) return Promise.resolve(touchCache(cache, key));
|
||
const pending = load().then(
|
||
(result) => {
|
||
if (cache.get(key) === pending) {
|
||
cache.delete(key);
|
||
cache.set(key, result);
|
||
trimArtistRequestCache(cache);
|
||
}
|
||
return result;
|
||
},
|
||
(error) => {
|
||
if (cache.get(key) === pending) cache.delete(key);
|
||
throw error;
|
||
},
|
||
);
|
||
cache.set(key, pending);
|
||
trimArtistRequestCache(cache);
|
||
return pending;
|
||
}
|
||
|
||
function resolvedArtistCacheValue(cache, artistId) {
|
||
const value = cache.get(artistCacheKey(artistId));
|
||
return value && typeof value.then !== "function" ? value : null;
|
||
}
|
||
|
||
export const getCatalogArtistOverview = (artistId) =>
|
||
cachedArtistRequest(catalogArtistOverviewCache, artistId, () =>
|
||
api.catalogArtistOverview(artistId).then((payload) =>
|
||
checkedArtistPayload(artistId, payload, "artist overview")),
|
||
);
|
||
|
||
export const getCatalogArtistHeroSeed = (artistId) =>
|
||
cachedArtistRequest(catalogArtistHeroSeedCache, artistId, () =>
|
||
api.catalogArtistHeroSeed(artistId),
|
||
).then((seed) => {
|
||
// The backend applies a short negative TTL. Do not turn a transient
|
||
// no-seed result into a frontend-session-long miss.
|
||
if (!seed) catalogArtistHeroSeedCache.delete(artistCacheKey(artistId));
|
||
return seed;
|
||
});
|
||
|
||
export const getCatalogArtistIdentityArt = (artistId) =>
|
||
cachedArtistRequest(catalogArtistArtDataCache, artistId, () =>
|
||
api.catalogArtistArt(artistId).then((payload) =>
|
||
checkedArtistPayload(artistId, payload, "artist art")),
|
||
).then((art) => {
|
||
// Empty provider/Bing results have a short backend TTL and may recover
|
||
// on a later visit. Keep the shared promise for the whole in-flight
|
||
// request, but do not turn a valid no-art payload into a session-long
|
||
// frontend miss. Positive banners, custom art and logos stay cached.
|
||
const hasArt = Boolean(
|
||
art?.banner_custom
|
||
|| art?.banner
|
||
|| art?.banners?.length
|
||
|| art?.logos?.length
|
||
);
|
||
if (!hasArt) catalogArtistArtDataCache.delete(artistCacheKey(artistId));
|
||
return art;
|
||
});
|
||
|
||
export const getCatalogArtistProviderLogos = (artistId) =>
|
||
cachedArtistRequest(catalogArtistLogoDataCache, artistId, () =>
|
||
api.catalogArtistLogos(artistId).then((logos) => {
|
||
if (!Array.isArray(logos) || logos.some((logo) => typeof logo !== "string")) {
|
||
throw new Error("artist logos returned an invalid response");
|
||
}
|
||
rememberProviderLogoStatus(artistId, logos.length > 0);
|
||
if (!logos.length) {
|
||
// Backend provider-art misses intentionally have a short TTL.
|
||
// Keep this page's settled negative decision, but do not pin it
|
||
// in the frontend request cache for the rest of the session.
|
||
catalogArtistLogoDataCache.delete(artistCacheKey(artistId));
|
||
}
|
||
return logos;
|
||
}),
|
||
);
|
||
|
||
export const getCatalogArtistPage = (artistId) =>
|
||
cachedArtistRequest(catalogArtistPageCache, artistId, () =>
|
||
api.catalogArtist(artistId).then((payload) =>
|
||
checkedArtistPayload(artistId, payload, "artist page")),
|
||
);
|
||
|
||
export function invalidateCatalogArtist(artistId) {
|
||
const key = artistCacheKey(artistId);
|
||
catalogArtistOverviewCache.delete(key);
|
||
catalogArtistHeroSeedCache.delete(key);
|
||
catalogArtistArtDataCache.delete(key);
|
||
catalogArtistLogoDataCache.delete(key);
|
||
catalogArtistPageCache.delete(key);
|
||
providerLogoStatusById.delete(key);
|
||
for (const logoKey of coverLogoByInput.keys()) {
|
||
if (logoKey.startsWith(`${key}\u0000`)) coverLogoByInput.delete(logoKey);
|
||
}
|
||
}
|
||
|
||
// ---- Stale-while-revalidate page replay -----------------------------------
|
||
// A revisit after an app restart used to start from the loading shell even
|
||
// though the previous session had the artist's FINAL merged page. The last
|
||
// merged page per artist is persisted (localStorage, MRU-capped) and painted
|
||
// immediately as a stale stand-in — paint-only: every stage of the normal
|
||
// cold flow still runs and replaces it wholesale, so this can never repeat
|
||
// the old "cached page skipped the MB pass" bug. Replayed pages keep
|
||
// `art_preview: true` (the full page still lands) and set `replayed: true`
|
||
// (the overview stage must not overwrite final classified albums with its
|
||
// raw first-50).
|
||
// v2: pages saved before guest appearances were filtered are stamped
|
||
// `identity_merged`, so they would skip the pass that drops them and keep
|
||
// rendering another artist's records. Cheap to discard — the replay only buys
|
||
// an instant first paint, and a miss is one ordinary load.
|
||
// v3: same argument for release ownership. A v2 replay carries a fused
|
||
// record's co-tenants and its wrong portrait, both stamped as merged.
|
||
// v4: same argument for original-album attribution. A v3 replay carries
|
||
// Deezer's licensed attribution on every popular row — "Bohemian Rhapsody" on
|
||
// the 2018 movie soundtrack, with that poster as its cover — and painting a
|
||
// known-wrong album name instantly is worse than one ordinary load.
|
||
const PAGE_REPLAY_RECIPE = "replay-v4";
|
||
const PAGE_REPLAY_LIMIT = 16;
|
||
// Matches the backend's provider disk-cache TTL on purpose: a replay must
|
||
// never outlive the MusicBrainz warmth it was saved with, or the identity
|
||
// re-merge that replaces it stops being instant.
|
||
const PAGE_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
|
||
const PAGE_REPLAY_INDEX = "ss-page-replay-index";
|
||
const pageReplayKey = (key) => `ss-page-replay:${key}`;
|
||
|
||
function loadPageReplay(artistId) {
|
||
try {
|
||
const key = artistCacheKey(artistId);
|
||
if (!key) return null;
|
||
const raw = localStorage.getItem(pageReplayKey(key));
|
||
if (!raw) return null;
|
||
const entry = JSON.parse(raw);
|
||
if (entry?.recipe !== PAGE_REPLAY_RECIPE) return null;
|
||
if (!entry.at || Date.now() - entry.at > PAGE_REPLAY_TTL_MS) return null;
|
||
const page = entry.page;
|
||
if (!sameArtistId(page?.artist?.id, artistId)) return null;
|
||
if (!Array.isArray(page?.albums) || !page.albums.length) return null;
|
||
return page;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function savePageReplay(artistId, page) {
|
||
try {
|
||
const key = artistCacheKey(artistId);
|
||
if (!key || !page?.albums?.length) return;
|
||
// Only durable content fields. Art (banners/logos/cover_logo) is
|
||
// deliberately excluded: those pipelines have their own caches and
|
||
// settled/override semantics that a replay must not preempt.
|
||
const slim = {
|
||
artist: page.artist,
|
||
popular_tracks: page.popular_tracks ?? [],
|
||
albums: page.albums,
|
||
top_genre: page.top_genre ?? null,
|
||
socials: page.socials ?? [],
|
||
typeHints: page.typeHints ?? [],
|
||
identity_merged: true,
|
||
};
|
||
localStorage.setItem(
|
||
pageReplayKey(key),
|
||
JSON.stringify({ recipe: PAGE_REPLAY_RECIPE, at: Date.now(), page: slim }),
|
||
);
|
||
const index = JSON.parse(localStorage.getItem(PAGE_REPLAY_INDEX) ?? "[]");
|
||
const next = [key, ...index.filter((k) => k !== key)];
|
||
for (const evicted of next.slice(PAGE_REPLAY_LIMIT)) {
|
||
localStorage.removeItem(pageReplayKey(evicted));
|
||
}
|
||
localStorage.setItem(PAGE_REPLAY_INDEX, JSON.stringify(next.slice(0, PAGE_REPLAY_LIMIT)));
|
||
} catch {
|
||
// Storage full or unavailable — the replay is purely optional.
|
||
}
|
||
}
|
||
|
||
/** Warm only the two visible stages while a user reads search results. The
|
||
* expensive exhaustive page remains click-driven. */
|
||
export function cancelCatalogArtistWarmup() {
|
||
artistWarmGeneration += 1;
|
||
}
|
||
|
||
export function warmCatalogArtist(seed) {
|
||
const remembered = rememberArtistSeed(seed);
|
||
if (!remembered) return;
|
||
const warmGeneration = ++artistWarmGeneration;
|
||
const heroModeReady = getHeroWarmMode();
|
||
void getCatalogArtistOverview(remembered.id).catch(() => {});
|
||
void getCatalogArtistHeroSeed(remembered.id)
|
||
.then(async (seed) => {
|
||
if (warmGeneration !== artistWarmGeneration) return;
|
||
if (seed?.banner && !seed.banner.startsWith("data:")) {
|
||
// Warm exactly the asset the selected renderer consumes. Live
|
||
// GPU never paints the legacy HD data URL, so preparing it would
|
||
// waste an extra decode and up to 12 MiB of WebView cache.
|
||
const mode = await heroModeReady;
|
||
if (warmGeneration !== artistWarmGeneration) return;
|
||
if (mode === "live_gpu") {
|
||
void getCatalogHeroGpuSource(seed.banner, null).catch(() => {});
|
||
} else {
|
||
void getCatalogHeroPreview(seed.banner).catch(() => {});
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
void getCatalogArtistProviderLogos(remembered.id)
|
||
.then((logos) => {
|
||
const primaryLogo = logos?.[0];
|
||
if (primaryLogo) void getCatalogArt(primaryLogo);
|
||
})
|
||
.catch(() => {});
|
||
// Also warm the MusicBrainz identity chain — the slowest piece of a cold
|
||
// artist page (3-5 requests behind MB's mandatory 1 req/s gate). Gated
|
||
// three ways so live-search churn can never flood MusicBrainz: only after
|
||
// 600ms of the artist staying the top hit (a newer warm generation
|
||
// supersedes the timer), only once per artist per session, and only for
|
||
// this single seed — never a result list.
|
||
const identityKey = artistCacheKey(remembered.id);
|
||
if (identityKey && remembered.name && !identityWarmupDone.has(identityKey)) {
|
||
setTimeout(() => {
|
||
if (warmGeneration !== artistWarmGeneration) return;
|
||
if (identityWarmupDone.has(identityKey)) return;
|
||
identityWarmupDone.add(identityKey);
|
||
void api.catalogArtistIdentity(remembered.id, remembered.name).catch(() => {});
|
||
}, 600);
|
||
}
|
||
}
|
||
const identityWarmupDone = new Set();
|
||
|
||
/** Move to a top-level view (the sidebar's destinations). Routed through here
|
||
* rather than assigning `ui.view` directly so history and the trail see it —
|
||
* a root also STARTS a journey, which is what the first crumb reports. */
|
||
export function goToView(view, opts = {}) {
|
||
captureScroll();
|
||
ui.view = view;
|
||
// The hub's routable ids are pills on the Community page; a repeat jump
|
||
// to one (already on that view) still has to switch the pill.
|
||
if (view === "search" || view === "downloads") ui.hubJump += 1;
|
||
if (opts.albumId !== undefined) ui.albumId = opts.albumId;
|
||
if (opts.playlistId !== undefined) ui.playlistId = opts.playlistId;
|
||
recordVisit({ view, label: opts.label ?? VIEW_LABELS[view] ?? view, glyph: VIEW_GLYPHS[view] ?? null, ...opts });
|
||
}
|
||
|
||
const VIEW_LABELS = {
|
||
home: "Home",
|
||
// The hub is fused into Community (2026-09-02): its three routable ids
|
||
// land on that page's pills and read as that page in the trail.
|
||
soulseek: "Community",
|
||
community: "Community",
|
||
search: "Community",
|
||
library: "Your Library",
|
||
downloads: "Community",
|
||
settings: "Settings",
|
||
playlists: "Playlists",
|
||
playlist: "Playlist",
|
||
lyrics: "Lyrics",
|
||
};
|
||
const VIEW_GLYPHS = {
|
||
home: "home",
|
||
soulseek: "users",
|
||
community: "users",
|
||
search: "users",
|
||
library: "library",
|
||
downloads: "users",
|
||
settings: "settings",
|
||
playlists: "playlist",
|
||
playlist: "music",
|
||
lyrics: "music",
|
||
};
|
||
|
||
/** Re-open a recorded place. The one function the history arrows and the trail
|
||
* both drive; every case must land on exactly the view the entry describes. */
|
||
export async function openEntry(entry) {
|
||
switch (entry.view) {
|
||
case "catalogArtist":
|
||
return openCatalogArtist(entry.artistId);
|
||
case "catalogDiscography":
|
||
// Falls back to the artist page by itself when the tabs it needs
|
||
// are not the ones in hand — see openCatalogDiscography.
|
||
return openCatalogDiscography(entry.artistId);
|
||
case "catalogAlbum":
|
||
// A local record lives on this same view but is composed from the
|
||
// library rather than fetched — the back arrow has to route on the
|
||
// id's realm or it would ask the catalog for a `lib:` id.
|
||
if (isLibraryAlbumId(entry.albumId)) {
|
||
return openLibraryAlbum(libraryAlbumIdOf(entry.albumId), {
|
||
seed: entry.art
|
||
? {
|
||
cover: entry.art,
|
||
title: entry.label ?? null,
|
||
artist: entry.artist ?? null,
|
||
kind: null,
|
||
}
|
||
: null,
|
||
});
|
||
}
|
||
// `recordVisit` stored this record's cover, so going back paints
|
||
// the skeleton with the artwork rather than a grey block. Older
|
||
// entries predate that field and simply seed nothing.
|
||
return openCatalogAlbum(entry.albumId, {
|
||
seed: entry.art
|
||
? {
|
||
cover: entry.art,
|
||
title: entry.label ?? null,
|
||
artist: entry.artist ?? null,
|
||
kind: null,
|
||
}
|
||
: null,
|
||
});
|
||
case "catalog":
|
||
return catalogGo(entry.query ?? entry.label);
|
||
case "playlist":
|
||
ui.playlistId = entry.playlistId ?? null;
|
||
ui.view = "playlist";
|
||
return;
|
||
case "library":
|
||
ui.albumId = entry.albumId ?? null;
|
||
ui.view = "library";
|
||
return;
|
||
default:
|
||
ui.view = entry.view;
|
||
}
|
||
}
|
||
|
||
export const navBack = () => goBack(openEntry);
|
||
export const navForward = () => goForward(openEntry);
|
||
export const navToCrumb = (entry) => goToCrumb(entry, openEntry);
|
||
export { nav };
|
||
|
||
/** Run a catalog search and show the results view. */
|
||
export async function catalogGo(query) {
|
||
captureScroll();
|
||
cancelCatalogArtistWarmup();
|
||
const q = query.trim();
|
||
if (!q) return;
|
||
const request = ++catalogSearchRequest;
|
||
ui.view = "catalog";
|
||
ui.albumId = null;
|
||
recordVisit({ view: "catalog", label: `“${q}”`, query: q, glyph: "search" });
|
||
catalog.query = q;
|
||
catalog.results = null;
|
||
catalog.loading = true;
|
||
catalog.error = "";
|
||
catalog.searchAlbumExtras = [];
|
||
catalog.searchAlbumDates = {};
|
||
catalog.searchListeners = {};
|
||
catalog.searchOwned = {};
|
||
catalog.searchArtistProfile = null;
|
||
catalog.searchAlbumPage = null;
|
||
try {
|
||
const results = await api.catalogSearch(q);
|
||
if (request !== catalogSearchRequest) return;
|
||
catalog.results = results;
|
||
// Only a query that found something is worth offering back, or learning
|
||
// as a correctly-spelled name — see `recentSearches`.
|
||
if (results?.artists?.length || results?.albums?.length || results?.tracks?.length) {
|
||
fruitfulSearches.add(q);
|
||
} else {
|
||
fruitfulSearches.delete(q);
|
||
}
|
||
for (const artist of results?.artists ?? []) rememberArtistSeed(artist);
|
||
// Search results are normally read for a moment before the first artist
|
||
// is opened. Spend that otherwise-idle time warming its identity/art
|
||
// lookup; the keyed backend single-flight shares this exact request if
|
||
// the user clicks immediately.
|
||
const likelyArtistId = results?.artists?.[0]?.id;
|
||
if (likelyArtistId != null) {
|
||
warmCatalogArtist(results.artists[0]);
|
||
}
|
||
// Every supplement is fired AFTER the results are on screen and none is
|
||
// awaited: the page must never wait on MusicBrainz's 1 req/s gate or on
|
||
// Last.fm to paint what Deezer already answered.
|
||
const stillCurrent = () => request === catalogSearchRequest;
|
||
void applySearchAlbumDates(results, stillCurrent);
|
||
void applySearchIdentityAlbums(results, stillCurrent);
|
||
void applySearchListeners(results, stillCurrent);
|
||
void applySearchOwnership(results, stillCurrent);
|
||
} catch (e) {
|
||
if (request === catalogSearchRequest) catalog.error = String(e);
|
||
} finally {
|
||
if (request === catalogSearchRequest) catalog.loading = false;
|
||
}
|
||
}
|
||
|
||
/* The two hero supplements. Both are driven by the VIEW rather than fired from
|
||
* `catalogGo`, because only the view knows which kind won the hero — and each
|
||
* costs a rate-limited lookup that would be wasted on the other two kinds. The
|
||
* ranking rule stays in one place instead of being mirrored here. */
|
||
let searchProfileRequest = 0;
|
||
/** Genres + Last.fm totals for an ARTIST hero. */
|
||
export async function loadSearchArtistProfile(artistId) {
|
||
const request = ++searchProfileRequest;
|
||
catalog.searchArtistProfile = null;
|
||
if (artistId == null) return;
|
||
try {
|
||
const profile = await api.catalogArtistProfile(artistId);
|
||
if (request === searchProfileRequest) catalog.searchArtistProfile = profile;
|
||
} catch {
|
||
// The hero reads fine on Deezer's own two figures alone.
|
||
}
|
||
}
|
||
|
||
let searchAlbumPageRequest = 0;
|
||
/** The real tracklist and headline totals for an ALBUM hero. The search's own
|
||
* track results are a different list entirely — they are what matched the
|
||
* query, not what is on the record. */
|
||
export async function loadSearchAlbumPage(albumId) {
|
||
const request = ++searchAlbumPageRequest;
|
||
catalog.searchAlbumPage = null;
|
||
if (albumId == null) return;
|
||
try {
|
||
const page = await api.catalogAlbum(albumId);
|
||
if (request === searchAlbumPageRequest) catalog.searchAlbumPage = page;
|
||
} catch {
|
||
// Falls back to the matched songs, which is what it showed before.
|
||
}
|
||
}
|
||
|
||
/** Hand the query off to the Soulseek network file search.
|
||
*
|
||
* A catalogue miss is not the end of the road in a Soulseek client — the
|
||
* network routinely carries what no catalogue lists — so every dead end on the
|
||
* results page offers this rather than nothing. */
|
||
export async function searchNetworkFor(query) {
|
||
const q = (query ?? "").trim();
|
||
if (!q) return;
|
||
// Seeded before the view swaps: Search.svelte reads `search.query` once, at
|
||
// mount, to show what it is looking for.
|
||
search.query = q;
|
||
goToView("search");
|
||
if (app.connection !== "online") {
|
||
app.showLogin = true;
|
||
return;
|
||
}
|
||
try {
|
||
await runSearch(q);
|
||
} catch (err) {
|
||
app.loginError = String(err);
|
||
app.showLogin = true;
|
||
}
|
||
}
|
||
|
||
/** The records a Deezer SEARCH cannot see, for the artist a search is about.
|
||
*
|
||
* Deezer's search index is not a discography: Death Grips' Exmilitary, My
|
||
* Bloody Valentine's Loveless and half of Panda Bear's early run are simply
|
||
* absent from it. The artist page already solves this — it merges a
|
||
* MusicBrainz supplement over the Deezer list — so the results page runs the
|
||
* SAME merge (`mergeArtistIdentity`) rather than a second, disagreeing one.
|
||
*
|
||
* Returns only the rows the merge INJECTED, so the caller can fold them into
|
||
* the album list it already has. The albums handed in are narrowed to the top
|
||
* artist's own first: an MB release group belongs to that artist, and letting
|
||
* a namesake's row block one would delete a real record from the page.
|
||
*/
|
||
export async function catalogSearchIdentityAlbums(seedArtist, searchAlbums) {
|
||
const name = seedArtist?.name ?? "";
|
||
if (seedArtist?.id == null || !name) return [];
|
||
const identity = await api.catalogArtistIdentity(seedArtist.id, name);
|
||
if (!identity?.albums?.length) return [];
|
||
const key = catNorm(name);
|
||
const mine = (searchAlbums ?? []).filter((a) => catNorm(a?.artist) === key);
|
||
const merged = mergeArtistIdentity(
|
||
{ artist: { name }, albums: mine, socials: [], typeHints: [] },
|
||
identity,
|
||
);
|
||
const known = new Set(mine.map((a) => a?.id));
|
||
return (merged?.albums ?? []).filter((a) => a && !known.has(a.id));
|
||
}
|
||
|
||
/** Release dates for the albums a search returned.
|
||
*
|
||
* `/search/album` omits `release_date` entirely; `/artist/{id}/albums` — which
|
||
* the artist OVERVIEW wraps, and which `warmCatalogArtist` has already asked
|
||
* for by the time this runs — carries one for every release. Matched by album
|
||
* id, so this is a lookup and never a title guess. Albums by an artist other
|
||
* than the one searched for keep no date, and simply show none.
|
||
*/
|
||
/** At most this many artists get a discography lookup for their dates. An
|
||
* album-title query returns rows from tribute bands, karaoke acts and piano
|
||
* covers; only the acts that actually hold several rows here are worth one. */
|
||
const ALBUM_DATE_ARTIST_BUDGET = 3;
|
||
|
||
async function applySearchAlbumDates(results, active) {
|
||
const undated = (results?.albums ?? []).filter((a) => a?.id != null && !a.release_date);
|
||
if (!undated.length) return;
|
||
// Grouped by the album's OWN artist rather than by the search's top artist.
|
||
// Deezer's ARTIST search answers nothing at all for "weezer blue album", so
|
||
// keying this on `results.artists[0]` left every row on an album-title
|
||
// query dateless — which is what showed "Weezer · — · 10 tracks".
|
||
const byArtist = new Map();
|
||
for (const album of undated) {
|
||
const key = catNorm(album.artist);
|
||
if (!key) continue;
|
||
if (!byArtist.has(key)) byArtist.set(key, { name: album.artist, rows: [] });
|
||
byArtist.get(key).rows.push(album);
|
||
}
|
||
const groups = [...byArtist.values()]
|
||
.sort((a, b) => b.rows.length - a.rows.length)
|
||
.slice(0, ALBUM_DATE_ARTIST_BUDGET);
|
||
const dates = {};
|
||
await Promise.all(
|
||
groups.map(async (group) => {
|
||
try {
|
||
const seed = await resolveArtistSeedByName(group.name);
|
||
if (seed?.id == null || !active()) return;
|
||
const overview = await getCatalogArtistOverview(seed.id);
|
||
if (!active()) return;
|
||
const byId = new Map();
|
||
const byTitle = new Map();
|
||
for (const album of overview?.albums ?? []) {
|
||
if (album?.id == null || !album.release_date) continue;
|
||
byId.set(String(album.id), album.release_date);
|
||
// First listing wins: the discography arrives newest-first,
|
||
// and the original pressing is the date a card should show.
|
||
const key = normText(album.title);
|
||
if (key && !byTitle.has(key)) byTitle.set(key, album.release_date);
|
||
}
|
||
for (const row of group.rows) {
|
||
// A search hit and the same record in the artist's own
|
||
// discography are frequently DIFFERENT Deezer ids — two
|
||
// pressings of one album — so an id miss falls back to the
|
||
// title. Safe here by construction: the only titles in play
|
||
// are this one artist's own.
|
||
const date =
|
||
byId.get(String(row.id)) ?? byTitle.get(normText(row.title));
|
||
if (date) dates[String(row.id)] = date;
|
||
}
|
||
} catch {
|
||
// One artist failing to date does not stop the others.
|
||
}
|
||
}),
|
||
);
|
||
if (!active() || !Object.keys(dates).length) return;
|
||
catalog.searchAlbumDates = dates;
|
||
}
|
||
|
||
async function applySearchIdentityAlbums(results, active) {
|
||
const seed = results?.artists?.[0];
|
||
if (!seed) return;
|
||
try {
|
||
let extras = await catalogSearchIdentityAlbums(seed, results.albums);
|
||
// A COLD MusicBrainz call answers with nothing under per-IP contention,
|
||
// and "no albums" is indistinguishable from "this artist has none". One
|
||
// delayed retry, exactly as the artist page's identity pass does — for a
|
||
// genuinely album-less artist both calls answer from the URL caches, so
|
||
// the retry costs the delay and no MusicBrainz traffic.
|
||
if (!extras.length) {
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
if (!active()) return;
|
||
extras = await catalogSearchIdentityAlbums(seed, results.albums);
|
||
}
|
||
if (!active() || !extras.length) return;
|
||
catalog.searchAlbumExtras = extras;
|
||
} catch {
|
||
// Best-effort supplement: the Deezer list is a complete page without it.
|
||
}
|
||
}
|
||
|
||
/** Last.fm listener counts for the result songs.
|
||
*
|
||
* Search results carry no counts — `catalog_search` is three Deezer calls and
|
||
* nothing else, deliberately, because the typeahead calls it per keystroke.
|
||
* The counts are a separate, deferred pass over EVERY row, resolved per
|
||
* performing artist by that row's own Deezer artist id (see
|
||
* `MetadataClient::track_listeners`). A row the backend has no count for keeps
|
||
* `null` — a dash in the UI, never a fabricated zero.
|
||
*
|
||
* An earlier version asked only the TOP artist's popular list and matched
|
||
* titles against it. That left every row credited to somebody else blank —
|
||
* Daft Punk's "Doin' it Right (feat. Panda Bear)" showed a dash on a Panda Bear
|
||
* search while the album page showed its 464k listeners.
|
||
*/
|
||
async function applySearchListeners(results, active) {
|
||
const tracks = results?.tracks ?? [];
|
||
if (!tracks.length) return;
|
||
try {
|
||
const counts = await api.catalogTrackListeners(
|
||
tracks.map((t) => ({ artistId: t.artist_id ?? null, title: t.title })),
|
||
);
|
||
if (!active() || !Array.isArray(counts)) return;
|
||
const out = {};
|
||
tracks.forEach((t, i) => {
|
||
if (counts[i] != null) out[t.id] = counts[i];
|
||
});
|
||
if (!active() || !Object.keys(out).length) return;
|
||
catalog.searchListeners = out;
|
||
} catch {
|
||
// No counts is a page without bars, not a broken page.
|
||
}
|
||
}
|
||
|
||
const libraryAlbumTitles = new Map(); // library album id -> Promise<Set<normTitle>>
|
||
|
||
/** Which of these catalog songs are already saved, as `{ [trackId]: true }`.
|
||
*
|
||
* Ownership is per TRACK and the library index is per album, so this walks only
|
||
* the library albums whose artist appears in the list — usually none, at most a
|
||
* handful — and memoizes each album's tracklist for the session. Shared by the
|
||
* results page and the typeahead so one SAVED badge cannot contradict another.
|
||
*/
|
||
export async function catalogTrackOwnership(tracks) {
|
||
const rows = tracks ?? [];
|
||
if (!rows.length || !libraryStore.albums.length) return {};
|
||
const wanted = new Set(rows.map((t) => normText(t?.artist)).filter(Boolean));
|
||
const albums = libraryStore.albums.filter((al) => wanted.has(normText(al.artist)));
|
||
if (!albums.length) return {};
|
||
const titlesByArtist = new Map();
|
||
await Promise.all(
|
||
albums.map(async (al) => {
|
||
if (!libraryAlbumTitles.has(al.id)) {
|
||
libraryAlbumTitles.set(
|
||
al.id,
|
||
api
|
||
.albumTracks(al.id)
|
||
.then((r) => new Set(r.map((row) => normText(row.title || ""))))
|
||
.catch(() => new Set()),
|
||
);
|
||
}
|
||
const titles = await libraryAlbumTitles.get(al.id);
|
||
const key = normText(al.artist);
|
||
if (!titlesByArtist.has(key)) titlesByArtist.set(key, new Set());
|
||
const into = titlesByArtist.get(key);
|
||
for (const t of titles) into.add(t);
|
||
}),
|
||
);
|
||
const out = {};
|
||
for (const t of rows) {
|
||
if (titlesByArtist.get(normText(t?.artist))?.has(normText(t?.title))) out[t.id] = true;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function applySearchOwnership(results, active) {
|
||
try {
|
||
const owned = await catalogTrackOwnership(results?.tracks);
|
||
if (!active() || !Object.keys(owned).length) return;
|
||
catalog.searchOwned = owned;
|
||
} catch {
|
||
// No badge is a fine answer; a wrong badge is not.
|
||
}
|
||
}
|
||
|
||
/** Queries that actually found something this session.
|
||
*
|
||
* History alone is the wrong list to offer back: a typo you made two minutes
|
||
* ago is in it, and re-running a known miss is not a suggestion. It is also the
|
||
* wrong vocabulary for the results page's spelling correction, which would
|
||
* otherwise learn the misspelling as a word and stop correcting it. */
|
||
const fruitfulSearches = new Set();
|
||
|
||
/** The searches you ran this session that returned something, newest first —
|
||
* read out of the app's own history rather than a second store, and never
|
||
* written to disk. */
|
||
export function recentSearches(exclude = "", limit = 6) {
|
||
const out = [];
|
||
for (let i = nav.stack.length - 1; i >= 0 && out.length < limit; i--) {
|
||
const e = nav.stack[i];
|
||
if (e.view !== "catalog" || !e.query) continue;
|
||
if (e.query === exclude || out.includes(e.query)) continue;
|
||
if (!fruitfulSearches.has(e.query)) continue;
|
||
out.push(e.query);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Open an artist as a four-stage page: synchronous identity shell, fast
|
||
* Deezer overview, independent provider artwork, then exhaustive refinement.
|
||
* Every ID gets a shell; known search/discovery seeds add the name + blurred
|
||
* portrait without waiting for IPC or the network. */
|
||
// Provider presence is an identity-scoped, monotonic decision. Once any exact
|
||
// provider pass finds a curated logo, a later empty/error result may not expose
|
||
// a cover-derived fallback in front of it.
|
||
const providerLogoStatusById = new Map();
|
||
|
||
// Cover-derived results are keyed by the exact identity-filtered cover input,
|
||
// not only by artist id. This prevents a negative or wordmark generated from an
|
||
// older/polluted discography from being reused after the candidate set changes.
|
||
// Values are either an in-flight Promise, a finalized data URL, or null.
|
||
const coverLogoByInput = new Map();
|
||
const ARTIST_LOGO_CACHE_MAX_CHARS = 12 * MIB;
|
||
|
||
function rememberProviderLogoStatus(artistId, hasProviderLogo) {
|
||
const key = artistCacheKey(artistId);
|
||
if (!key) return;
|
||
const prior = providerLogoStatusById.get(key);
|
||
setBoundedMap(
|
||
providerLogoStatusById,
|
||
key,
|
||
prior === true || hasProviderLogo === true,
|
||
ARTIST_LOGO_CACHE_MAX,
|
||
);
|
||
}
|
||
|
||
/** Apply a fast, definitive provider-logo decision. With no provider logo, an
|
||
* exact-input cached result or generation starts once the hardened final cover
|
||
* list is supplied. */
|
||
async function resolveCoverLogoAvailability(artistId, page, providerLogosReady, active) {
|
||
const logos = await providerLogosReady;
|
||
const statusKey = artistCacheKey(artistId);
|
||
if (!Array.isArray(logos)) {
|
||
if (
|
||
providerLogoStatusById.get(statusKey) === false
|
||
&& page
|
||
&& active()
|
||
) {
|
||
await fetchCoverLogo(artistId, page, active);
|
||
return { provider: false };
|
||
}
|
||
return { provider: null };
|
||
}
|
||
rememberProviderLogoStatus(artistId, logos.length > 0);
|
||
if (!active()) return { provider: logos.length > 0 };
|
||
const current = catalog.artistPage;
|
||
if (logos.length) {
|
||
if (sameArtistId(current?.artist?.id, artistId)) {
|
||
catalog.artistPage = {
|
||
...current,
|
||
logos: current.logos?.length ? current.logos : logos,
|
||
provider_logos_settled: true,
|
||
cover_logo: null,
|
||
cover_logo_pending: false,
|
||
};
|
||
}
|
||
return { provider: true };
|
||
}
|
||
if (sameArtistId(current?.artist?.id, artistId) && !current.provider_logos_settled) {
|
||
catalog.artistPage = { ...current, provider_logos_settled: true };
|
||
}
|
||
// Do not use the old artist-id-only persisted restore here. Its candidate
|
||
// fingerprint is unavailable, so it could have been generated from the
|
||
// polluted discography we are hardening against. The exact-input request
|
||
// below still hits the backend's versioned memory/disk cache on revisits.
|
||
if (page) await fetchCoverLogo(artistId, { ...page, logos: [] }, active);
|
||
return { provider: false };
|
||
}
|
||
|
||
function mergeCatalogArtistArt(artistId, art, active) {
|
||
if (!sameArtistId(art?.artist?.id, artistId) || !active()) return;
|
||
const current = catalog.artistPage;
|
||
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
|
||
const finalBanners = art.banners ?? [];
|
||
const artLogos = Array.isArray(art.logos) ? art.logos : [];
|
||
const finalLogos = current.logos?.length ? current.logos : artLogos;
|
||
const mergedArtist = {
|
||
...current.artist,
|
||
...art.artist,
|
||
// The completed identity-filtered page owns this count. A late art
|
||
// profile may still carry Deezer's broader/fused total; use it only
|
||
// while the shell has no count at all.
|
||
album_count: current.artist?.album_count ?? art.artist?.album_count ?? null,
|
||
};
|
||
rememberArtistSeed(mergedArtist);
|
||
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
|
||
catalog.artistPage = {
|
||
...current,
|
||
artist: mergedArtist,
|
||
banner: art.banner ?? null,
|
||
banners: finalBanners,
|
||
banner_custom: art.banner_custom ?? false,
|
||
logos: finalLogos,
|
||
provider_logos_settled: true,
|
||
cover_logo: finalLogos.length ? null : current.cover_logo ?? null,
|
||
cover_logo_pending: finalLogos.length ? false : current.cover_logo_pending ?? false,
|
||
hero_seed_banner: finalBanners.length ? null : current.hero_seed_banner,
|
||
loading_shell: false,
|
||
placeholder_picture:
|
||
current.placeholder_picture ?? art.artist?.picture ?? null,
|
||
};
|
||
}
|
||
|
||
/** Fire the deferred cover-logo extraction for an artist with no provider logo,
|
||
* merging the result into its own page field. Backend-cached, so ~instant on a
|
||
* revisit. Called from BOTH the cold-load and warm-cache paths of
|
||
* openCatalogArtist — the warm path early-returns and would otherwise skip it. */
|
||
/** Lower ranks first. Studio-album covers print the bare band name; singles,
|
||
* EPs and anything with a remix/live/mix in the title print tracklists and
|
||
* credits — poison for a wordmark. So OCR the clean covers first. */
|
||
function albumLogoRank(a) {
|
||
const type = a.record_type ?? "";
|
||
const typeRank = type === "album" ? 0 : type === "ep" ? 2 : 3;
|
||
const noisy = /\b(remix|remixes|mix|live|edit|dub|version|bonus|instrumental)\b/i.test(
|
||
a.title ?? "",
|
||
)
|
||
? 1
|
||
: 0;
|
||
return typeRank + noisy;
|
||
}
|
||
|
||
// Must not undercut the backend's own cap (COVER_LOGO_MAX_COVERS): whatever is
|
||
// trimmed here the extractor can never see. A prolific artist's wordmark is
|
||
// frequently on a low-ranked remix single — all seven of Poolside's readable
|
||
// covers sat past position 14 — and the backend stops early once it has enough
|
||
// hits, so a longer list costs nothing for artists whose top covers work.
|
||
function coverLogoCandidates(page) {
|
||
return [...(page.albums ?? [])]
|
||
.sort((a, b) => albumLogoRank(a) - albumLogoRank(b))
|
||
.map((a) => ({ title: a.title ?? "", url: a.cover }))
|
||
.filter((c) => c.url)
|
||
.slice(0, 40);
|
||
}
|
||
|
||
function coverLogoInputKey(artistId, page, covers) {
|
||
const idKey = artistCacheKey(artistId);
|
||
const artistKey = artistIdentityKey(page.artist?.name);
|
||
const candidates = covers
|
||
.map(({ title, url }) => `${title}\u0001${url}`)
|
||
.join("\u0002");
|
||
return `${idKey}\u0000${artistKey}\u0000${candidates}`;
|
||
}
|
||
|
||
function readCoverLogoForPage(artistId, page) {
|
||
if (providerLogoStatusById.get(artistCacheKey(artistId)) !== false) return null;
|
||
const covers = coverLogoCandidates(page);
|
||
if (!covers.length) return null;
|
||
const value = touchCache(coverLogoByInput, coverLogoInputKey(artistId, page, covers));
|
||
return typeof value === "string" ? value : null;
|
||
}
|
||
|
||
function coverLogoRequest(artistId, page, covers) {
|
||
const inputKey = coverLogoInputKey(artistId, page, covers);
|
||
if (coverLogoByInput.has(inputKey)) {
|
||
return Promise.resolve(touchCache(coverLogoByInput, inputKey));
|
||
}
|
||
const pending = api
|
||
.catalogCoverLogo(artistId, page.artist?.name ?? "", covers)
|
||
.then(
|
||
(dataUrl) => {
|
||
if (dataUrl != null && typeof dataUrl !== "string") {
|
||
throw new Error("cover logo returned an invalid response");
|
||
}
|
||
const result = dataUrl ?? null;
|
||
if (coverLogoByInput.get(inputKey) === pending) {
|
||
setResolvedCache(
|
||
coverLogoByInput,
|
||
inputKey,
|
||
result,
|
||
ARTIST_LOGO_CACHE_MAX,
|
||
ARTIST_LOGO_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return result;
|
||
},
|
||
(error) => {
|
||
if (coverLogoByInput.get(inputKey) === pending) {
|
||
coverLogoByInput.delete(inputKey);
|
||
}
|
||
throw error;
|
||
},
|
||
);
|
||
coverLogoByInput.set(inputKey, pending);
|
||
trimResolvedCache(
|
||
coverLogoByInput,
|
||
ARTIST_LOGO_CACHE_MAX,
|
||
ARTIST_LOGO_CACHE_MAX_CHARS,
|
||
);
|
||
return pending;
|
||
}
|
||
|
||
async function fetchCoverLogo(artistId, page, active) {
|
||
const statusKey = artistCacheKey(artistId);
|
||
if (
|
||
!sameArtistId(page?.artist?.id, artistId)
|
||
|| page.logos?.length
|
||
|| providerLogoStatusById.get(statusKey) !== false
|
||
|| !active()
|
||
) {
|
||
return null;
|
||
}
|
||
const covers = coverLogoCandidates(page);
|
||
if (!covers.length) return null;
|
||
|
||
const current = catalog.artistPage;
|
||
if (
|
||
sameArtistId(current?.artist?.id, artistId)
|
||
&& !current.cover_logo_pending
|
||
&& !readCoverLogoForPage(artistId, page)
|
||
) {
|
||
catalog.artistPage = { ...current, cover_logo_pending: true };
|
||
}
|
||
try {
|
||
const dataUrl = await coverLogoRequest(artistId, page, covers);
|
||
if (!dataUrl || !active()) return dataUrl;
|
||
const latest = catalog.artistPage;
|
||
if (
|
||
!sameArtistId(latest?.artist?.id, artistId)
|
||
|| latest.logos?.length
|
||
|| providerLogoStatusById.get(statusKey) !== false
|
||
) {
|
||
return dataUrl;
|
||
}
|
||
// Its OWN field, never `logos`: the provider-art pass sets `logos`
|
||
// (often to []) and would otherwise clobber it. The view prefers
|
||
// provider `logos` and falls back to this.
|
||
catalog.artistPage = { ...latest, cover_logo: dataUrl };
|
||
return dataUrl;
|
||
} catch {
|
||
return null;
|
||
} finally {
|
||
const latest = catalog.artistPage;
|
||
if (
|
||
active()
|
||
&& sameArtistId(latest?.artist?.id, artistId)
|
||
&& latest.cover_logo_pending
|
||
) {
|
||
catalog.artistPage = { ...latest, cover_logo_pending: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
/** MusicBrainz "last pass" for an open artist page. MB is rate-limited to
|
||
* 1 req/s by policy, which used to cost the release grid ~2-3s; Apple's
|
||
* unthrottled artist lookup now supplies the release supplement, so MB runs
|
||
* AFTER the page is on screen and merges in whenever it lands. Deliberately
|
||
* additive-only: it contributes the socials and the few deep cuts (practice
|
||
* tapes, BBC sessions) neither Deezer nor Apple carries, and never edits a
|
||
* record those two already own.
|
||
*
|
||
* Two failure modes this function owns:
|
||
* - MB can 503 under per-IP contention. A fully empty result gets one
|
||
* delayed retry — failures are not cached backend-side, so it is real.
|
||
* - The session page cache used to hold the RAW backend page forever, and
|
||
* the cached fast path skipped this pass entirely — so a warm revisit
|
||
* silently LOST the supplement it had on the cold visit ("A ? of WHEN"
|
||
* vanishing from Panda Bear). The merged page is now written back into
|
||
* the cache and stamped `identity_merged`, and the fast path re-runs this
|
||
* pass for cached pages that lack the stamp. */
|
||
async function applyArtistIdentity(artistId, active) {
|
||
try {
|
||
const name = catalog.artistPage?.artist?.name ?? "";
|
||
let identity = await api.catalogArtistIdentity(artistId, name);
|
||
// Socials are a separate deferred command now, so the identity
|
||
// payload's socials are ALWAYS empty — they can no longer prove "MB
|
||
// knows this artist" here, and waiting for the retry below would
|
||
// delay the footer links 8 s for artists MB carries without release
|
||
// groups. Fire them independently before the retry decision.
|
||
void applyArtistSocials(artistId, active);
|
||
const empty = !(identity?.albums?.length || identity?.type_hints?.length);
|
||
if (empty) {
|
||
// Transient-failure retry. For a genuinely album-less MB artist
|
||
// both calls answer from the URL caches, so the second pass
|
||
// costs no MusicBrainz traffic — only this deliberate delay.
|
||
await new Promise((resolve) => setTimeout(resolve, 8000));
|
||
if (!active()) return;
|
||
identity = await api.catalogArtistIdentity(artistId, name);
|
||
}
|
||
if (!active()) return;
|
||
const current = catalog.artistPage;
|
||
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
|
||
const merged = mergeArtistIdentity(current, identity);
|
||
catalog.artistPage = merged;
|
||
if (merged.fused_record) void disownFusedPortrait(artistId, merged);
|
||
// The wordmark is lifted from THESE albums' covers, and this pass is
|
||
// fired beside the one above rather than after it — so on a fused record
|
||
// it normally wins the race and reads the unfiltered list. Wand's page
|
||
// wore a "wand." lifted from an Indonesian co-tenant's single that way.
|
||
// Re-run whenever ownership actually changed the covers on offer; the
|
||
// request is keyed by the exact list, so an unchanged one is a cache hit
|
||
// and costs nothing.
|
||
const coversChanged =
|
||
(current.albums ?? []).length !== merged.albums.length
|
||
|| (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover);
|
||
if (coversChanged && !merged.logos?.length) {
|
||
void resolveCoverLogoAvailability(
|
||
artistId,
|
||
merged,
|
||
Promise.resolve(merged.logos ?? []),
|
||
active,
|
||
);
|
||
}
|
||
// Write the supplemented page back into the session cache: a warm
|
||
// revisit must not render less than the cold visit did. Overwriting
|
||
// a pending entry is safe — cachedArtistRequest only installs its
|
||
// own result while its promise is still the stored value.
|
||
const key = artistCacheKey(artistId);
|
||
if (key) {
|
||
catalogArtistPageCache.delete(key);
|
||
catalogArtistPageCache.set(key, merged);
|
||
trimArtistRequestCache(catalogArtistPageCache);
|
||
}
|
||
savePageReplay(artistId, merged);
|
||
} catch {
|
||
// Best-effort supplement: the page is complete without it.
|
||
}
|
||
}
|
||
|
||
/** The identity merge itself, as a pure function of the page and the identity
|
||
* payload.
|
||
*
|
||
* Extracted so the Now Playing panel can run the SAME merge the artist page
|
||
* runs. It used to live inside `applyArtistIdentity`, which only the artist
|
||
* page calls — so the panel showed the raw Deezer list: My Bloody Valentine
|
||
* appeared with four releases because Loveless and Isn't Anything exist only
|
||
* as MusicBrainz supplements, and Weezer filed differently because the panel
|
||
* never received `type_hints` either. Both surfaces read one implementation
|
||
* now; a second copy would only guarantee they disagree in new ways.
|
||
*/
|
||
function mergeArtistIdentity(current, identity) {
|
||
// MB identity extras are always albums, so only rows that could BE
|
||
// that album may block one: a same-title SINGLE is a different
|
||
// release (the "Phantom Island" shape — a 2024 single and a 2025
|
||
// album), and letting it occupy the title made the real album vanish
|
||
// from the page.
|
||
const seen = new Set(
|
||
(current.albums ?? [])
|
||
.filter((a) => a.record_type !== "single")
|
||
.map((a) => normText(a.title)),
|
||
);
|
||
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
|
||
// An MB row whose title is a PREFIX of an existing row's full title
|
||
// is the same work Deezer carries as a qualified variant —
|
||
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
|
||
// (Deluxe)". Injecting it adds a second card with no track count.
|
||
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
|
||
// distinct: a bare numeral/volume marker never counts as a variant
|
||
// qualifier. normText already strips brackets, so compare against
|
||
// the raw title normalized without stripping.
|
||
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
// Same single-typed exemption as `seen`: only album-like rows may
|
||
// claim to be a variant of an MB album.
|
||
const existingRaw = (current.albums ?? [])
|
||
.filter((a) => a.record_type !== "single")
|
||
.map((a) => rawNorm(a.title));
|
||
const sequelTail = (tail) =>
|
||
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
|
||
tail.trim(),
|
||
);
|
||
const coveredByVariant = (title) => {
|
||
const base = rawNorm(title);
|
||
if (base.length < 8) return false;
|
||
return existingRaw.some(
|
||
(t) =>
|
||
t.length > base.length
|
||
&& t.startsWith(base)
|
||
&& !sequelTail(t.slice(base.length)),
|
||
);
|
||
};
|
||
// `normText` DELETES bracket contents, which is right for peer file
|
||
// matching and wrong here: MusicBrainz writes a qualifier after a colon
|
||
// where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced
|
||
// to "worldwar3" while MB's "World War 3: Lean" reduced to
|
||
// "worldwar3lean" and the two never collided. The result was a second
|
||
// card for a record already on the page — and once mixtapes had a tab
|
||
// of their own, the same work appeared in Albums AND Mixtapes.
|
||
//
|
||
// This key keeps the qualifier and instead folds the three things the
|
||
// providers genuinely disagree about: the separator, the words
|
||
// introducing a number, and how that number is written. Measured on
|
||
// Gucci Mane it collapses ten such pairs, among them
|
||
// "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five",
|
||
// "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" /
|
||
// "Trap House Four".
|
||
//
|
||
// Single-letter roman numerals are deliberately NOT folded: "X", "V"
|
||
// and "I" are real album titles, and reading them as 10, 5 and 1 would
|
||
// merge unrelated records.
|
||
const ORDINALS = {
|
||
one: "1", two: "2", three: "3", four: "4", five: "5",
|
||
six: "6", seven: "7", eight: "8", nine: "9", ten: "10",
|
||
ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9",
|
||
};
|
||
const releaseKey = (title) =>
|
||
asciiFold(title ?? "")
|
||
.toLowerCase()
|
||
.replace(/[‘’']/g, "")
|
||
.replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ")
|
||
.replace(/[^a-z0-9]+/g, " ")
|
||
.trim()
|
||
.split(" ")
|
||
.map((word) => ORDINALS[word] ?? word)
|
||
.join("");
|
||
// Every album-like row already on the page, by that key, so a matched
|
||
// MB row can be recognised as the one on screen rather than injected
|
||
// beside it.
|
||
// Every row sharing a key, not just the first: a provider sometimes
|
||
// carries the same record twice under its own spellings ("Brick Factory
|
||
// 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put
|
||
// one copy in Mixtapes and leave the other in Albums, which is a worse
|
||
// page than leaving both alone.
|
||
const existingByKey = new Map();
|
||
for (const album of current.albums ?? []) {
|
||
if (album.record_type === "single") continue;
|
||
const key = releaseKey(album.title);
|
||
if (!key) continue;
|
||
if (!existingByKey.has(key)) existingByKey.set(key, []);
|
||
existingByKey.get(key).push(album);
|
||
}
|
||
// A MusicBrainz mixtape that IS a row already on the page contributes
|
||
// its TYPE, not a card. The Deezer row wins the slot because it is the
|
||
// playable one — it carries a real album id and tracklist — and MB's
|
||
// Mixtape/Street verdict re-files it, exactly as `type_hints` re-files
|
||
// a mislabelled compilation. That is what moves "World War 3 (Lean)"
|
||
// out of Albums instead of leaving one copy in each tab.
|
||
const adoptedMixtapeIds = new Set();
|
||
const extra = identityAlbums.filter((a) => {
|
||
if (!a?.title) return false;
|
||
const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined;
|
||
if (twins?.length) {
|
||
for (const twin of twins) {
|
||
if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id);
|
||
}
|
||
return false;
|
||
}
|
||
return (
|
||
!seen.has(normText(a.title))
|
||
&& !existingByKey.has(releaseKey(a.title))
|
||
&& !coveredByVariant(a.title)
|
||
);
|
||
});
|
||
const socials = identity?.socials ?? [];
|
||
// MB's authoritative primary/secondary types. The view matches these
|
||
// to displayed releases by title and re-files any the provider
|
||
// mislabeled "album" (compilations, soundtracks…).
|
||
const typeHints = identity?.type_hints ?? [];
|
||
// Guest appearances, dropped only where MusicBrainz can arbitrate.
|
||
//
|
||
// Deezer's /artist/{id}/albums returns records the artist merely
|
||
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
|
||
// Thief's discography that way. The backend flags them (`appears_on`:
|
||
// the release's primary artist is someone else), but that flag ALONE
|
||
// must never delete a row: Deezer marks every contributor "Main", so a
|
||
// guest spot looks exactly like a genuine joint album — Kanye West's
|
||
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
|
||
// erased from his page.
|
||
//
|
||
// MusicBrainz separates them, because it credits a real collaboration
|
||
// under BOTH artists: querying Kanye's own MBID returns "Watch the
|
||
// Throne", while Mid-Air Thief's returns only his two real albums. So a
|
||
// flagged row survives if MB credits it and is dropped if MB does not
|
||
// — and ONLY when MB actually answered for this artist. No MB albums
|
||
// means no arbiter, and every row is kept rather than guessed at, which
|
||
// keeps thin-coverage artists (the obscure end, where this matters
|
||
// most) from losing real releases.
|
||
//
|
||
// The title check alone is not enough, and Kanye is the proof. MB files
|
||
// a collaboration under both parties only when the credit NAMES both
|
||
// ("Watch the Throne"); when the collaboration is its own MB entity it
|
||
// does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS
|
||
// appears anywhere in Ye's 315 release groups. All three were being
|
||
// erased from his page by the very rule written to protect them.
|
||
// `owned_album_ids` is the backend's wider answer to the same question
|
||
// — MB release groups of every type plus the MB-verified Apple
|
||
// catalogue — so a real collaboration survives on evidence instead of
|
||
// on MusicBrainz happening to duplicate the credit.
|
||
//
|
||
// `foreign_album_ids` is the other half: releases that belong to a
|
||
// DIFFERENT act sharing this Deezer artist record. Deezer files several
|
||
// unrelated performers under one id (there is exactly one Deezer
|
||
// "Wand", and it holds an LA psych band, a Brazilian funk MC, an
|
||
// Indonesian act and four more), which no amount of search ranking or
|
||
// credit-role reading can undo. The backend only fills this in when it
|
||
// can NAME the co-tenant, so on an ordinary artist it is empty and the
|
||
// page is untouched.
|
||
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
|
||
const owned = new Set(identity?.owned_album_ids ?? []);
|
||
const foreign = new Set(identity?.foreign_album_ids ?? []);
|
||
const ownAlbums = (current.albums ?? [])
|
||
.filter((a) => {
|
||
if (foreign.has(a?.id)) return false;
|
||
if (!a?.appears_on) return true;
|
||
if (owned.has(a?.id)) return true;
|
||
// No MB albums = no arbiter = keep everything, so thin-coverage
|
||
// artists never lose real releases.
|
||
return identityAlbums.length ? mbTitles.has(normText(a.title)) : true;
|
||
})
|
||
// Rows MusicBrainz typed Mixtape/Street under a title Deezer spells
|
||
// differently. A new object rather than a mutation: `current.albums`
|
||
// is shared with the page replay cache and with whatever is already
|
||
// rendered, and this must not edit either in place.
|
||
.map((a) =>
|
||
adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a,
|
||
);
|
||
const merged = {
|
||
...current,
|
||
identity_merged: true,
|
||
// A fused record's Deezer PORTRAIT is untrustworthy for exactly the
|
||
// reason its releases are: it is whichever co-tenant uploaded last.
|
||
// Wand's is a different artist entirely, and `deezerPortraitMissing`
|
||
// cannot catch it — it is a real photograph, just not of this band.
|
||
fused_record: !!identity?.fused_record,
|
||
albums: extra.length
|
||
? [...ownAlbums, ...extra].sort((a, b) =>
|
||
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
|
||
: ownAlbums,
|
||
socials: socials.length ? socials : current.socials,
|
||
typeHints: typeHints.length ? typeHints : current.typeHints,
|
||
};
|
||
return merged;
|
||
}
|
||
|
||
/** Deferred footer links: merged whenever they arrive, never blocking the
|
||
* identity pass. Cached back into the session page cache like the identity
|
||
* merge, so a warm revisit keeps its links. */
|
||
async function applyArtistSocials(artistId, active) {
|
||
try {
|
||
const socials = await api.catalogArtistSocials(artistId);
|
||
if (!socials?.length || !active()) return;
|
||
const current = catalog.artistPage;
|
||
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
|
||
if (current.socials?.length) return;
|
||
const merged = { ...current, socials };
|
||
catalog.artistPage = merged;
|
||
const key = artistCacheKey(artistId);
|
||
if (key) {
|
||
catalogArtistPageCache.delete(key);
|
||
catalogArtistPageCache.set(key, merged);
|
||
trimArtistRequestCache(catalogArtistPageCache);
|
||
}
|
||
savePageReplay(artistId, merged);
|
||
} catch {
|
||
// Footer links are decorative; missing them is fine.
|
||
}
|
||
}
|
||
|
||
export async function openCatalogArtist(artistId, seedArtist = null) {
|
||
cancelCatalogArtistWarmup();
|
||
captureScroll();
|
||
catalogSearchRequest += 1;
|
||
const request = ++artistPageRequest;
|
||
const artistKey = artistCacheKey(artistId);
|
||
if (!artistKey) return;
|
||
// After the bail-out above, so a request we never act on cannot scroll the
|
||
// page the reader is still on.
|
||
catalog.artistOpenSeq += 1;
|
||
activateCoverUpgradeScope(artistId);
|
||
ui.view = "catalogArtist";
|
||
recordVisit({ view: "catalogArtist", label: seedArtist?.name ?? "Artist", artistId, art: seedArtist?.picture ?? null, round: true });
|
||
const active = () =>
|
||
request === artistPageRequest
|
||
&& ui.view === "catalogArtist"
|
||
&& sameArtistId(catalog.artistPage?.artist?.id, artistId);
|
||
const providedSeed = sameArtistId(seedArtist?.id, artistId)
|
||
? rememberArtistSeed(seedArtist)
|
||
: null;
|
||
const remembered =
|
||
providedSeed
|
||
?? touchCache(artistSeedById, artistKey)
|
||
?? null;
|
||
const providerLogosReady = getCatalogArtistProviderLogos(artistId).catch(() => null);
|
||
const loadFullArtistArt = () =>
|
||
getCatalogArtistIdentityArt(artistId)
|
||
.then((art) => mergeCatalogArtistArt(artistId, art, active))
|
||
.catch(() => {});
|
||
let completed = resolvedArtistCacheValue(catalogArtistPageCache, artistId);
|
||
if (completed && !sameArtistId(completed.artist?.id, artistId)) {
|
||
catalogArtistPageCache.delete(artistKey);
|
||
completed = null;
|
||
}
|
||
if (completed) {
|
||
rememberArtistSeed(completed.artist);
|
||
if (completed.logos?.length) rememberProviderLogoStatus(artistId, true);
|
||
catalog.artistPage = {
|
||
...completed,
|
||
art_preview: false,
|
||
loading_shell: false,
|
||
partial_error: null,
|
||
hero_seed_banner: null,
|
||
// A positive curated set is identity-safe to reuse. An earlier
|
||
// negative is not: wait for this generation's short-TTL provider
|
||
// request before authorizing any cover-derived fallback.
|
||
provider_logos_settled: Boolean(completed.logos?.length),
|
||
cover_logo: null,
|
||
placeholder_picture: remembered?.picture ?? completed.artist?.picture ?? null,
|
||
};
|
||
catalog.artistLoading = false;
|
||
catalog.artistStageError = "";
|
||
catalog.error = "";
|
||
void resolveCoverLogoAvailability(artistId, completed, providerLogosReady, active);
|
||
void loadFullArtistArt();
|
||
// A cached page from before the MB pass landed (or from a visit
|
||
// where it failed) is missing its supplement — the fast path must
|
||
// not render less than the cold path did.
|
||
if (!completed.identity_merged) void applyArtistIdentity(artistId, active);
|
||
else if (!completed.socials?.length) void applyArtistSocials(artistId, active);
|
||
return;
|
||
}
|
||
// The search portrait is a small square image — it must never enter the
|
||
// real hero-source list (banner/banners feed the carousel and 4K finisher).
|
||
// It stays a presentation-only placeholder threaded through every state.
|
||
const seedPicture = remembered?.picture ?? null;
|
||
const replay = loadPageReplay(artistId);
|
||
catalog.artistPage = replay
|
||
? {
|
||
// Stale stand-in from the last visit: the final classified album
|
||
// list paints immediately while every cold stage below re-runs
|
||
// and replaces it. Art fields start empty on purpose — the art
|
||
// pipeline owns them.
|
||
...replay,
|
||
logos: [],
|
||
provider_logos_settled: false,
|
||
banner: null,
|
||
banners: [],
|
||
banner_credit: null,
|
||
banner_custom: false,
|
||
art_preview: true,
|
||
loading_shell: false,
|
||
replayed: true,
|
||
partial_error: null,
|
||
hero_seed_banner: null,
|
||
cover_logo: null,
|
||
placeholder_picture: seedPicture ?? replay.artist?.picture ?? null,
|
||
}
|
||
: {
|
||
artist: {
|
||
id: remembered?.id ?? artistId,
|
||
name: remembered?.name ?? "",
|
||
picture: seedPicture,
|
||
fans: remembered?.fans ?? null,
|
||
album_count: remembered?.album_count ?? null,
|
||
},
|
||
logos: [],
|
||
provider_logos_settled: false,
|
||
banner: null,
|
||
banners: [],
|
||
banner_credit: null,
|
||
banner_custom: false,
|
||
popular_tracks: [],
|
||
albums: [],
|
||
top_genre: null,
|
||
socials: [],
|
||
art_preview: true,
|
||
loading_shell: true,
|
||
partial_error: null,
|
||
hero_seed_banner: null,
|
||
placeholder_picture: seedPicture,
|
||
};
|
||
catalog.artistLoading = true;
|
||
catalog.artistStageError = "";
|
||
catalog.error = "";
|
||
|
||
// A persisted positive mark can paint over the identity shell as soon as
|
||
// the fast provider-logo check confirms this artist has no provider logo.
|
||
// Provider artists start their complete artwork stage immediately; a cold
|
||
// no-provider artist reserves the network for cover generation first.
|
||
void resolveCoverLogoAvailability(artistId, null, providerLogosReady, active);
|
||
// Bing discovery is independent of cover-logo OCR. Run both under their own
|
||
// bounded backend limits so neither user-visible asset sits behind the
|
||
// other's multi-second pipeline.
|
||
void loadFullArtistArt();
|
||
|
||
// A single identity-safe source can arrive before the complete provider
|
||
// tier/list. It feeds only the first preview; the final artwork response
|
||
// still owns carousel selection and 4K finishing.
|
||
void getCatalogArtistHeroSeed(artistId)
|
||
.then((seed) => {
|
||
if (!active() || !seed?.banner) return;
|
||
const current = catalog.artistPage;
|
||
if (
|
||
!current
|
||
|| !sameArtistId(current.artist?.id, artistId)
|
||
|| current.banners?.length
|
||
|| current.banner_custom
|
||
) {
|
||
return;
|
||
}
|
||
catalog.artistPage = {
|
||
...current,
|
||
hero_seed_banner: seed.banner,
|
||
banner_custom: seed.banner_custom ?? false,
|
||
loading_shell: false,
|
||
};
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Warm the MusicBrainz identity pass NOW instead of after the full page
|
||
// lands. Its 4-6 requests serialize behind MusicBrainz's mandatory
|
||
// 1 req/s gate (measured 3.7-8.5 s — most of a cold page's
|
||
// time-to-fully-sorted), so starting it beside the page load hides
|
||
// almost all of that behind work that was happening anyway. Fire and
|
||
// forget: applyArtistIdentity later re-asks with the authoritative name
|
||
// and rides the warmed URL caches; results here are deliberately unused.
|
||
let identityWarmed = false;
|
||
const warmArtistIdentity = (name) => {
|
||
if (identityWarmed || !name) return;
|
||
identityWarmed = true;
|
||
void api.catalogArtistIdentity(artistId, name).catch(() => {});
|
||
};
|
||
warmArtistIdentity(remembered?.name);
|
||
|
||
// One-round-trip content stage: popular tracks and the first curated
|
||
// releases become usable while exhaustive classification continues.
|
||
void getCatalogArtistOverview(artistId)
|
||
.then((overview) => {
|
||
warmArtistIdentity(overview?.artist?.name);
|
||
if (!active()) return;
|
||
const current = catalog.artistPage;
|
||
if (
|
||
!current
|
||
|| !sameArtistId(current.artist?.id, artistId)
|
||
|| current.art_preview === false
|
||
|| !sameArtistId(overview.artist?.id, artistId)
|
||
) return;
|
||
rememberArtistSeed(overview.artist);
|
||
catalog.artistPage = {
|
||
...current,
|
||
artist: { ...current.artist, ...overview.artist },
|
||
// A replayed page already shows the last visit's FINAL
|
||
// classified albums; the overview's raw first-50 would be a
|
||
// strict downgrade. Fresh albums arrive with the full page.
|
||
...(current.replayed
|
||
? {}
|
||
: {
|
||
popular_tracks: overview.popular_tracks ?? [],
|
||
albums: overview.albums ?? [],
|
||
}),
|
||
loading_shell: false,
|
||
placeholder_picture:
|
||
current.placeholder_picture ?? overview.artist?.picture ?? null,
|
||
};
|
||
})
|
||
.catch(() => {});
|
||
|
||
// Popular's fast lane. The overview paints the section immediately (its
|
||
// supplements are budgeted), so a cold page's rows start in Deezer's
|
||
// order with no listener counts. The real list — Last.fm order and
|
||
// counts — needs none of the dates/recordings work the full page also
|
||
// waits for, so it is asked for on its own and patched in the moment it
|
||
// lands (~2s cold, instant warm). Last writer wins against the overview:
|
||
// warm, the two lists are computed from identical inputs and cannot
|
||
// differ. The full page stays authoritative once it arrives, and a
|
||
// replayed page already shows the last visit's FINAL list — patching a
|
||
// pool-shaped list over either would be a downgrade.
|
||
void api.catalogArtistPopular(artistId)
|
||
.then((tracks) => {
|
||
if (!active() || !tracks?.length) return;
|
||
const current = catalog.artistPage;
|
||
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
|
||
if (current.art_preview === false || current.replayed) return;
|
||
catalog.artistPage = { ...current, popular_tracks: tracks };
|
||
})
|
||
.catch(() => {});
|
||
|
||
try {
|
||
// Overview, identity art, and the first hero all remain immediate.
|
||
// Give rapid back-to-back navigation a brief chance to settle before
|
||
// starting the exhaustive release-classification request; stale pages
|
||
// should not consume provider/backend work merely to fill a cache.
|
||
await new Promise((resolve) => setTimeout(resolve, 180));
|
||
if (!active()) return;
|
||
const page = await getCatalogArtistPage(artistId);
|
||
if (active() && sameArtistId(page?.artist?.id, artistId)) {
|
||
rememberArtistSeed(page.artist);
|
||
const staged = catalog.artistPage;
|
||
const stagedHasArt = sameArtistId(staged?.artist?.id, artistId);
|
||
const finalBanners = page.banner_custom
|
||
? page.banners ?? []
|
||
: stagedHasArt && staged?.banners?.length
|
||
? staged.banners
|
||
: page.banners ?? [];
|
||
const finalLogos = stagedHasArt && staged?.logos?.length
|
||
? staged.logos
|
||
: page.logos ?? [];
|
||
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
|
||
catalog.artistPage = {
|
||
...page,
|
||
// A replayed page carries the last visit's MB type hints AND
|
||
// socials; the fresh page always arrives with both empty
|
||
// (socials are a deferred command now), so dropping them here
|
||
// would let filing/footer visibly regress for the moment
|
||
// before the identity/socials passes re-merge them.
|
||
...(stagedHasArt && staged?.typeHints?.length
|
||
? { typeHints: staged.typeHints }
|
||
: {}),
|
||
...(stagedHasArt && staged?.socials?.length
|
||
? { socials: staged.socials }
|
||
: {}),
|
||
banner: page.banner_custom
|
||
? page.banner
|
||
: stagedHasArt
|
||
? staged?.banner ?? page.banner ?? null
|
||
: page.banner ?? null,
|
||
banners: finalBanners,
|
||
logos: finalLogos,
|
||
provider_logos_settled: true,
|
||
art_preview: false,
|
||
loading_shell: false,
|
||
partial_error: null,
|
||
hero_seed_banner:
|
||
finalBanners.length ? null : staged?.hero_seed_banner ?? null,
|
||
placeholder_picture:
|
||
staged?.placeholder_picture ?? page.artist?.picture ?? null,
|
||
cover_logo:
|
||
finalLogos.length
|
||
? null
|
||
: staged?.cover_logo ?? null,
|
||
cover_logo_pending: staged?.cover_logo_pending ?? false,
|
||
};
|
||
// MusicBrainz last pass — shared with the cached fast path, see
|
||
// applyArtistIdentity for the semantics.
|
||
void applyArtistIdentity(artistId, active);
|
||
|
||
// No provider logo for this artist → try to lift a wordmark from the
|
||
// album covers (band name printed as clean text). Deferred, cached,
|
||
// additive-only; on failure the plain text name stays.
|
||
void resolveCoverLogoAvailability(artistId, page, providerLogosReady, active);
|
||
}
|
||
} catch (e) {
|
||
if (active()) {
|
||
void loadFullArtistArt();
|
||
catalog.error = String(e);
|
||
catalog.artistStageError = "Some release details could not be loaded.";
|
||
if (catalog.artistPage) {
|
||
catalog.artistPage = {
|
||
...catalog.artistPage,
|
||
art_preview: false,
|
||
loading_shell: false,
|
||
partial_error: catalog.artistStageError,
|
||
};
|
||
}
|
||
}
|
||
} finally {
|
||
if (request === artistPageRequest) catalog.artistLoading = false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open the artist's whole discography on a page of its own.
|
||
*
|
||
* Reached from the artist page's Discography section, and only from there —
|
||
* the tabs it renders are the ones that page published (see `discoTabs`). If
|
||
* they are missing or belong to somebody else, this lands on the artist page
|
||
* instead, which republishes them and puts "Show all" one click away. That is
|
||
* the honest failure: an empty discography would be a lie about the catalogue.
|
||
*/
|
||
export async function openCatalogDiscography(artistId, seedArtist = null) {
|
||
const artist = seedArtist ?? catalog.artistPage?.artist ?? null;
|
||
if (!catalog.discoTabs || catalog.discoTabs.artistId !== artistId) {
|
||
return openCatalogArtist(artistId, artist);
|
||
}
|
||
captureScroll();
|
||
ui.view = "catalogDiscography";
|
||
recordVisit({
|
||
view: "catalogDiscography",
|
||
label: "Discography",
|
||
artistId,
|
||
artist: artist?.name ?? null,
|
||
glyph: "library",
|
||
});
|
||
}
|
||
|
||
/** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a
|
||
* track to highlight/scroll to (used by the now-playing song link). */
|
||
export async function openCatalogAlbum(albumId, opts = {}) {
|
||
const albumKey = albumCacheKey(albumId);
|
||
if (!albumKey) return;
|
||
captureScroll();
|
||
// A verified-id navigation supersedes any still-running name search.
|
||
albumResolveRequest += 1;
|
||
const request = ++albumPageRequest;
|
||
ui.view = "catalogAlbum";
|
||
// `keepCurrent` holds the page that is already on screen until the new one
|
||
// lands, instead of clearing to the loading state. Switching between two
|
||
// editions of the same record is a swap, not a navigation — blanking the
|
||
// whole page for it reads as a flash. The album view crossfades on top of
|
||
// this; every other caller still gets the plain loading state.
|
||
if (!opts.keepCurrent) {
|
||
catalog.albumPage = null;
|
||
catalog.albumLoading = true;
|
||
// Only on a real navigation. An edition swap holds the page it is
|
||
// already showing, so seeding a skeleton it will never render would
|
||
// just leave stale artwork behind for the next navigation to flash.
|
||
catalog.albumSeed = opts.seed ?? null;
|
||
}
|
||
catalog.pendingTrackId = opts.trackId ?? null;
|
||
catalog.error = "";
|
||
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
||
try {
|
||
const page = await api.catalogAlbum(albumId);
|
||
if (!sameAlbumId(page?.album?.id, albumKey)) {
|
||
throw new Error("catalog album returned a mismatched album identity");
|
||
}
|
||
if (!active()) return;
|
||
catalog.albumPage = page;
|
||
recordVisit({
|
||
view: "catalogAlbum",
|
||
label: page.album.title,
|
||
albumId: page.album.id,
|
||
art: page.album.cover ?? null,
|
||
artist: page.album.artist ?? null,
|
||
});
|
||
// The artist crumb the trail synthesises for a DIRECT arrival needs
|
||
// the portrait and id, and the album payload carries only the name
|
||
// (metadata::CatalogAlbum) — so a reader who skipped the artist page
|
||
// got a bare disc glyph in the breadcrumb (Kyle, 2026-08-30). The
|
||
// seed resolver shares the album header's own cache and in-flight
|
||
// dedupe, so this is the same lookup the page mounting already pays
|
||
// for; when it lands, the crumb is updated in place (same key) and
|
||
// trail() reads artistArt/artistId out of it. Also heals the crumb's
|
||
// CLICK, which had artistId null on every direct arrival.
|
||
void resolveArtistSeedByName(page.album.artist).then((seed) => {
|
||
if (!seed || !active()) return;
|
||
recordVisit({
|
||
view: "catalogAlbum",
|
||
label: page.album.title,
|
||
albumId: page.album.id,
|
||
art: page.album.cover ?? null,
|
||
artist: page.album.artist ?? null,
|
||
artistId: seed.id ?? null,
|
||
artistArt: seed.picture ?? null,
|
||
});
|
||
});
|
||
// No catalog trackId (e.g. local playback) — match the song by title.
|
||
if (catalog.pendingTrackId == null && opts.trackTitle) {
|
||
const t = page.tracks.find(
|
||
(tr) => catNorm(tr.title) === catNorm(opts.trackTitle),
|
||
);
|
||
if (t) catalog.pendingTrackId = t.id;
|
||
}
|
||
} catch (e) {
|
||
if (active()) {
|
||
catalog.error = String(e);
|
||
// A held page would swallow the failure — the old edition stays up
|
||
// and the click looks like it did nothing. Clear so the error is
|
||
// the thing on screen, exactly as a normal navigation would.
|
||
if (opts.keepCurrent) catalog.albumPage = null;
|
||
}
|
||
} finally {
|
||
if (request === albumPageRequest) {
|
||
catalog.albumLoading = false;
|
||
// The real page owns the artwork from here. Held any longer, the
|
||
// seed would still be on screen behind the next navigation's first
|
||
// frame, showing the previous record's cover under a new skeleton.
|
||
catalog.albumSeed = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open the album a preview-modal item declares, landing on the track that was
|
||
* being previewed.
|
||
*
|
||
* The record comes from the item's own provider `albumId` — never a title
|
||
* match — so a top-tracks preview lands on the single or compilation the
|
||
* modal's metadata line was already naming, not on a same-titled record.
|
||
*
|
||
* The conversion is the reason this is one function rather than a lambda at
|
||
* each call site: `contracts.js` freezes every manifest id as a STRING, while
|
||
* `catalog_album` takes a `u64` (a JSON string fails to deserialize) and the
|
||
* album page's flash compares track ids with `===`. Both cross back to numbers
|
||
* here, once.
|
||
*/
|
||
export function openAlbumFromPreviewItem(item) {
|
||
const albumId = Number(item?.albumId);
|
||
if (!Number.isFinite(albumId)) return;
|
||
const trackId = Number(item?.sources?.[0]?.providerTrackId);
|
||
return openCatalogAlbum(albumId, {
|
||
trackId: Number.isFinite(trackId) ? trackId : null,
|
||
});
|
||
}
|
||
|
||
const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||
|
||
/** Resolve an artist by name (cached) and open their catalog page — used by
|
||
* the now-playing artist link, which only knows the name. */
|
||
/** Resolve an artist NAME to their catalog seed — `{ id, name, picture, fans,
|
||
* album_count }` — or null when no artist by that name exists.
|
||
*
|
||
* Shares the very caches `openArtistByName` fills, so a page that shows an
|
||
* artist's portrait beside a name has already paid for the click that
|
||
* navigates to them, and vice versa. In-flight lookups are deduped by name:
|
||
* an album page mounting can otherwise fire the same search its own back
|
||
* button is about to.
|
||
*/
|
||
const artistSeedRequests = new Map(); // normalized name → Promise<seed|null>
|
||
export function resolveArtistSeedByName(name) {
|
||
const norm = (name || "").trim().toLowerCase();
|
||
if (!norm) return Promise.resolve(null);
|
||
const cached = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null;
|
||
if (cached) return Promise.resolve(cached);
|
||
// A remembered miss stays a miss — the negative is cached as a null id.
|
||
if (artistIdCache.has(norm) && touchCache(artistIdCache, norm) == null) {
|
||
return Promise.resolve(null);
|
||
}
|
||
const inflight = artistSeedRequests.get(norm);
|
||
if (inflight) return inflight;
|
||
const promise = (async () => {
|
||
try {
|
||
const res = await api.catalogSearch(name);
|
||
const artists = res.artists ?? [];
|
||
// Same rule as openArtistByName: the most-followed exact match, so
|
||
// a shared name resolves to the canonical artist rather than an
|
||
// obscure namesake the search happened to list first.
|
||
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
|
||
const pool = exact.length ? exact : artists;
|
||
const best = pool.reduce(
|
||
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
|
||
null,
|
||
);
|
||
if (!best) {
|
||
setBoundedMap(artistIdCache, norm, null, ARTIST_IDENTITY_CACHE_MAX);
|
||
return null;
|
||
}
|
||
return rememberArtistSeed(best);
|
||
} catch {
|
||
// Transport failures stay retryable — never negative-cached.
|
||
return null;
|
||
} finally {
|
||
artistSeedRequests.delete(norm);
|
||
}
|
||
})();
|
||
artistSeedRequests.set(norm, promise);
|
||
return promise;
|
||
}
|
||
|
||
export async function openArtistByName(name) {
|
||
const norm = (name || "").trim().toLowerCase();
|
||
if (!norm) return;
|
||
let id = artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined;
|
||
if (id !== undefined) {
|
||
const cachedSeed = artistSeedCache.has(norm)
|
||
? touchCache(artistSeedCache, norm)
|
||
: null;
|
||
if (id != null) openCatalogArtist(id, cachedSeed);
|
||
return;
|
||
}
|
||
|
||
// A name-only Now Playing link still navigates immediately. Identity
|
||
// resolution replaces this shell in place instead of holding the old view
|
||
// behind a catalog search.
|
||
const resolveRequest = ++artistPageRequest;
|
||
ui.view = "catalogArtist";
|
||
catalog.artistLoading = true;
|
||
catalog.error = "";
|
||
catalog.artistStageError = "";
|
||
catalog.artistPage = {
|
||
artist: { id: null, name, picture: null, fans: null, album_count: null },
|
||
logos: [],
|
||
provider_logos_settled: false,
|
||
banner: null,
|
||
banners: [],
|
||
banner_custom: false,
|
||
popular_tracks: [],
|
||
albums: [],
|
||
top_genre: null,
|
||
socials: [],
|
||
art_preview: true,
|
||
loading_shell: true,
|
||
partial_error: null,
|
||
placeholder_picture: null,
|
||
};
|
||
|
||
try {
|
||
const res = await api.catalogSearch(name);
|
||
const artists = res.artists ?? [];
|
||
// Prefer the most-followed match so a shared name ("Pond", "Panda
|
||
// Bear") resolves to the canonical artist, not an obscure namesake
|
||
// that Deezer's search happens to list first.
|
||
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
|
||
const pool = exact.length ? exact : artists;
|
||
const best = pool.reduce(
|
||
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
|
||
null,
|
||
);
|
||
id = best?.id ?? null;
|
||
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
|
||
if (best) rememberArtistSeed(best);
|
||
if (
|
||
id != null
|
||
&& resolveRequest === artistPageRequest
|
||
&& ui.view === "catalogArtist"
|
||
) {
|
||
return openCatalogArtist(id, best);
|
||
}
|
||
if (resolveRequest === artistPageRequest && id == null) {
|
||
catalog.error = "Artist not found.";
|
||
catalog.artistPage = null;
|
||
}
|
||
} catch (error) {
|
||
// Only a successful empty search is safe to negative-cache. A
|
||
// transport/provider failure must remain retryable on the next click.
|
||
if (resolveRequest === artistPageRequest) {
|
||
catalog.error = String(error);
|
||
catalog.artistPage = null;
|
||
}
|
||
} finally {
|
||
if (resolveRequest === artistPageRequest) catalog.artistLoading = false;
|
||
}
|
||
}
|
||
|
||
/** Local library albums are rendered by the SAME page as catalog records —
|
||
* there is no second album view. Their ids are namespaced so they can never
|
||
* collide with a Deezer album id in the page cache or an identity check.
|
||
* `openCatalogAlbum` refuses these; they go through `openLibraryAlbum`. */
|
||
const LIB_ALBUM_PREFIX = "lib:";
|
||
export const isLibraryAlbumId = (id) =>
|
||
typeof id === "string" && id.startsWith(LIB_ALBUM_PREFIX);
|
||
export const libraryAlbumIdOf = (id) =>
|
||
isLibraryAlbumId(id) ? Number(id.slice(LIB_ALBUM_PREFIX.length)) : null;
|
||
|
||
/**
|
||
* Open a LOCAL album on the album page. Same view, same shape — a library
|
||
* record simply has no catalog counterpart to fetch, so the page is composed
|
||
* from what the library itself knows: its tracks, their durations and
|
||
* bitrates, and the cover already on disk.
|
||
*
|
||
* The remote-only sections (listeners, editions, prose) are absent rather than
|
||
* empty; the page hides what it has no data for. Playback stays LOCAL — the
|
||
* point of a library record is the file you already have.
|
||
*/
|
||
export async function openLibraryAlbum(albumId, opts = {}) {
|
||
const id = Number(albumId);
|
||
if (!Number.isFinite(id)) return;
|
||
captureScroll();
|
||
// A verified navigation supersedes any still-running name search, exactly
|
||
// as openCatalogAlbum does — otherwise a slow resolve can land on top.
|
||
albumResolveRequest += 1;
|
||
const request = ++albumPageRequest;
|
||
ui.view = "catalogAlbum";
|
||
ui.albumId = null;
|
||
catalog.albumPage = null;
|
||
catalog.albumLoading = true;
|
||
catalog.albumSeed = opts.seed ?? null;
|
||
catalog.error = "";
|
||
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
|
||
try {
|
||
if (!libraryStore.loaded) await refreshLibrary();
|
||
const meta = libraryStore.albums.find((a) => a.id === id) ?? null;
|
||
if (!meta) throw new Error("album is not in your library");
|
||
const [tracks, cover] = await Promise.all([
|
||
api.albumTracks(id),
|
||
getArt(meta.art_path).catch(() => null),
|
||
]);
|
||
if (!active()) return;
|
||
catalog.albumPage = {
|
||
local: true,
|
||
libraryAlbumId: id,
|
||
libraryTracks: tracks,
|
||
artPath: meta.art_path ?? null,
|
||
album: {
|
||
id: `${LIB_ALBUM_PREFIX}${id}`,
|
||
title: meta.title,
|
||
artist: meta.artist,
|
||
cover: cover ?? null,
|
||
release_date: meta.year ? String(meta.year) : null,
|
||
record_type: "album",
|
||
track_count: tracks.length,
|
||
is_soundtrack: false,
|
||
},
|
||
// Shaped like catalog tracks so the tracklist renders unchanged;
|
||
// `path`/`bitrate_kbps` ride along for the local row and playback.
|
||
tracks: tracks.map((t) => ({
|
||
id: `${LIB_ALBUM_PREFIX}t${t.id}`,
|
||
title: t.title,
|
||
artist: t.artist || meta.artist,
|
||
album: meta.title,
|
||
album_id: null,
|
||
cover: cover ?? null,
|
||
duration_secs: t.duration_secs ?? null,
|
||
preview: null,
|
||
track_no: t.track_no ?? null,
|
||
unreleased: false,
|
||
path: t.path,
|
||
bitrate_kbps: t.bitrate_kbps ?? null,
|
||
})),
|
||
album_plays: null,
|
||
album_listeners: null,
|
||
totals_summed: false,
|
||
};
|
||
recordVisit({
|
||
view: "catalogAlbum",
|
||
label: meta.title,
|
||
albumId: `${LIB_ALBUM_PREFIX}${id}`,
|
||
artist: meta.artist,
|
||
art: cover ?? null,
|
||
});
|
||
} catch (error) {
|
||
if (active()) {
|
||
catalog.error = String(error);
|
||
catalog.albumPage = null;
|
||
}
|
||
} finally {
|
||
if (active()) catalog.albumLoading = false;
|
||
}
|
||
}
|
||
|
||
/** Confirmed answers from `openAlbumByName`, keyed on the normalized pair:
|
||
* an album id, or `null` for a record no catalog has. Both are worth keeping —
|
||
* a local-only album misses every time, and remembering the miss is what makes
|
||
* a second click instant instead of re-running the search. Only SETTLED
|
||
* answers land here; a transport failure stays retryable. */
|
||
const albumNameResolves = new Map();
|
||
|
||
/** Resolve an album by artist + title (via catalog search) and open its page —
|
||
* used by the now-playing album/song links and the sidebar's recently-played
|
||
* rows when there's no catalog albumId (e.g. a local library track, which
|
||
* carries no catalog identity of any kind).
|
||
*
|
||
* Returns the album id it opened, or `null` when nothing matched — callers
|
||
* that have a fallback destination need to know which happened. */
|
||
export async function openAlbumByName(artist, title, opts = {}) {
|
||
const request = ++albumResolveRequest;
|
||
if (opts.albumId != null) {
|
||
openCatalogAlbum(opts.albumId, opts);
|
||
return opts.albumId;
|
||
}
|
||
if (!artist || !title) return null;
|
||
const na = catNorm(artist);
|
||
const nt = catNorm(title);
|
||
// A year hint changes which record the same name resolves to (the
|
||
// self-titled-trio case), so it is part of the answer's identity — a
|
||
// shared key would let one LP's answer poison the others'.
|
||
const key = opts.year != null ? `${na}|${nt}|${opts.year}` : `${na}|${nt}`;
|
||
if (albumNameResolves.has(key)) {
|
||
const id = albumNameResolves.get(key);
|
||
if (id != null) openCatalogAlbum(id, opts);
|
||
return id;
|
||
}
|
||
/** Same-titled rows from a DATED list (the artist's own discography),
|
||
* preferring the hinted year. Self-titled multiples (American Football ×3,
|
||
* Weezer ×7) all normalize to one title, so without a date the first row
|
||
* simply wins — and the first row is the wrong record two times in three. */
|
||
const pickByYear = (albums) => {
|
||
const same = (albums ?? []).filter((al) => catNorm(al.title) === nt);
|
||
if (!same.length) return null;
|
||
if (opts.year != null) {
|
||
const dated = same.find((al) =>
|
||
String(al.release_date ?? "").startsWith(String(opts.year)),
|
||
);
|
||
if (dated) return dated;
|
||
}
|
||
return same[0];
|
||
};
|
||
try {
|
||
const res = await api.catalogSearch(`${artist} ${title}`);
|
||
if (request !== albumResolveRequest) return null;
|
||
// A title-only or first-result fallback can cross into a namesake's
|
||
// catalog. Without a verified id, both artist and title must match.
|
||
const searchMatches = (res.albums ?? []).filter(
|
||
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
|
||
);
|
||
// Deezer's /search/album strips release dates, so a year hint cannot
|
||
// be checked here — and a single hit is no proof of the RIGHT record
|
||
// when namesake editions exist. A unique, unhinted hit keeps the fast
|
||
// path; ambiguity (a hint, or several same-titled hits) falls through
|
||
// to the artist's own dated discography below.
|
||
let match =
|
||
searchMatches.length === 1 && opts.year == null ? searchMatches[0] : null;
|
||
// Deezer's SEARCH index is not the whole discography. The artist page
|
||
// merges a MusicBrainz supplement on top of it, so records Deezer does
|
||
// not carry at all still have a real id, a real release date and full
|
||
// Last.fm figures there — Panda Bear's "A ? of WHEN" is one, and it is
|
||
// exactly what made this resolve give up and fall through to a bare
|
||
// local page. Ask the ARTIST'S OWN discography before concluding the
|
||
// record does not exist; it is the same list the artist page's album
|
||
// expander opens from, and it is already cached whenever that page has
|
||
// been visited.
|
||
if (!match) {
|
||
const seed = await resolveArtistSeedByName(artist);
|
||
if (request !== albumResolveRequest) return null;
|
||
if (seed?.id != null) {
|
||
const page = await getCatalogArtistPage(seed.id).catch(() => null);
|
||
if (request !== albumResolveRequest) return null;
|
||
match = pickByYear(page?.albums);
|
||
// The cached page is only SUPPLEMENTED once the artist page
|
||
// has been visited — cold, it is the raw Deezer list and the
|
||
// MB-only records are still missing. Run the same merge the
|
||
// artist page and the Now Playing panel run, rather than
|
||
// concluding the record does not exist.
|
||
if (!match) {
|
||
const identity = await api
|
||
.catalogArtistIdentity(seed.id, seed.name ?? artist)
|
||
.catch(() => null);
|
||
if (request !== albumResolveRequest) return null;
|
||
if (identity?.albums?.length) {
|
||
const merged = mergeArtistIdentity(
|
||
{
|
||
artist: { name: seed.name ?? artist },
|
||
albums: page?.albums ?? [],
|
||
socials: [],
|
||
typeHints: [],
|
||
},
|
||
identity,
|
||
);
|
||
match = pickByYear(merged?.albums);
|
||
}
|
||
}
|
||
}
|
||
// The discography had nothing to say (unresolvable artist, or the
|
||
// record missing from it) — a bare search hit is still better than
|
||
// a dead click, year-verified or not.
|
||
if (!match) match = searchMatches[0] ?? null;
|
||
}
|
||
// A settled miss is an answer ABOUT THE RECORD: neither the catalog nor
|
||
// the artist's own discography has it. Negative-cache that. A throw
|
||
// below is an answer about the network, and is not cached.
|
||
albumNameResolves.set(key, match ? match.id : null);
|
||
if (!match) return null;
|
||
openCatalogAlbum(match.id, opts);
|
||
return match.id;
|
||
} catch {
|
||
// no album match — leave the view as-is
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Open a row from the recently-played list.
|
||
*
|
||
* Shared, because two surfaces show that list — the sidebar's history and the
|
||
* search page's "Jump back in" — and the rules below are identity rules. A
|
||
* second copy would be a second opinion about which record a history row is,
|
||
* which is exactly the mistake the namesake guard in `openAlbumByName` exists
|
||
* to prevent.
|
||
*/
|
||
export async function openRecentAlbum(a) {
|
||
if (a?.kind === "catalog" && a.catalog_album_id) {
|
||
// The catalog page, not the old library album view — streamed records
|
||
// live in the catalog. The seed paints title/artist/cover immediately
|
||
// instead of flashing a skeleton.
|
||
openCatalogAlbum(a.catalog_album_id, {
|
||
seed: {
|
||
id: a.catalog_album_id,
|
||
title: a.title,
|
||
artist: a.artist,
|
||
cover: a.art_url ?? null,
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
// A library row has no catalog id to open with — local albums carry no
|
||
// catalog identity at all (`albums.mbid` is null and never surfaced) — so
|
||
// the record is resolved by artist + title, where BOTH must match
|
||
// normalized or nothing opens. That namesake guard lives in
|
||
// openAlbumByName and is the reason this can't just take a first hit.
|
||
//
|
||
// Either way it is the SAME album page: a record no catalog has (Panda
|
||
// Bear's "A ? of WHEN", verified live) opens from the library's own data
|
||
// instead. There is no second album view to fall back to.
|
||
if (await openAlbumByName(a.artist, a.title)) return;
|
||
void openLibraryAlbum(a.album_id);
|
||
}
|
||
|
||
/** Save the file(s) you're already streaming into the library — no re-search,
|
||
* no re-source. `scope`: "track" (current) or "album" (all streamed tracks in
|
||
* the queue). Downloads the KNOWN peer file(s) directly (skipping the search
|
||
* that made the old button fail) → tag/organize pipeline. Returns { ok, msg }
|
||
* for a UI toast. */
|
||
export async function saveStream(scope) {
|
||
const items =
|
||
scope === "album"
|
||
? player.queue.filter((it) => it.stream)
|
||
: player.queue[player.queueIndex]?.stream
|
||
? [player.queue[player.queueIndex]]
|
||
: [];
|
||
if (items.length === 0) return { ok: false, msg: "Nothing streaming" };
|
||
try {
|
||
// Cached tracks organize instantly; only un-streamed ones download.
|
||
const [organized, queued] = await api.saveToLibrary(
|
||
items.map((it) => ({
|
||
username: it.stream.username,
|
||
path: it.path,
|
||
size: it.stream.size,
|
||
})),
|
||
);
|
||
let msg;
|
||
if (queued > 0) {
|
||
msg = organized > 0 ? `Saved ${organized} · downloading ${queued}` : `Downloading ${queued}…`;
|
||
} else {
|
||
msg = organized > 1 ? `Saved ${organized} to library` : "Saved to library";
|
||
}
|
||
return { ok: true, msg };
|
||
} catch {
|
||
return { ok: false, msg: "Couldn’t save" };
|
||
}
|
||
}
|
||
|
||
// ---- bulk save of a playlist's stream rows ----------------------------------
|
||
// The playlist page's two collection actions, one job at a time app-wide:
|
||
// "cached" — file every row whose bytes are ALREADY fully in the stream
|
||
// cache into the library. Instant, free, touches no peer.
|
||
// "download" — find a source for every row (download prefs: quality floor,
|
||
// quality priority) and fetch it through save_to_library —
|
||
// cached rows organize free, the rest download the known file.
|
||
// Sequential on purpose: one Soulseek search at a time is the pending-
|
||
// resolver's own anti-flood posture, and a bulk job has no reason to be
|
||
// ruder than playback.
|
||
export const playlistBulk = $state({
|
||
active: false,
|
||
mode: null, // "cached" | "download"
|
||
playlistId: null,
|
||
done: 0,
|
||
total: 0,
|
||
organized: 0, // filed into the library instantly (was fully cached)
|
||
queued: 0, // handed to the download pipeline
|
||
missed: 0, // no complete cache ("cached") / no source found ("download")
|
||
summary: null, // last finished run's one-line result, until dismissed
|
||
});
|
||
let playlistBulkCancel = false;
|
||
export function cancelPlaylistBulk() {
|
||
playlistBulkCancel = true;
|
||
}
|
||
export function dismissPlaylistBulk() {
|
||
playlistBulk.summary = null;
|
||
}
|
||
|
||
export async function runPlaylistBulk(playlistId, rows, mode) {
|
||
if (playlistBulk.active) return false;
|
||
// Stream rows with full identity, deduped — the party-playlist case:
|
||
// the same track twice must not download twice.
|
||
const seen = new Set();
|
||
const want = rows.filter((r) => {
|
||
if (r.kind !== "stream" || !r.artist || !r.title) return false;
|
||
const key = `${normText(r.artist)}|${normText(r.title)}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
playlistBulkCancel = false;
|
||
Object.assign(playlistBulk, {
|
||
active: true,
|
||
mode,
|
||
playlistId,
|
||
done: 0,
|
||
total: want.length,
|
||
organized: 0,
|
||
queued: 0,
|
||
missed: 0,
|
||
summary: null,
|
||
});
|
||
try {
|
||
if (mode === "download") await autoConnect();
|
||
for (const row of want) {
|
||
if (playlistBulkCancel) break;
|
||
try {
|
||
const known = await cachedStreamSourceFor(
|
||
row.track_id ?? null,
|
||
row.artist,
|
||
row.title,
|
||
);
|
||
let src = known
|
||
? { username: known.username, path: known.path, size: known.size }
|
||
: null;
|
||
if (mode === "cached") {
|
||
// Only a byte-complete cache counts — a prefix is not
|
||
// the track, and this mode promised "no downloading".
|
||
const len = src ? await api.streamCacheLen(src.username, src.path) : null;
|
||
if (!src || !src.size || len !== src.size) {
|
||
playlistBulk.missed += 1;
|
||
continue;
|
||
}
|
||
} else if (!src) {
|
||
const found = await resolveStreamSourceFor(
|
||
{
|
||
trackId: row.track_id ?? null,
|
||
artist: row.artist,
|
||
title: row.title,
|
||
album: row.album ?? null,
|
||
duration: row.duration_secs ?? null,
|
||
},
|
||
9000,
|
||
false,
|
||
);
|
||
if (!found) {
|
||
playlistBulk.missed += 1;
|
||
continue;
|
||
}
|
||
src = {
|
||
username: found.stream.username,
|
||
path: found.path,
|
||
size: found.stream.size,
|
||
};
|
||
}
|
||
const [organized, queued] = await api.saveToLibrary([src]);
|
||
playlistBulk.organized += organized;
|
||
playlistBulk.queued += queued;
|
||
if (organized === 0 && queued === 0) playlistBulk.missed += 1;
|
||
} catch {
|
||
playlistBulk.missed += 1;
|
||
} finally {
|
||
playlistBulk.done += 1;
|
||
}
|
||
}
|
||
const parts = [];
|
||
if (playlistBulk.organized)
|
||
parts.push(`${playlistBulk.organized} saved to library`);
|
||
if (playlistBulk.queued) parts.push(`${playlistBulk.queued} downloading`);
|
||
if (playlistBulk.missed)
|
||
parts.push(
|
||
`${playlistBulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`,
|
||
);
|
||
if (playlistBulkCancel) parts.push("stopped early");
|
||
playlistBulk.summary = parts.length ? parts.join(" · ") : "Nothing to do";
|
||
if (playlistBulk.organized > 0) void refreshLibrary();
|
||
} finally {
|
||
playlistBulk.active = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||
function currentArtistAlbum() {
|
||
const item = player.queue[player.queueIndex] ?? null;
|
||
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
|
||
// so its own fields are the whole identity — splitting would offer the
|
||
// playlist name as an artist (and negative-cache the click into silence).
|
||
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
|
||
const sub = player.track?.sub ?? "";
|
||
const split = sub.includes(" — ")
|
||
? sub.split(" — ").map((s) => s.trim())
|
||
: [sub.trim(), ""];
|
||
// Per field, the item's own artist/album win — the player bar's rule.
|
||
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
|
||
}
|
||
|
||
/** Anchor-exact album jump for a row that knows its catalog track id but not
|
||
* its record (playlist stream rows from before commits stored albums): one
|
||
* name search, then the ID picks the track — never a name guess. */
|
||
async function openAlbumForCurrentTrackId(opts = {}) {
|
||
const item = player.queue[player.queueIndex] ?? null;
|
||
const trackId = item?.trackId;
|
||
if (trackId == null || !item?.artist || !item?.title) return false;
|
||
try {
|
||
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
|
||
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
|
||
if (hit?.album_id != null) {
|
||
openCatalogAlbum(hit.album_id, { trackId, ...opts });
|
||
return true;
|
||
}
|
||
} catch {
|
||
// fall through to the name path
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
|
||
export function openCurrentArtist() {
|
||
const [artist] = currentArtistAlbum();
|
||
if (artist) openArtistByName(artist);
|
||
}
|
||
export async function openCurrentAlbum() {
|
||
if (player.catalogRef.albumId != null) {
|
||
openCatalogAlbum(player.catalogRef.albumId);
|
||
return;
|
||
}
|
||
if (await openAlbumForCurrentTrackId()) return;
|
||
const [artist, album] = currentArtistAlbum();
|
||
if (album) openAlbumByName(artist, album);
|
||
}
|
||
export async function openCurrentSong() {
|
||
const trackTitle = player.track?.title;
|
||
if (player.catalogRef.albumId != null) {
|
||
openCatalogAlbum(player.catalogRef.albumId, {
|
||
trackId: player.catalogRef.trackId,
|
||
trackTitle,
|
||
});
|
||
return;
|
||
}
|
||
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
|
||
const [artist, album] = currentArtistAlbum();
|
||
if (album) openAlbumByName(artist, album, { trackTitle });
|
||
}
|
||
|
||
// ---- Now Playing discovery (more-from-artist / fans-also-like / about) ----
|
||
|
||
export const discovery = $state({
|
||
artist: null, // normalized name currently loaded (guards against races)
|
||
loading: false,
|
||
artistId: null,
|
||
pageLoaded: false,
|
||
pageWanted: false,
|
||
picture: null,
|
||
fans: null,
|
||
albumCount: null,
|
||
albums: [], // discography → "More from"
|
||
related: [], // "Fans also like"
|
||
bio: null, // Wikipedia summary → "About the artist"
|
||
// Declared links for the artist ({ kind, url }), already fetched as part of
|
||
// the artist page below — kept rather than discarded so the Now Playing
|
||
// panel can show the same chips the artist page does.
|
||
socials: [],
|
||
// MusicBrainz's authoritative primary/secondary types, merged in by
|
||
// `applyDiscoveryIdentity`. The panel files its releases by these exactly
|
||
// as the artist page does; without them the same discography sorted two
|
||
// different ways depending on which surface you were looking at.
|
||
typeHints: [],
|
||
});
|
||
|
||
const bioCache = new Map(); // normalized name → bio text (or null)
|
||
const BIO_CACHE_MAX = 64;
|
||
|
||
/** Deezer's "this artist has no photo" portrait is the md5 of the EMPTY
|
||
* STRING. Crucially the API still returns a normal-looking per-artist hash
|
||
* that merely REDIRECTS there, and the placeholder has the same dimensions as
|
||
* a real portrait — so only the redirect target identifies it. The response's
|
||
* final URL is readable (the CDN sends CORS headers), and the fetch warms the
|
||
* same image the card is about to display. */
|
||
const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e";
|
||
const portraitProbeCache = new Map(); // picture url → is-placeholder
|
||
async function deezerPortraitMissing(url) {
|
||
if (!url) return true;
|
||
if (portraitProbeCache.has(url)) return touchCache(portraitProbeCache, url);
|
||
let missing = false;
|
||
try {
|
||
const response = await fetch(url);
|
||
missing = response.ok && response.url.includes(DEEZER_EMPTY_PICTURE_HASH);
|
||
} catch {
|
||
// Offline or blocked: keep the portrait rather than guess it away.
|
||
missing = false;
|
||
}
|
||
setBoundedMap(portraitProbeCache, url, missing, 64);
|
||
return missing;
|
||
}
|
||
|
||
/** Replace the portrait of an artist whose Deezer record was proven to hold
|
||
* more than one act.
|
||
*
|
||
* `deezerPortraitMissing` above catches the grey silhouette. It cannot catch
|
||
* this: a fused record's `picture` is a genuine photograph at genuine
|
||
* dimensions of a genuine artist — just not the one the page is about. Wand's
|
||
* is the co-tenant who uploaded last, sitting above a biography of the LA
|
||
* psych band. The hero the artist page already resolved is identity-gated, so
|
||
* it is the honest replacement; if there is none, no picture beats a wrong one.
|
||
*
|
||
* Both surfaces are corrected — the page's blurred instant-shell seed and the
|
||
* Now Playing "About the artist" card, which is where the wrong face is
|
||
* actually legible. */
|
||
async function disownFusedPortrait(artistId, merged) {
|
||
const suspect = merged.artist?.picture ?? null;
|
||
const seed = await getCatalogArtistHeroSeed(artistId).catch(() => null);
|
||
const replacement = seed?.banner ?? merged.banner ?? null;
|
||
const current = catalog.artistPage;
|
||
if (current && sameArtistId(current.artist?.id, artistId)) {
|
||
const corrected = {
|
||
...current,
|
||
artist: { ...current.artist, picture: replacement },
|
||
placeholder_picture: replacement,
|
||
};
|
||
catalog.artistPage = corrected;
|
||
// Same reason the identity merge writes itself back: a warm revisit
|
||
// must not resurrect what this pass just disproved.
|
||
const key = artistCacheKey(artistId);
|
||
if (key) {
|
||
catalogArtistPageCache.delete(key);
|
||
catalogArtistPageCache.set(key, corrected);
|
||
trimArtistRequestCache(catalogArtistPageCache);
|
||
}
|
||
rememberArtistSeed(corrected.artist);
|
||
savePageReplay(artistId, corrected);
|
||
}
|
||
if (
|
||
discovery.picture
|
||
&& suspect
|
||
&& discovery.picture === suspect
|
||
&& sameArtistId(discovery.artistId, artistId)
|
||
) discovery.picture = replacement;
|
||
// Hand the verdict to the backend so the surfaces that CANNOT afford to
|
||
// re-derive it agree too — above all the suggestion dropdown, which is
|
||
// typed against per keystroke and is where the wrong face is met first.
|
||
try {
|
||
await api.rememberFusedArtistPortrait(artistId, replacement);
|
||
} catch {
|
||
// Best-effort: this session is already correct without it.
|
||
}
|
||
}
|
||
|
||
/** Short artist bio, resolved by the backend through the artist's OWN
|
||
* MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw
|
||
* browser fetch that displayed Wikipedia's FIRST hit for
|
||
* "<name> musician band" with no verification, which is how the panel came
|
||
* to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under
|
||
* Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the
|
||
* catalog id to anchor identity, so it runs after the id resolves. */
|
||
async function fetchArtistBio(artistId, name) {
|
||
try {
|
||
return (await api.catalogArtistBio(artistId, name)) ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Run the artist page's identity merge against the panel's own list.
|
||
*
|
||
* Deliberately NOT `applyArtistIdentity`: that one writes `catalog.artistPage`,
|
||
* which would overwrite whatever artist page the user is actually looking at.
|
||
* Only the merge is shared — see [`mergeArtistIdentity`] — so the panel gains
|
||
* the supplemented releases and the type hints without touching the page.
|
||
*
|
||
* Guarded on the normalized artist name, like everything else here, so a track
|
||
* change mid-flight cannot land one artist's supplement on another's list.
|
||
*/
|
||
async function applyDiscoveryIdentity(artistId, norm, name) {
|
||
try {
|
||
const identity = await api.catalogArtistIdentity(artistId, name ?? "");
|
||
if (discovery.artist !== norm) return;
|
||
if (!(identity?.albums?.length || identity?.type_hints?.length)) return;
|
||
const merged = mergeArtistIdentity(
|
||
{
|
||
// `artist.name` is load-bearing: `artistScopedRecords` uses it to
|
||
// reject rows belonging to a co-tenant of a fused Deezer record.
|
||
artist: { name },
|
||
albums: discovery.albums,
|
||
socials: discovery.socials,
|
||
typeHints: discovery.typeHints,
|
||
},
|
||
identity,
|
||
);
|
||
if (discovery.artist !== norm) return;
|
||
discovery.albums = merged.albums ?? discovery.albums;
|
||
discovery.typeHints = merged.typeHints ?? discovery.typeHints;
|
||
if (merged.socials?.length) discovery.socials = merged.socials;
|
||
} catch {
|
||
// Best-effort: the panel is complete without the supplement.
|
||
}
|
||
}
|
||
|
||
/** Resolve the playing artist's catalog id, then load their discography +
|
||
* related artists for the Now Playing discovery sections. Guarded by the
|
||
* normalized name so rapid track changes don't clobber each other. */
|
||
export async function loadDiscovery(artistName) {
|
||
const norm = (artistName || "").trim().toLowerCase();
|
||
if (!norm) {
|
||
discovery.artist = null;
|
||
return;
|
||
}
|
||
const resuming = discovery.artist === norm;
|
||
if (resuming && discovery.pageLoaded) return;
|
||
if (resuming && discovery.loading) {
|
||
// If the panel reopened after the delayed page check already skipped,
|
||
// let the in-flight overview request perform one exhaustive retry
|
||
// before it settles instead of losing refinement permanently.
|
||
if (ui.nowPlaying) discovery.pageWanted = true;
|
||
return;
|
||
}
|
||
discovery.artist = norm;
|
||
discovery.loading = true;
|
||
discovery.pageWanted = ui.nowPlaying;
|
||
if (!resuming) {
|
||
discovery.pageLoaded = false;
|
||
discovery.artistId = null;
|
||
discovery.picture = null;
|
||
discovery.fans = null;
|
||
discovery.albumCount = null;
|
||
discovery.albums = [];
|
||
discovery.related = [];
|
||
discovery.socials = [];
|
||
discovery.typeHints = [];
|
||
discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null;
|
||
}
|
||
|
||
try {
|
||
let id = discovery.artistId
|
||
?? (artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined);
|
||
if (id === undefined) {
|
||
const res = await api.catalogSearch(artistName);
|
||
const match =
|
||
res.artists?.find((a) => a.name.trim().toLowerCase() === norm) ??
|
||
res.artists?.[0] ??
|
||
null;
|
||
id = match?.id ?? null;
|
||
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
|
||
if (match) rememberArtistSeed(match);
|
||
}
|
||
if (discovery.artist !== norm) return; // track changed while searching
|
||
if (id == null) return;
|
||
discovery.artistId = id;
|
||
// The bio is identity-anchored, so it can only start once the catalog
|
||
// id is known.
|
||
if (!bioCache.has(norm)) {
|
||
fetchArtistBio(id, artistName).then((bio) => {
|
||
// Only PIN a real answer. A null is usually a transient
|
||
// MusicBrainz throttle (its 1 req/s gate is shared with the
|
||
// identity pass and the library warmer), and caching that
|
||
// would blank the card for the rest of the session even
|
||
// though the very next attempt succeeds. Retries are cheap —
|
||
// every tier is disk cached for 24 h.
|
||
if (bio) setBoundedMap(bioCache, norm, bio, BIO_CACHE_MAX);
|
||
if (discovery.artist === norm) discovery.bio = bio;
|
||
});
|
||
}
|
||
const overviewReady = discovery.albums.length
|
||
? Promise.resolve(null)
|
||
: getCatalogArtistOverview(id).catch(() => null);
|
||
// The Now Playing panel can paint its useful discovery content from
|
||
// the overview. Delay exhaustive classification until the artist and
|
||
// open panel have remained stable, avoiding invisible work after a
|
||
// rapid skip or panel close.
|
||
const pageReady = (async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
if (discovery.artist !== norm || !ui.nowPlaying) return null;
|
||
discovery.pageWanted = false;
|
||
return getCatalogArtistPage(id).catch(() => null);
|
||
})();
|
||
const [overview, related] = await Promise.all([
|
||
overviewReady,
|
||
api.catalogRelated(id).catch(() => []),
|
||
]);
|
||
if (discovery.artist !== norm) return;
|
||
if (overview) {
|
||
rememberArtistSeed(overview.artist);
|
||
discovery.picture = overview.artist.picture ?? null;
|
||
// A Deezer "picture" can exist and still be the grey silhouette
|
||
// (see deezerPortraitMissing). Those artists usually DO have
|
||
// provider/Bing hero art — the artist page already resolves it —
|
||
// so borrow that instead of showing a placeholder.
|
||
void (async () => {
|
||
const portrait = discovery.picture;
|
||
if (portrait && !(await deezerPortraitMissing(portrait))) return;
|
||
const seed = await getCatalogArtistHeroSeed(id).catch(() => null);
|
||
if (
|
||
discovery.artist === norm
|
||
&& discovery.picture === portrait
|
||
&& seed?.banner
|
||
) discovery.picture = seed.banner;
|
||
})();
|
||
discovery.fans = overview.artist.fans ?? null;
|
||
discovery.albumCount =
|
||
overview.artist.album_count ?? overview.albums.length;
|
||
discovery.albums = overview.albums ?? [];
|
||
}
|
||
discovery.related = related ?? [];
|
||
let page = await pageReady;
|
||
if (discovery.artist !== norm) return;
|
||
if (!page && discovery.pageWanted && ui.nowPlaying) {
|
||
discovery.pageWanted = false;
|
||
page = await getCatalogArtistPage(id).catch(() => null);
|
||
if (discovery.artist !== norm) return;
|
||
}
|
||
if (page) {
|
||
rememberArtistSeed(page.artist);
|
||
discovery.picture = page.artist.picture ?? null;
|
||
discovery.fans = page.artist.fans ?? null;
|
||
discovery.albumCount = page.artist.album_count ?? page.albums.length;
|
||
discovery.albums = page.albums ?? [];
|
||
discovery.socials = page.socials ?? [];
|
||
discovery.typeHints = page.typeHints ?? [];
|
||
discovery.pageLoaded = true;
|
||
discovery.pageWanted = false;
|
||
// The same MusicBrainz merge the artist page runs. Without it this
|
||
// panel showed the raw provider list — My Bloody Valentine with
|
||
// four releases, because Loveless and Isn't Anything are supplements
|
||
// Deezer's regional catalog does not carry — and filed everything
|
||
// without `type_hints`, so the same discography sorted two ways
|
||
// depending on which surface you were looking at. Skipped when the
|
||
// page arrived already merged (a warm revisit through the cache).
|
||
if (!page.identity_merged) {
|
||
void applyDiscoveryIdentity(id, norm, page.artist?.name ?? artistName);
|
||
}
|
||
}
|
||
} catch {
|
||
// leave whatever loaded; sections just stay empty
|
||
} finally {
|
||
if (discovery.artist === norm) discovery.loading = false;
|
||
}
|
||
}
|
||
|
||
// ---- auto-sourcing resolver ----
|
||
// The catalog is the app's front door: picking a song or album quietly runs
|
||
// a Soulseek search, ranks the sources, and streams/downloads the winner.
|
||
// The manual Search view stays available as a fallback.
|
||
|
||
export const resolver = $state({
|
||
active: false,
|
||
label: "",
|
||
error: "",
|
||
});
|
||
|
||
export const RESOLVER_AUDIO_QUALITY = {
|
||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
||
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
|
||
};
|
||
|
||
// Formats that decode progressively while the file is still downloading.
|
||
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||
// complete, so they're save-only.
|
||
//
|
||
// `opus` was here and must not be: this build has no Opus decoder, so the
|
||
// resolver would pick an Opus source to stream, download it, and fail at the
|
||
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
|
||
// see crates/audio-formats.
|
||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
|
||
|
||
// Lossless for LABELLING — what the file claims to be, which is a different
|
||
// question from whether we can decode it. `.ape` and `.wv` are genuinely
|
||
// lossless and genuinely unplayable here; a peer's file is still worth naming
|
||
// accurately in search results.
|
||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
|
||
|
||
// User source-selection preferences, independent per mode. Loaded from
|
||
// settings at startup and refreshed by the Settings view after a commit;
|
||
// defaults reproduce the pre-feature behavior exactly.
|
||
const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" };
|
||
export const sourcePrefs = {
|
||
stream: { ...DEFAULT_SOURCE_PREFS },
|
||
download: { ...DEFAULT_SOURCE_PREFS },
|
||
};
|
||
export async function refreshSourcePrefs() {
|
||
try {
|
||
const s = await api.getSettings();
|
||
sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) };
|
||
sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) };
|
||
} catch {
|
||
// settings unavailable (fresh install / backend restart) → defaults
|
||
}
|
||
}
|
||
|
||
// ---- 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;
|
||
// A remembered file whose NAME does not even contain the track's
|
||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||
// played it from the warm cache forever). Delete it and resolve
|
||
// normally — a wrongly-dropped legit source only costs one search.
|
||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||
map.delete(key);
|
||
saveStreamSources();
|
||
continue;
|
||
}
|
||
try {
|
||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||
} catch {
|
||
// backend unavailable — resolve normally
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
void refreshSourcePrefs();
|
||
|
||
// ---- artwork for a stream that arrived without any -----------------------
|
||
|
||
/** The cover embedded in a track's stream, but only if this app has already
|
||
* streamed it and still holds the whole file.
|
||
*
|
||
* Nothing is fetched and no peer is contacted: this is the playlist page
|
||
* asking what it can know for free about rows the catalog has never heard
|
||
* of. Null means "not from here" — never "this row has no artwork". */
|
||
export async function cachedStreamArt(trackId, artist, title) {
|
||
const src = await cachedStreamSourceFor(trackId ?? null, artist, title);
|
||
if (!src) return null;
|
||
try {
|
||
return await api.streamEmbeddedArt(src.username, src.path);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Catalog covers already looked up this session, keyed artist|title.
|
||
* A stored `null` is a real answer — the catalog was asked and had nothing
|
||
* that matched strictly enough to be sure — and it stops every replay of
|
||
* that track asking again. */
|
||
const streamArtLookups = new Map();
|
||
|
||
/** A streamed track showing no artwork at all: find it some, in order of
|
||
* certainty.
|
||
*
|
||
* The bytes the peer is sending ARE the recording, so a cover embedded in
|
||
* them belongs to it — no matching, no doubt, no network. Only a file that
|
||
* carries none asks the catalog, and then through the same strict matcher
|
||
* the import review verifies rows with. A loose name lookup here is exactly
|
||
* what once put one record's cover on seven unrelated songs, so no match
|
||
* means no art: a blank tile is the honest answer, a wrong cover is not.
|
||
*
|
||
* Mutates the queue item in place, which is what every art surface already
|
||
* reads — `art` for a local image, `artUrl` for a catalog cover — so the
|
||
* bar, the panel and the lightbox all fill from one assignment. */
|
||
async function ensureStreamArt(item) {
|
||
if (!item?.stream || !item.path || item.art || item.artUrl) return;
|
||
// The world moves while a lookup is in flight: the queue can be replaced,
|
||
// and a later pass (or the stream-done retry) can land art first.
|
||
const stillBlank = () =>
|
||
player.queue.includes(item) && !item.art && !item.artUrl;
|
||
const showing = () => player.queue[player.queueIndex] === item;
|
||
|
||
try {
|
||
const embedded = await api.streamEmbeddedArt(item.stream.username, item.path);
|
||
if (embedded && stillBlank()) {
|
||
item.art = embedded;
|
||
if (showing()) setAccentFromArt(embedded);
|
||
return;
|
||
}
|
||
} catch {
|
||
// No tag, no file yet, or an unreadable one — the catalog is next.
|
||
}
|
||
if (!stillBlank()) return;
|
||
|
||
const artist = item.artist ?? "";
|
||
const title = item.title ?? "";
|
||
if (!artist || !title) return; // nothing to be strict ABOUT
|
||
const key = `${nameNorm(artist)}|${nameNorm(title)}`;
|
||
if (streamArtLookups.has(key)) {
|
||
const known = streamArtLookups.get(key);
|
||
if (known && stillBlank()) {
|
||
item.artUrl = known;
|
||
if (showing()) setAccentFromArtUrl(known);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
const res = await api.catalogSearch(`${artist} ${title}`);
|
||
const hit = pickCatalogMatch(res?.tracks, {
|
||
title,
|
||
artist,
|
||
duration_secs: item.duration ?? null,
|
||
});
|
||
const url = hqCover(hit?.cover ?? null) ?? null;
|
||
streamArtLookups.set(key, url);
|
||
if (url && stillBlank()) {
|
||
item.artUrl = url;
|
||
if (showing()) setAccentFromArtUrl(url);
|
||
}
|
||
} catch {
|
||
// Offline or a provider hiccup: leave the key unset so a later play
|
||
// can try again rather than remembering a failure as an absence.
|
||
}
|
||
}
|
||
|
||
|
||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
|
||
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||
* benefit of the doubt when the user asked for a minimum. Thresholds sit
|
||
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
|
||
function passesSourcePrefs(f, prefs) {
|
||
const lossless = LOSSLESS_EXTS.has(f.extension);
|
||
if (prefs.max === "lossy_only" && lossless) return false;
|
||
switch (prefs.min) {
|
||
case "lossless":
|
||
if (!lossless) return false;
|
||
break;
|
||
case "kbps320":
|
||
if (!lossless && (f.bitrate ?? 0) < 315) return false;
|
||
break;
|
||
case "kbps256":
|
||
if (!lossless && (f.bitrate ?? 0) < 250) return false;
|
||
break;
|
||
case "kbps192":
|
||
if (!lossless && (f.bitrate ?? 0) < 186) return false;
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
|
||
* overwhelmingly name files unaccented; without folding, normText simply
|
||
* DELETED accented letters ("Jóga" → "jga") and the request could never
|
||
* match the file. */
|
||
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
|
||
|
||
const normText = (s) =>
|
||
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
|
||
|
||
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
|
||
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
|
||
* with just the primary segment, capped to a few words. */
|
||
function simplifyTitle(s) {
|
||
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
|
||
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
|
||
return words || s || "";
|
||
}
|
||
|
||
// Words that mark an *edition* of a release, not a distinct release. Used to
|
||
// tell an edition suffix (noise, safe to drop) from a variant name that names
|
||
// a different album — critical for self-titled records where the base title
|
||
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
|
||
const ALBUM_EDITION_RE =
|
||
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
|
||
|
||
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
|
||
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
|
||
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
|
||
* only hold edition noise (or there are none). */
|
||
export function albumDisambiguator(title) {
|
||
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
|
||
for (const inner of groups) {
|
||
for (const part of inner.split(/\s*[/|;]\s*/)) {
|
||
const p = part.trim();
|
||
if (p && !ALBUM_EDITION_RE.test(p)) return p;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/** Search-query title-part for a release: the simplified title, plus the
|
||
* disambiguating variant name when there is one, so distinct self-titled
|
||
* albums search for their own colour instead of colliding on the band name. */
|
||
function albumQueryTitle(album) {
|
||
const base = simplifyTitle(album.title);
|
||
// Fall back to a caller-supplied variant (the family's colour) when the
|
||
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
|
||
// but names no colour, so without this it searches bare "Weezer".
|
||
const d = albumDisambiguator(album.title) || album.variant || "";
|
||
return d ? `${base} ${d}` : base;
|
||
}
|
||
|
||
// Server-distributed excluded search phrases (Soulseek message 160). A query
|
||
// containing one is NEVER routed by the server — it returns zero responses
|
||
// network-wide with no error. Verified live: every query containing "mgmt"
|
||
// was silent while "little dark age" alone returned 738 responses. The list
|
||
// arrives by event after login and is also fetchable on demand (covers a
|
||
// WebView reload); the resolver strips these terms before they reach the wire.
|
||
let excludedSearchPhrases = [];
|
||
export function setExcludedSearchPhrases(list) {
|
||
excludedSearchPhrases = (Array.isArray(list) ? list : [])
|
||
.map((p) => String(p ?? "").toLowerCase().trim())
|
||
.filter(Boolean);
|
||
}
|
||
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
|
||
|
||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||
export function sanitizeSearchQuery(query) {
|
||
let out = ` ${query ?? ""} `;
|
||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||
}
|
||
return out.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
/** Run a background source search (own token — the Search view is untouched)
|
||
* and collect responses. Ends early once `goodCount()` finds enough.
|
||
*
|
||
* The early exit is evaluated on a 250ms TICK, not only when a batch
|
||
* arrives: peers deliver most results within the first second and then go
|
||
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
|
||
* still sat out the full window waiting for one more batch that never came
|
||
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
|
||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||
* a workable set at 1800ms, and the full window only runs when results
|
||
* are genuinely sparse. */
|
||
/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER
|
||
* traffic, and the server drops sessions that flood it — measured
|
||
* 2026-08-03: a fresh login was killed inside ~90 seconds while background
|
||
* album fills re-searched on every rapid track click, and the reconnect
|
||
* throttle then locked the account out. User-triggered resolves feel
|
||
* instant (the first search never waits); only bursts are spread. */
|
||
const SEARCH_MIN_GAP_MS = 1500;
|
||
let lastSearchStartedAt = 0;
|
||
|
||
// Exported for the import finder (import-audio.js): the pacing, poisoned-
|
||
// term hygiene and early-exit logic must exist ONCE, not be re-coded.
|
||
export async function collectSources(query, maxMs, goodCount) {
|
||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||
lastSearchStartedAt = performance.now();
|
||
const token = await api.startSearch(query);
|
||
const responses = [];
|
||
let unlisten = null;
|
||
await new Promise((resolve) => {
|
||
const started = performance.now();
|
||
const timer = setTimeout(() => {
|
||
clearInterval(tick);
|
||
resolve();
|
||
}, maxMs);
|
||
const tick = setInterval(() => {
|
||
if (!goodCount) return;
|
||
const elapsed = performance.now() - started;
|
||
const good = goodCount(responses);
|
||
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
|
||
clearTimeout(timer);
|
||
clearInterval(tick);
|
||
resolve();
|
||
}
|
||
}, 250);
|
||
unlisten = api.onEvent("soulseek:search-results", (batch) => {
|
||
for (const r of batch) if (r.token === token) responses.push(r);
|
||
});
|
||
});
|
||
unlisten?.();
|
||
api.stopSearch(token).catch(() => {});
|
||
return responses;
|
||
}
|
||
|
||
/** Queries measured silent (zero responses) recently — skipped for a while
|
||
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
|
||
* every album/track of the same artist. */
|
||
const silentQueries = new Map();
|
||
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
|
||
function querySilent(q) {
|
||
const at = silentQueries.get(q);
|
||
if (at == null) return false;
|
||
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
|
||
silentQueries.delete(q);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Terms PROVEN poisoned this session: a rung containing the term was silent
|
||
// while a term-less rung answered — the network is fine, the term is dead
|
||
// (the "mgmt" case; the server's own excluded list does not admit it).
|
||
// Applied exactly like server-excluded phrases, so the second album by the
|
||
// same artist never pays the discovery timeout again.
|
||
const learnedPoisonedTerms = new Set();
|
||
|
||
/** Estimated seconds until this peer's file has fully arrived: transfer time
|
||
* from the file's size and the peer's advertised upload speed (bytes/sec),
|
||
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
|
||
* peer hides its numbers — an unknown speed must not look like a fast one. */
|
||
function estimatedTransferSecs(r, file) {
|
||
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
|
||
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
|
||
let secs = size / speed;
|
||
if (!r.has_free_upload_slot) secs += 90;
|
||
secs += Math.min(r.queue_length ?? 0, 50) * 45;
|
||
return secs;
|
||
}
|
||
|
||
function scoreSource(r, file, prefs = null) {
|
||
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
|
||
if (!q) return -1;
|
||
const bitrate = file.bitrate;
|
||
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
|
||
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
|
||
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
|
||
// only reorders sources that already passed the user's min/max quality
|
||
// window (passesSourcePrefs runs before scoring), so "fastest" can never
|
||
// dip below the configured floor. Format/bitrate remain as tie-breakers.
|
||
if (prefs?.priority === "speed") {
|
||
// Positive baseline: callers treat score <= 0 as "reject this
|
||
// format", and a slow, deep-queued peer must rank last, not vanish.
|
||
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
|
||
let s = 100_000 - eta * 25;
|
||
s += q * 50;
|
||
if (bitrate) s += Math.min(bitrate, 1500) / 50;
|
||
return s;
|
||
}
|
||
let s = q * 1000;
|
||
if (bitrate) s += Math.min(bitrate, 1500) / 10;
|
||
if (r.has_free_upload_slot) s += 600;
|
||
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
|
||
s -= Math.min(r.queue_length ?? 0, 50) * 12;
|
||
return s;
|
||
}
|
||
|
||
function requireOnline() {
|
||
if (app.connection !== "online") {
|
||
app.showLogin = true;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
|
||
// remixes, demos, covers, radio edits… `normText` strips brackets, so
|
||
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
|
||
// these are tested against the RAW filename + folder to catch that. Matching
|
||
// is symmetric: a candidate must carry exactly the markers the requested
|
||
// title carries — no live file for a studio request, no studio file for an
|
||
// explicitly "(Live)" catalog track.
|
||
const TRACK_VARIANT_MARKERS = [
|
||
/\blive\b/i,
|
||
/\bunplugged\b/i,
|
||
/\bacoustic\b/i,
|
||
/\bremix(?:es|ed)?\b/i,
|
||
/\brmx\b/i,
|
||
/\brework(?:ed)?\b/i,
|
||
/\bdemo(?:s)?\b/i,
|
||
/\binstrumental\b/i,
|
||
/\bkaraoke\b/i,
|
||
/\bcover(?:s|ed)?\b/i,
|
||
/\btribute\b/i,
|
||
/\b(?:radio|single)\s+edit\b/i,
|
||
/\b(?:club|extended|dub|dance)\s+mix\b/i,
|
||
/\bmedley\b/i,
|
||
/\bmashup\b/i,
|
||
/\bsession(?:s)?\b/i,
|
||
/\brehearsal\b/i,
|
||
/\bouttake(?:s)?\b/i,
|
||
/\balternate\b/i,
|
||
/\btake\s+\d+\b/i,
|
||
/\bre-?recorded\b/i,
|
||
// "<Name>'s Version" re-records (Taylor's Version and kin): a different
|
||
// recording of the song, matched symmetrically like every marker here —
|
||
// found live by the source audit when a request for "22" top-ranked
|
||
// "22 (Taylor's Version)".
|
||
/\b\w+['’]s\s+version\b/i,
|
||
/\bsped\s+up\b/i,
|
||
/\bslowed\b/i,
|
||
/\b8[\s-]?bit\b/i,
|
||
];
|
||
// Checked against the filename only: "Reprise" is also a record label
|
||
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
|
||
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
|
||
|
||
/** True when the candidate names a different version of the song than the
|
||
* request asked for (either direction). */
|
||
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
|
||
const cand = `${candidateFolder}\\${candidateName}`;
|
||
for (const re of TRACK_VARIANT_MARKERS) {
|
||
if (re.test(cand) !== re.test(wantedTitle)) return true;
|
||
}
|
||
for (const re of TRACK_VARIANT_NAME_ONLY) {
|
||
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** A peer file's playtime is the wrong recording when it's far from the
|
||
* catalog's known length — most often a ripped 30-second streaming *preview*
|
||
* whose filename matches the real track (e.g. a 30s "Long Season" for a
|
||
* 41-minute live track). Only judges when BOTH durations are known; the band
|
||
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
|
||
* silence never trip it. */
|
||
function durationMismatch(expectSecs, candSecs) {
|
||
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
|
||
const tol = Math.max(45, expectSecs * 0.2);
|
||
return Math.abs(candSecs - expectSecs) > tol;
|
||
}
|
||
|
||
/** Ranked candidate files for a song across all responses (best first).
|
||
* `album` (title string, optional) biases toward folders of the release the
|
||
* user is actually looking at. `expectSecs` (optional) is the catalog track's
|
||
* known length, used to drop preview clips / wrong-length rips. */
|
||
function songCandidates(
|
||
responses,
|
||
title,
|
||
streamOnly = false,
|
||
album = null,
|
||
expectSecs = null,
|
||
artist = null,
|
||
requireIdentityEvidence = false,
|
||
) {
|
||
const want = normText(title);
|
||
const wantedRaw = title ?? "";
|
||
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
|
||
const artistKey = artist ? normText(artist) : "";
|
||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||
const out = [];
|
||
for (const r of responses) {
|
||
for (const f of r.files) {
|
||
if (!normText(f.name).includes(want)) continue;
|
||
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
|
||
if (!passesSourcePrefs(f, prefs)) continue;
|
||
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
|
||
if (durationMismatch(expectSecs, f.duration)) continue;
|
||
let s = scoreSource(r, f, prefs);
|
||
if (s <= 0) continue;
|
||
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
|
||
const artistEvidence =
|
||
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
|
||
// A rung that dropped the artist term (server-excluded phrase)
|
||
// matches by title alone, which happily returns other artists'
|
||
// covers of the song. The peer's PATH must then prove identity:
|
||
// the artist somewhere in the folder/filename, or the exact
|
||
// release folder.
|
||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||
continue;
|
||
}
|
||
// The requested release's own folder beats compilation/misc rips
|
||
// (below a format-tier jump, so quality still wins).
|
||
if (albumEvidence) s += 800;
|
||
if (artistEvidence) s += 400;
|
||
// 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, 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;
|
||
}
|
||
|
||
/** Exposed for the source-resolution audit harness (session-scratchpad CDP
|
||
* probes drive the REAL matching/scoring code against live search responses).
|
||
* Not part of the app surface. */
|
||
export const _sourceAudit = {
|
||
songCandidates,
|
||
albumCandidates,
|
||
sanitizeSearchQuery,
|
||
collectSources,
|
||
variantMismatch,
|
||
durationMismatch,
|
||
asciiFold,
|
||
normText,
|
||
simplifyTitle,
|
||
stopPlayback,
|
||
};
|
||
|
||
/** Resolves true when the stream is audibly playing; false on decode/peer
|
||
* failure (status drops to idle) or when nothing plays within `ms`. */
|
||
function waitForStreamStart(ms) {
|
||
return new Promise((resolve) => {
|
||
const t0 = performance.now();
|
||
const iv = setInterval(() => {
|
||
if (player.status === "playing") {
|
||
clearInterval(iv);
|
||
resolve(true);
|
||
} else if (performance.now() - t0 > ms || player.status === "idle") {
|
||
clearInterval(iv);
|
||
resolve(false);
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
/** Ranked candidate folders for an album (best first). `trackCountOverride`
|
||
* is the authoritative track count from the catalog tracklist fetch; prefer it
|
||
* over `album.track_count`, which is routinely null when Play is pressed on the
|
||
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
|
||
function albumCandidates(
|
||
responses,
|
||
album,
|
||
streamOnly = false,
|
||
trackCountOverride = null,
|
||
requireIdentityEvidence = false,
|
||
) {
|
||
const artistKey = normText(album.artist ?? "");
|
||
const wantAlbum = normText(simplifyTitle(album.title));
|
||
const wanted = trackCountOverride ?? album.track_count ?? null;
|
||
const year = album.release_date?.slice(0, 4) || null;
|
||
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
|
||
// colour word, so we can prefer the right folder and reject a wrong-colour
|
||
// one that a fuzzy peer search returns anyway.
|
||
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
|
||
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
|
||
const folders = new Map();
|
||
for (const r of responses) {
|
||
for (const f of r.files) {
|
||
const key = `${r.username}|${f.folder}`;
|
||
let g = folders.get(key);
|
||
if (!g) {
|
||
g = { username: r.username, folder: f.folder, files: [], r };
|
||
folders.set(key, g);
|
||
}
|
||
g.files.push(f);
|
||
}
|
||
}
|
||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||
const out = [];
|
||
for (const g of folders.values()) {
|
||
const audio = g.files.filter(
|
||
(f) =>
|
||
(streamOnly
|
||
? RESOLVER_STREAMABLE.has(f.extension)
|
||
: RESOLVER_AUDIO_QUALITY[f.extension])
|
||
&& passesSourcePrefs(f, prefs),
|
||
);
|
||
// Require enough streamable files to plausibly be this release, but
|
||
// NEVER default an unknown count to 3 — that silently rejected every
|
||
// single/2-track release (whose peer folders hold 1–2 matching files)
|
||
// and is exactly why "No streamable album sources found" fired for
|
||
// real, obviously-streamable singles like ICARUS. Known count → at most
|
||
// 3, floor 1; unknown → just 1.
|
||
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
|
||
if (audio.length < minAudio) continue;
|
||
const top = audio
|
||
.map((f) => scoreSource(g.r, f, prefs))
|
||
.reduce((a, b) => a + b, 0) / audio.length;
|
||
let s = top;
|
||
const folderNorm = normText(g.folder);
|
||
const albumEvidence = folderNorm.includes(wantAlbum);
|
||
const artistEvidence = artistKey && folderNorm.includes(artistKey);
|
||
// Artist-less query rung (server-excluded phrase): the folder path
|
||
// must prove the release's identity — see songCandidates.
|
||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||
continue;
|
||
}
|
||
if (albumEvidence) s += 400;
|
||
if (artistEvidence) s += 300;
|
||
// Prefer folders whose track count matches the release.
|
||
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
|
||
// The release year in the folder path is a strong per-release signal
|
||
// (distinguishes reissues and same-named albums).
|
||
if (year && g.folder.includes(year)) s += 300;
|
||
// Self-titled colour disambiguation: reward the matching "(X Album)"
|
||
// folder, and strongly reject a folder that clearly names a different
|
||
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
|
||
if (wantColour) {
|
||
const folderColours = [
|
||
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
|
||
].map((m) => m[1]);
|
||
if (folderColours.includes(wantColour)) s += 500;
|
||
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,
|
||
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;
|
||
}
|
||
|
||
/** Walk a LADDER of query shapes until one returns responses. Each rung is
|
||
* sanitized against the server's excluded-phrase list first (a poisoned term
|
||
* silences the whole query), deduplicated, and dropped when sanitizing left
|
||
* nothing. Returns `{ responses, artistIncluded }` — `artistIncluded` is
|
||
* whether the rung that produced the responses still contained the artist
|
||
* term, so candidate filters know to demand artist/album evidence in the
|
||
* file path instead (a title-only query happily returns covers and karaoke).
|
||
*
|
||
* `queries` may be a string (single rung) or an array, best-first. */
|
||
async function resolve(label, queries, goodCount, artistTerm = null) {
|
||
resolver.active = true;
|
||
resolver.label = label;
|
||
resolver.error = "";
|
||
player.error = "";
|
||
try {
|
||
const artistKey = artistTerm ? normText(artistTerm) : "";
|
||
const seen = new Set();
|
||
const ladder = [];
|
||
for (const raw of [].concat(queries).filter(Boolean)) {
|
||
// Each shape also gets an ASCII-folded rung: peers name files
|
||
// unaccented, and the server AND-matches query tokens, so
|
||
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
|
||
for (const variant of [raw, asciiFold(raw)]) {
|
||
const q = sanitizeSearchQuery(variant);
|
||
if (!q || seen.has(q)) continue;
|
||
seen.add(q);
|
||
ladder.push(q);
|
||
}
|
||
}
|
||
// Non-final rungs get a short window: a rung the network answers at
|
||
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
|
||
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
|
||
// outright. The post-login warm-up retry moved BEHIND the ladder: if
|
||
// ANY rung answered, connectivity is proven and no retry is owed —
|
||
// the old first-rung retry burned 7 extra seconds on every poisoned
|
||
// artist query before the fallback rung even ran.
|
||
let sawSilentArtistRung = false;
|
||
for (let index = 0; index < ladder.length; index++) {
|
||
const q = ladder[index];
|
||
if (querySilent(q)) continue;
|
||
const final = index === ladder.length - 1;
|
||
const hasArtist = artistKey && normText(q).includes(artistKey);
|
||
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
|
||
if (responses.length) {
|
||
// An answered term-less rung after a silent artist rung PROVES
|
||
// the artist term is what kills the query — learn it, so the
|
||
// artist's other releases skip the dead rung entirely.
|
||
if (sawSilentArtistRung && !hasArtist && artistTerm) {
|
||
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
|
||
}
|
||
return {
|
||
responses,
|
||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||
};
|
||
}
|
||
if (hasArtist) sawSilentArtistRung = true;
|
||
silentQueries.set(q, performance.now());
|
||
}
|
||
// Whole ladder silent: either the terms are all poisoned or we are in
|
||
// the post-login window where the server has not attached us to a
|
||
// search parent yet. One quiet retry of the primary rung covers the
|
||
// latter (and clears its silent mark on success).
|
||
if (ladder.length) {
|
||
const q = ladder[0];
|
||
const responses = await collectSources(q, 7000, goodCount);
|
||
if (responses.length) {
|
||
silentQueries.delete(q);
|
||
return {
|
||
responses,
|
||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||
};
|
||
}
|
||
}
|
||
return { responses: [], artistIncluded: true };
|
||
} finally {
|
||
resolver.active = false;
|
||
resolver.label = "";
|
||
}
|
||
}
|
||
|
||
// ---- library-first playback ----
|
||
// Clicking play on a catalog album/song that's already saved plays the
|
||
// local library copy instead of sourcing a stream from a peer.
|
||
|
||
/** Library album matching a catalog release (same normalization as the
|
||
* artist page's ✓ badges, plus the simplified-title variant so verbose
|
||
* catalog editions still match). */
|
||
function libraryAlbumFor(artist, title, releaseDate = null) {
|
||
const a = normText(artist);
|
||
const t1 = normText(title);
|
||
if (!a || !t1) return null;
|
||
// normText strips ALL bracket content, so base-title comparison alone
|
||
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
|
||
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
|
||
// a library copy saved under the bare provider title "Weezer". After the
|
||
// base titles agree, identity therefore needs the variant name
|
||
// (albumDisambiguator) to agree — or, when either side lacks one, the
|
||
// release YEAR to corroborate. A self-titled base carries no identity at
|
||
// all (the title is just the artist), so even a bare↔bare title match
|
||
// requires the year there. No usable evidence → no local match; streaming
|
||
// the right album beats instantly playing the wrong one.
|
||
const wantD = normText(albumDisambiguator(title));
|
||
// The simplified-title fallback lets a library copy saved under a terse
|
||
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
|
||
// (Deluxe Edition)"); disambiguated variants never take it.
|
||
const t2 = wantD ? null : normText(simplifyTitle(title));
|
||
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
|
||
const selfTitled = t1 === a;
|
||
return (
|
||
libraryStore.albums.find((al) => {
|
||
if (normText(al.artist) !== a) return false;
|
||
const lt = normText(al.title);
|
||
if (lt !== t1 && !(t2 && lt === t2)) return false;
|
||
const libD = normText(albumDisambiguator(al.title));
|
||
if (wantD && wantD === libD) return true;
|
||
if (wantD !== libD || selfTitled) {
|
||
const libYear = Number(al.year) || null;
|
||
return wantYear != null && libYear != null && libYear === wantYear;
|
||
}
|
||
return true;
|
||
}) ?? null
|
||
);
|
||
}
|
||
|
||
/** The library album matching a catalog release, or null — the same evidence
|
||
* rules as library-first playback (variant name + release-year for self-titled
|
||
* records). Exposed for the artist page's saved-track ticks. */
|
||
export function libraryLocalAlbum(artist, album) {
|
||
return libraryAlbumFor(artist, album?.title ?? "", album?.release_date ?? null);
|
||
}
|
||
|
||
/** Whether a catalog release already has a matching copy in the library —
|
||
* the same evidence rules as library-first playback, so the artist page's
|
||
* ✓ badges and the play routing can never disagree. */
|
||
export function libraryHasAlbum(artist, album) {
|
||
return Boolean(libraryLocalAlbum(artist, album));
|
||
}
|
||
|
||
/** Locate a track in the library by artist + title, searching only that
|
||
* artist's albums. Returns { album, tracks, index } or null. */
|
||
async function libraryTrackFor(artist, title) {
|
||
const a = normText(artist);
|
||
const t = normText(title);
|
||
if (!a || !t) return null;
|
||
for (const al of libraryStore.albums) {
|
||
if (normText(al.artist) !== a) continue;
|
||
try {
|
||
const tracks = await api.albumTracks(al.id);
|
||
const index = tracks.findIndex((tr) => normText(tr.title) === t);
|
||
if (index >= 0) return { album: al, tracks, index };
|
||
} catch {
|
||
// library unavailable — fall through to streaming
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Distill a peer file's tags into the quality info the Now Playing panel
|
||
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
|
||
* many clients only report some, so the panel hides what's missing. */
|
||
function qualityOf(f) {
|
||
if (!f) return null;
|
||
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
|
||
return {
|
||
format: ext.toUpperCase(),
|
||
bitrate: f.bitrate ?? null,
|
||
sampleRate: f.sample_rate ?? null,
|
||
bitDepth: f.bit_depth ?? null,
|
||
vbr: !!f.vbr,
|
||
size: f.size ?? null,
|
||
};
|
||
}
|
||
|
||
/** Quality info for a local library track: format from the path, the rest
|
||
* from the DB (sample-rate/bit-depth/size backfilled for older files at
|
||
* startup). The panel drops whatever's still missing. Null if there's no
|
||
* path. `t` is a library Track row. */
|
||
function localQuality(t) {
|
||
const path = t?.path || "";
|
||
const ext = path.split(".").pop() || "";
|
||
if (!ext || ext === path) return null;
|
||
return {
|
||
format: ext.toUpperCase(),
|
||
bitrate: t.bitrate_kbps ?? null,
|
||
sampleRate: t.sample_rate ?? null,
|
||
bitDepth: t.bit_depth ?? null,
|
||
vbr: false,
|
||
size: t.size ?? null,
|
||
};
|
||
}
|
||
|
||
/** Deezer's CDN renders the same cover at whatever size the URL's size
|
||
* segment asks for. Track objects carry the 250px list thumb
|
||
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
|
||
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
|
||
* untouched. */
|
||
function hqCover(url) {
|
||
if (!url) return url;
|
||
try {
|
||
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
|
||
} catch {
|
||
return url;
|
||
}
|
||
return url.replace(
|
||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||
"/1000x1000$1.$2",
|
||
);
|
||
}
|
||
|
||
/** The tier gridCover asks for, chosen by device pixel ratio.
|
||
*
|
||
* The biggest consumer is the album expander's rail art at a FIXED 209px — the
|
||
* rail column is 250px regardless of window or monitor size, so a 4K display
|
||
* does not make the art box bigger; only DPR does. 209px needs 418 device
|
||
* pixels at DPR 2, which 500 covers, and 4K on Windows normally runs 150% or
|
||
* 200% scaling. Measured side by side at DPR 2 the 500 and 1000 sources are
|
||
* indistinguishable at actual size; at DPR 3 the 500 is visibly soft.
|
||
* So: 500 below that line, 1000 above it, rather than paying 3.6x the bytes
|
||
* (148KB -> 501KB per cover) on every display to fix the top one.
|
||
* Coverart Archive has no 1000 rendition — 1200 is its next step up. */
|
||
function coverTier() {
|
||
const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
|
||
return dpr > 2 ? { square: 1000, caa: 1200 } : { square: 500, caa: 500 };
|
||
}
|
||
|
||
/** Responsive card-sized variant of a canonical cover URL. The resolver keeps
|
||
* its original/high-resolution winner for the player; neither the 150px grid
|
||
* nor the expander's 209px rail should decode 9-million-pixel Apple masters
|
||
* into GPU memory to do it. */
|
||
export function gridCover(url) {
|
||
if (!url || url.startsWith("data:")) return url;
|
||
const { square, caa } = coverTier();
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.hostname.endsWith("dzcdn.net")) {
|
||
return url.replace(
|
||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||
`/${square}x${square}$1.$2`,
|
||
);
|
||
}
|
||
if (parsed.hostname.endsWith("mzstatic.com")) {
|
||
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, `/${square}x${square}bb.jpg`);
|
||
return parsed.toString();
|
||
}
|
||
if (parsed.hostname.endsWith("coverartarchive.org")) {
|
||
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, `/front-${caa}`);
|
||
return parsed.toString();
|
||
}
|
||
} catch {
|
||
return url;
|
||
}
|
||
return url;
|
||
}
|
||
|
||
/** A tiny CDN-rendered variant used for blurred placeholders and palette
|
||
* sampling. Deezer and Apple both resize the same source artwork by URL. */
|
||
export function tinyCover(url) {
|
||
if (!url) return null;
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.hostname.endsWith("mzstatic.com")) {
|
||
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/96x96bb.jpg");
|
||
return parsed.toString();
|
||
}
|
||
if (parsed.hostname.endsWith("coverartarchive.org")) {
|
||
const originalPath = parsed.pathname;
|
||
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-250");
|
||
return parsed.pathname === originalPath && !originalPath.endsWith("/front-250")
|
||
? null
|
||
: parsed.toString();
|
||
}
|
||
if (!parsed.hostname.endsWith("dzcdn.net")) return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
const tiny = url.replace(
|
||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||
"/56x56$1.$2",
|
||
);
|
||
return tiny === url ? null : tiny;
|
||
}
|
||
|
||
/** Find the best source for a song and stream it, failing over to the next
|
||
* candidate when a peer never starts sending. Songs already in the library
|
||
* play locally (with their album as the queue).
|
||
* opts: { cover, trackId, albumId, album, expectSecs } — catalog context for
|
||
* art, badges, album-folder source preference, and length verification. */
|
||
export async function streamSong(artist, title, opts = {}) {
|
||
if (resolver.active) return;
|
||
// opts.skipLocal: the import picker's A/B auditions the CANDIDATE — the
|
||
// local-library shortcut would find the user's own copy and play them
|
||
// against themselves (decision 11). The stream cache below still counts:
|
||
// those bytes came from a peer, so their quality is honestly "theirs".
|
||
if (!opts.skipLocal) {
|
||
const local = await libraryTrackFor(artist, title);
|
||
if (local) {
|
||
playAlbum(
|
||
local.tracks,
|
||
`${local.album.artist} — ${local.album.title}`,
|
||
local.index,
|
||
local.album.art_path ?? null,
|
||
);
|
||
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,
|
||
};
|
||
scheduleAlbumFill();
|
||
}
|
||
return;
|
||
}
|
||
if (!requireOnline()) return;
|
||
autoAlbum = null;
|
||
player.track = { title, sub: "finding best source…", source: "stream" };
|
||
player.status = "buffering";
|
||
// Before the Soulseek resolve, not after it. Every caller already hands us
|
||
// the record this track belongs to, and the panel loads its tracklist off
|
||
// `catalogRef.albumId` — so setting it here lets the record load in
|
||
// PARALLEL with finding a source, instead of starting only once a peer has
|
||
// been picked. That wait was the whole "why does it take time to show the
|
||
// rest of the album" delay: the id was in hand the moment you clicked.
|
||
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
|
||
const { responses, artistIncluded } = await resolve(
|
||
`${artist} — ${title}`,
|
||
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
|
||
(rs) => songCandidates(rs, title, true, opts.album, opts.expectSecs, artist).length,
|
||
artist,
|
||
);
|
||
const candidates = songCandidates(
|
||
responses, title, true, opts.album, opts.expectSecs, artist, !artistIncluded,
|
||
);
|
||
if (candidates.length === 0) {
|
||
player.status = "idle";
|
||
player.track = null;
|
||
// Hand back the record claimed above: nothing is playing from it.
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
resolver.error = `No streamable sources found for “${title}”`;
|
||
return;
|
||
}
|
||
// 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,
|
||
title,
|
||
// "Artist — Album" whenever the record is known, which is the
|
||
// same shape `playAlbum` writes. The panel splits `sub` on the
|
||
// dash to get its clickable artist/album lines, so a bare
|
||
// artist here meant a single track NEVER named the record it
|
||
// came from — even though `opts.album` was passed by every
|
||
// caller and sat unused one line below.
|
||
sub: opts.album ? `${artist} — ${opts.album}` : artist,
|
||
artist,
|
||
album: opts.album ?? null,
|
||
duration: c.file.duration ?? opts.expectSecs ?? null,
|
||
art: null,
|
||
artUrl: hqCover(opts.cover) ?? null,
|
||
trackId: opts.trackId ?? null,
|
||
stream: { username: c.username, size: c.file.size },
|
||
quality: qualityOf(c.file),
|
||
},
|
||
];
|
||
const queuedItem = player.queue[0];
|
||
playQueueIndex(0);
|
||
if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) {
|
||
// Keep the album going: resolve + pre-buffer the next album
|
||
// track in the background, Spotify-style.
|
||
//
|
||
// Only if this song still owns the queue. `waitForStreamStart`
|
||
// polls the GLOBAL player status, so a play command issued while
|
||
// this one was buffering can satisfy it — and that command already
|
||
// cleared autoAlbum on its way past. Installing ours over the top
|
||
// would point the album fill at a queue belonging to something
|
||
// else entirely.
|
||
if (opts.albumId && player.queue[0] === queuedItem) {
|
||
autoAlbum = {
|
||
albumId: opts.albumId,
|
||
albumTitle: opts.album ?? null,
|
||
cover: hqCover(opts.cover) ?? null,
|
||
tracks: null,
|
||
pending: false,
|
||
};
|
||
scheduleAlbumFill();
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
stopPlayback();
|
||
resolver.error = `Sources for “${title}” aren't responding — try again in a moment`;
|
||
}
|
||
|
||
/** Find the best source for a song and download it into the library. */
|
||
export async function saveSong(artist, title) {
|
||
if (!requireOnline() || resolver.active) return false;
|
||
const { responses, artistIncluded } = await resolve(
|
||
`${artist} — ${title}`,
|
||
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
|
||
(rs) => songCandidates(rs, title, false, null, null, artist).length,
|
||
artist,
|
||
);
|
||
const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0];
|
||
if (!best) {
|
||
resolver.error = `No sources found for “${title}”`;
|
||
return false;
|
||
}
|
||
await api.downloadFile(best.username, best.file.path, best.file.size);
|
||
return true;
|
||
}
|
||
|
||
/** Match a peer file to a catalog track, so queue items carry the tagged
|
||
* title instead of the filename.
|
||
*
|
||
* A track title contained in the filename normally identifies the track — but
|
||
* only if it's *specific* to this file. When peers embed the album name in
|
||
* every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album
|
||
* name is also a track title, that album name is a substring of every filename
|
||
* and — being long — used to out-rank each track's real, shorter title, so the
|
||
* whole album collapsed to one repeated title. So: ignore any title that
|
||
* appears in (roughly) every filename, take the longest *specific* match, and
|
||
* otherwise fall back to album order when the folder is a complete album. */
|
||
function catalogTrackFor(file, i, files, tracks) {
|
||
if (!tracks?.length) return null;
|
||
const fname = normText(file.name);
|
||
const base = (t) => normText(t.title);
|
||
const named = (t) => base(t).length >= 3 && fname.includes(base(t));
|
||
|
||
// PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds
|
||
// exactly the album's track count it's almost always the whole album in
|
||
// order, so file `i` is track `i`. That's the only reliable way to tell
|
||
// same-named variants apart — "My Name Is Jonas (2024 Remaster)",
|
||
// "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base
|
||
// title, so filename matching alone can't say which belongs here (it used
|
||
// to pick the longest title, i.e. the verbose live/demo one). Only distrust
|
||
// the position if the filename clearly names a DIFFERENT song than the one
|
||
// sitting there (a reordered/mismatched folder — handled by the backup).
|
||
if (files.length === tracks.length) {
|
||
const at = tracks[i];
|
||
if (!fname || named(at) || !tracks.some(named)) return at;
|
||
}
|
||
|
||
// BACKUP: fuzzy filename→title matching for a partial/reordered folder.
|
||
// Drop any title that appears in ~every filename (that's the album name),
|
||
// then among the rest prefer the closest duration (disambiguates same-named
|
||
// variants), then the longest title.
|
||
const contained = tracks.filter(named);
|
||
const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0);
|
||
const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5));
|
||
let pool = contained.filter((t) => ubiquity(t) < ubiquitous);
|
||
if (!pool.length) pool = contained;
|
||
if (pool.length) {
|
||
const dur = file.duration;
|
||
if (dur) {
|
||
const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur);
|
||
// Closest duration wins; on a tie prefer the shorter title, i.e. the
|
||
// plain studio version over a longer "(Live…)" / "(Demo…)" variant.
|
||
return pool.reduce((a, b) =>
|
||
off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a,
|
||
);
|
||
}
|
||
return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a));
|
||
}
|
||
return files.length === tracks.length ? tracks[i] : null;
|
||
}
|
||
|
||
/** Turn a peer filename into a readable title when no catalog track matches
|
||
* it (a genuine bonus/extra file on an expanded edition): drop the extension
|
||
* and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a
|
||
* title rather than "04. Dragon Ball Z…". Conservative — only strips a number
|
||
* that's clearly a track index (has a separator, or a leading zero), so a title
|
||
* like "99 Luftballons" is left intact. */
|
||
function cleanTrackName(name) {
|
||
const stem = (name ?? "").replace(/\.[^.]+$/, "");
|
||
const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim();
|
||
return stripped || stem;
|
||
}
|
||
|
||
/** Find the best folder for an album; stream it as a queue, failing over to
|
||
* the next-best folder when a peer never starts sending. Albums already in
|
||
* the library play locally instead. */
|
||
export async function streamCatalogAlbum(album) {
|
||
if (resolver.active) return;
|
||
const local = libraryAlbumFor(album.artist, album.title, album.release_date ?? null);
|
||
if (local) {
|
||
try {
|
||
const tracks = await api.albumTracks(local.id);
|
||
if (tracks.length > 0) {
|
||
playAlbum(
|
||
tracks,
|
||
`${local.artist} — ${local.title}`,
|
||
0,
|
||
local.art_path ?? null,
|
||
);
|
||
player.catalogRef = { albumId: album.id ?? null, trackId: null };
|
||
return;
|
||
}
|
||
} catch {
|
||
// library unavailable — fall through to streaming
|
||
}
|
||
}
|
||
// Every track of this record remembered and still byte-complete in the
|
||
// cache? Queue the whole album from memory — no search, no popup, and
|
||
// playable offline. A single unremembered or evicted track falls through
|
||
// to the resolver exactly as before, which then re-teaches the memory via
|
||
// the queue it builds.
|
||
if (album.id) {
|
||
const known = await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null);
|
||
if (known?.length) {
|
||
const sources = [];
|
||
for (const t of known) {
|
||
const src = await cachedStreamSourceFor(t.id ?? null, album.artist, t.title);
|
||
if (!src) {
|
||
sources.length = 0;
|
||
break;
|
||
}
|
||
sources.push({ t, src });
|
||
}
|
||
if (sources.length) {
|
||
autoAlbum = null;
|
||
player.queue = sources.map(({ t, src }) => ({
|
||
path: src.path,
|
||
title: t.title,
|
||
sub: `${album.artist} — ${album.title}`,
|
||
artist: album.artist,
|
||
album: album.title ?? null,
|
||
duration: src.duration ?? t.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: album.cover ?? null,
|
||
trackId: t.id ?? null,
|
||
stream: { username: src.username, size: src.size },
|
||
quality: src.quality ?? null,
|
||
}));
|
||
player.catalogRef = { albumId: album.id ?? null, trackId: null };
|
||
playQueueIndex(0);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (!requireOnline()) return;
|
||
autoAlbum = null;
|
||
player.track = { title: album.title, sub: "finding best source…", source: "stream" };
|
||
player.status = "buffering";
|
||
// Resolve the authoritative tracklist FIRST. Its length is the true track
|
||
// count — Deezer's artist-album LIST omits nb_tracks, so album.track_count
|
||
// is routinely null when Play is pressed on the artist page — and its
|
||
// titles are what the queue should show instead of peer filenames. Feeding
|
||
// the real count into albumCandidates both stops the ≥3-files filter from
|
||
// rejecting small releases and lets the folder-count preference pick the
|
||
// edition that matches the release (e.g. the 3-track single, not a 7-track
|
||
// CDR that would leak bonus tracks into the queue as raw filenames).
|
||
const catalogPromise = album.id
|
||
? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
|
||
: Promise.resolve(null);
|
||
// Overlap the peer search with the tracklist fetch when the release is
|
||
// provably multi-track — the search is the long pole, and the tracklist
|
||
// round-trip used to serialize in front of it. `liveTrackCount` feeds the
|
||
// early-exit counter and tightens itself the moment the tracklist lands.
|
||
// A possible single (unknown count, or single/EP typed) keeps the
|
||
// sequential order, because a confirmed single must take the SONG path
|
||
// and must not race an already-active album resolve.
|
||
const provablyMultiTrack =
|
||
(album.track_count ?? 0) > 1 || album.record_type === "album";
|
||
let liveTrackCount = album.track_count || null;
|
||
const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length;
|
||
let resolvePromise = null;
|
||
if (provablyMultiTrack) {
|
||
resolvePromise = resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
goodCount,
|
||
album.artist,
|
||
);
|
||
}
|
||
const catalogTracks = await catalogPromise;
|
||
const trackCount = catalogTracks?.length || album.track_count || null;
|
||
liveTrackCount = trackCount;
|
||
|
||
// A confirmed single is one track — stream it through the song path, which
|
||
// matches peer files by TITLE (album folder-matching can't identify a lone
|
||
// file among a folder's contents) and layers on the live/remix + duration
|
||
// hardening. Without this, a single fell into the album path and, with an
|
||
// unknown count, was rejected for having fewer than 3 files per folder.
|
||
if (trackCount === 1) {
|
||
// Rare mistyped release: a search may already be in flight — let it
|
||
// finish so the song path's own resolve isn't refused as re-entrant.
|
||
if (resolvePromise) await resolvePromise.catch(() => {});
|
||
const t = catalogTracks?.[0];
|
||
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,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const { responses, artistIncluded } = await (resolvePromise
|
||
?? resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
goodCount,
|
||
album.artist,
|
||
));
|
||
const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded);
|
||
if (candidates.length === 0) {
|
||
player.status = "idle";
|
||
player.track = null;
|
||
resolver.error = `No streamable album sources found for “${album.title}”`;
|
||
return;
|
||
}
|
||
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 {
|
||
path: f.path,
|
||
title: ct?.title ?? cleanTrackName(f.name),
|
||
sub: `${album.artist} — ${album.title}`,
|
||
artist: album.artist,
|
||
album: album.title ?? null,
|
||
duration: f.duration ?? ct?.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: album.cover ?? null,
|
||
trackId: ct?.id ?? null,
|
||
stream: { username: folder.username, size: f.size },
|
||
quality: qualityOf(f),
|
||
};
|
||
});
|
||
// One album resolve teaches the memory the WHOLE record, so the next
|
||
// press of Play needs no search at all once these files finish
|
||
// caching. A folder that never delivers is harmless — its cache stays
|
||
// incomplete and the probe rejects it.
|
||
for (const it of player.queue) rememberStreamSource(it);
|
||
player.catalogRef = { albumId: album.id ?? null, trackId: null };
|
||
playQueueIndex(0);
|
||
if (await waitForStreamStart(attempt === 0 ? 12000 : 8000)) return;
|
||
}
|
||
stopPlayback();
|
||
resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;
|
||
}
|
||
|
||
/** Find the best folder for an album and download all of it. */
|
||
export async function saveCatalogAlbum(album) {
|
||
if (!requireOnline() || resolver.active) return false;
|
||
// Same authoritative-count logic as streamCatalogAlbum: a single downloads
|
||
// as one track (folder-count matching can't find a lone-file release), and
|
||
// the real count keeps a multi-track Save from being rejected or grabbing a
|
||
// wrong-length edition.
|
||
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 await saveSong(album.artist, t?.title ?? album.title);
|
||
}
|
||
const { responses, artistIncluded } = await resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
(rs) => albumCandidates(rs, album, false, trackCount).length,
|
||
album.artist,
|
||
);
|
||
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
|
||
if (!best) {
|
||
resolver.error = `No album sources found for “${album.title}”`;
|
||
return false;
|
||
}
|
||
// The whole folder, extras included — the pipeline organizes and the
|
||
// staging cleanup files art/logs next to the finished album.
|
||
for (const f of best.files) {
|
||
await api.downloadFile(best.username, f.path, f.size);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/* --------------------------------------------------------------- searching
|
||
* Qt keeps every search alive in its own tab, and so do we: `searchTabs` is
|
||
* the strip, each entry a self-contained session (its own token, groups and
|
||
* raw file map). `search` below is the ACTIVE session's face — the whole UI
|
||
* reads through it, so a tab switch is a one-line assignment here rather than
|
||
* a rewrite there. */
|
||
export const SEARCH_TABS_MAX = 8;
|
||
|
||
export const searchTabs = $state({
|
||
/** Newest last. Each: {id, token, query, searching, responses, groups} */
|
||
list: [],
|
||
activeId: null,
|
||
/** What searchNetworkFor is about to look for, shown by the box in the
|
||
* gap before the first tab exists (and if login interrupts). */
|
||
seeded: "",
|
||
});
|
||
|
||
let searchSeq = 0;
|
||
// Raw per-group file lists, per session. Deliberately NOT reactive: a broad
|
||
// search stores up to 300 × 500 file objects, and wrapping each in a $state
|
||
// proxy is what used to freeze the webview (GC storm at ~1GB+). Components
|
||
// re-render off the group's reactive fileCount and read the list through
|
||
// filesForGroup(). Keyed by session id so closing a tab frees the whole map.
|
||
const searchFilesById = new Map();
|
||
// Session id -> its 45s routing-close timer.
|
||
const searchStopTimers = new Map();
|
||
const EMPTY_GROUPS = Object.freeze({});
|
||
|
||
function sessionById(id) {
|
||
return searchTabs.list.find((s) => s.id === id) ?? null;
|
||
}
|
||
function activeSession() {
|
||
return sessionById(searchTabs.activeId);
|
||
}
|
||
function filesOf(id) {
|
||
let m = searchFilesById.get(id);
|
||
if (!m) searchFilesById.set(id, (m = new Map()));
|
||
return m;
|
||
}
|
||
|
||
/** The active session, as the single object every existing reader expects. */
|
||
export const search = {
|
||
get token() { return activeSession()?.token ?? null; },
|
||
set token(v) { const s = activeSession(); if (s) s.token = v; },
|
||
get query() { return activeSession()?.query ?? searchTabs.seeded; },
|
||
// Seed ONLY. searchNetworkFor assigns this before it calls runSearch, and
|
||
// a write-through would rename the live tab — which runSearch would then
|
||
// find by query and re-run in place, wiping the results the user was
|
||
// looking at. A tab's query is its identity; only runSearch sets it.
|
||
set query(v) { searchTabs.seeded = v; },
|
||
get searching() { return activeSession()?.searching ?? false; },
|
||
set searching(v) { const s = activeSession(); if (s) s.searching = v; },
|
||
get responses() { return activeSession()?.responses ?? 0; },
|
||
set responses(v) { const s = activeSession(); if (s) s.responses = v; },
|
||
get groups() { return activeSession()?.groups ?? EMPTY_GROUPS; },
|
||
set groups(v) { const s = activeSession(); if (s) s.groups = v; },
|
||
};
|
||
|
||
function trimHiddenSearchFiles(session) {
|
||
if (!session) return;
|
||
const visibleKeys = new Set(
|
||
Object.values(session.groups)
|
||
.sort(
|
||
(a, b) =>
|
||
(b.hasFreeSlot - a.hasFreeSlot)
|
||
|| (b.speed - a.speed),
|
||
)
|
||
.slice(0, 120)
|
||
.map((group) => group.key),
|
||
);
|
||
const files = filesOf(session.id);
|
||
for (const key of files.keys()) {
|
||
if (!visibleKeys.has(key)) files.delete(key);
|
||
}
|
||
}
|
||
|
||
export function filesForGroup(key) {
|
||
// Read-only: never creates a map (a null activeId would leave one behind).
|
||
return searchFilesById.get(searchTabs.activeId)?.get(key)?.files ?? [];
|
||
}
|
||
|
||
// Mirrors crates/audio-formats: everything recognised as audio, playable or
|
||
// not. Kept in sync by `audio_format_support`, which the backend answers from
|
||
// the same table the scanner and share index use.
|
||
export const AUDIO_EXTS = new Set([
|
||
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
|
||
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
|
||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||
]);
|
||
|
||
// Recognised but with no decoder in this build. Shown, searchable, shareable
|
||
// and downloadable — never handed to the player, which refuses them anyway.
|
||
export const UNPLAYABLE_EXTS = new Set([
|
||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||
]);
|
||
export const isPlayableExt = (ext) =>
|
||
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
|
||
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
|
||
|
||
export const downloads = $state({
|
||
// id -> download payload (latest update wins)
|
||
items: {},
|
||
});
|
||
|
||
export const network = $state({
|
||
shareFolders: 0,
|
||
shareFiles: 0,
|
||
respondedCount: 0,
|
||
uploads: {}, // id -> upload payload
|
||
});
|
||
|
||
/* ------------------------------------------------ the Soulseek hub's seats
|
||
* Rooms, chat and the buddy list — fed by the room/message/user events the
|
||
* backend pump emits, drained by the hub view's Rooms/Chat/Users tabs. */
|
||
export const slsk = $state({
|
||
/** Public room list, [{name, users}] — pushed after login. */
|
||
rooms: [],
|
||
/** room → { users: [RoomUser], messages: [{username, message, ts}] }. */
|
||
joined: {},
|
||
activeRoom: null,
|
||
/** username → { messages: [{me, text, ts}], unread }. */
|
||
chats: {},
|
||
activeChat: null,
|
||
buddies: [],
|
||
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
|
||
buddyState: {},
|
||
/** A username another area (Community) wants browsed in the hub's Browse
|
||
* tab; the hub consumes and clears it on arrival. */
|
||
browseRequest: null,
|
||
});
|
||
|
||
const SLSK_FEED_MAX = 500;
|
||
|
||
function trimFeed(arr, max) {
|
||
if (arr.length > max) arr.splice(0, arr.length - max);
|
||
}
|
||
|
||
export async function loadBuddies() {
|
||
try {
|
||
slsk.buddies = await api.buddyList();
|
||
} catch {
|
||
// backend unavailable — the Users tab renders empty and retries on
|
||
// the next connect
|
||
}
|
||
}
|
||
|
||
export async function joinRoom(name) {
|
||
await api.slskJoinRoom(name);
|
||
// The roster arrives as soulseek:room-joined; nothing optimistic here.
|
||
}
|
||
|
||
export async function leaveRoom(name) {
|
||
await api.slskLeaveRoom(name);
|
||
delete slsk.joined[name];
|
||
if (slsk.activeRoom === name) slsk.activeRoom = Object.keys(slsk.joined)[0] ?? null;
|
||
}
|
||
|
||
/** The server echoes our own line back to everyone including us, so the
|
||
* local append would double it — send only. */
|
||
export async function sayRoom(room, text) {
|
||
const t = text.trim();
|
||
if (t) await api.slskSayRoom(room, t);
|
||
}
|
||
|
||
export function openChat(username) {
|
||
slsk.chats[username] ??= { messages: [], unread: 0 };
|
||
slsk.chats[username].unread = 0;
|
||
slsk.activeChat = username;
|
||
}
|
||
|
||
/** DMs are NOT echoed back — our half is appended locally. */
|
||
export async function sendDm(username, text) {
|
||
const t = text.trim();
|
||
if (!t) return;
|
||
await api.slskMessageUser(username, t);
|
||
openChat(username);
|
||
slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() });
|
||
trimFeed(slsk.chats[username].messages, SLSK_FEED_MAX);
|
||
}
|
||
|
||
export async function addBuddy(username) {
|
||
slsk.buddies = await api.buddyAdd(username);
|
||
}
|
||
|
||
export async function removeBuddy(username) {
|
||
slsk.buddies = await api.buddyRemove(username);
|
||
delete slsk.buddyState[username];
|
||
}
|
||
|
||
const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]);
|
||
const DOWNLOAD_HISTORY_MAX = 300;
|
||
const UPLOAD_HISTORY_MAX = 100;
|
||
|
||
function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) {
|
||
const values = Object.values(store);
|
||
if (
|
||
waitUntilIdle
|
||
&& values.some((transfer) => !TERMINAL_TRANSFER_STATES.has(transfer.state))
|
||
) {
|
||
return;
|
||
}
|
||
const terminal = values
|
||
.filter((transfer) => TERMINAL_TRANSFER_STATES.has(transfer.state))
|
||
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
|
||
for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id];
|
||
}
|
||
|
||
// ---- streaming rate ------------------------------------------------------
|
||
// `player.streamSpeed` is measured here because nothing upstream reports it.
|
||
// Samples are [timestamp, bytesReceived] over a rolling window, so a transfer
|
||
// that stalls decays toward zero instead of reporting its last burst forever,
|
||
// and a bursty peer reads as one speed rather than a flickering number.
|
||
const RATE_WINDOW_MS = 1500;
|
||
let rateSamples = [];
|
||
|
||
function resetStreamRate() {
|
||
rateSamples = [];
|
||
player.streamSpeed = 0;
|
||
}
|
||
|
||
function noteStreamRate(received) {
|
||
const now = performance.now();
|
||
// A new file restarts the measurement: without this, the first sample of
|
||
// the next track is compared against the last of the previous one.
|
||
if (rateSamples.length && received < rateSamples[rateSamples.length - 1][1]) {
|
||
rateSamples = [];
|
||
}
|
||
rateSamples.push([now, received]);
|
||
while (rateSamples.length > 2 && now - rateSamples[0][0] > RATE_WINDOW_MS) {
|
||
rateSamples.shift();
|
||
}
|
||
}
|
||
|
||
function streamRate() {
|
||
if (rateSamples.length < 2) return 0;
|
||
const [t0, b0] = rateSamples[0];
|
||
const [t1, b1] = rateSamples[rateSamples.length - 1];
|
||
const secs = (t1 - t0) / 1000;
|
||
if (secs <= 0) return 0;
|
||
return Math.max(0, (b1 - b0) / secs);
|
||
}
|
||
|
||
export const player = $state({
|
||
status: "idle", // idle | buffering | playing | paused
|
||
track: null, // { title, sub, source: 'stream'|'local' }
|
||
position: 0,
|
||
duration: null,
|
||
// The player bar temporarily owns position while the user is dragging the
|
||
// playhead. Incoming backend ticks resume on release.
|
||
scrubbing: false,
|
||
volume: 0.7,
|
||
muted: false,
|
||
// streaming buffer info
|
||
streamReceived: 0,
|
||
streamTotal: 0,
|
||
// Bytes/sec, DERIVED here rather than reported: the `soulseek:stream` event
|
||
// carries only {received, total, done}, and unlike the download path there
|
||
// is no `speed_bps` to read. 0 means "not measured yet" — the panel shows
|
||
// no rate rather than a zero, because a fresh stream is not a stalled one.
|
||
streamSpeed: 0,
|
||
error: "",
|
||
// album play queue (local tracks)
|
||
queue: [],
|
||
queueIndex: -1,
|
||
shuffle: false,
|
||
// The PREDETERMINED shuffle order: a permutation of queue indices for
|
||
// the current cycle. Play order is this array left to right; the
|
||
// position is wherever `queueIndex` sits in it. Predetermined so the
|
||
// up-next card, the gapless preload/append, and the actual advance all
|
||
// agree on the SAME future — a random pick at advance time made the
|
||
// card show the linear next track and the preloader cache it, while
|
||
// playback then went somewhere else entirely.
|
||
shuffleOrder: [],
|
||
// Under repeat "all", the NEXT cycle's order — generated the moment
|
||
// anything needs to see across the cycle boundary, so the last track's
|
||
// up-next and its preload name the reshuffle's real first track.
|
||
shuffleNextOrder: null,
|
||
repeat: "off", // off | all | one
|
||
// waveform peaks (0..1) for the current local track, [] while loading
|
||
waveform: [],
|
||
// How much of the TRACK `waveform` actually describes, 0..1 — reported by
|
||
// the backend with the peaks themselves, so the two can never disagree.
|
||
// 1 for local files and complete streams; a mid-transfer decode covers
|
||
// exactly this prefix and the lanes fold it across exactly this fraction.
|
||
// Where the standing bars end IS how much of the track has loaded.
|
||
waveformCoverage: 1,
|
||
// True while a waveform request for the CURRENT track is still working —
|
||
// what separates "computing, show the loading swell" from "there is no
|
||
// waveform, rest as dots". Cleared on success and on final failure.
|
||
wavePending: false,
|
||
// Which catalog entity is playing (drives now-playing badges).
|
||
catalogRef: { albumId: null, trackId: null },
|
||
// True for a track restored from the last session whose AUDIO did not
|
||
// survive — an app restart rather than a page reload. The transport reads
|
||
// "paused", but there is nothing loaded to resume, so play has to re-source
|
||
// it from the queue. See `resumePlayback`.
|
||
needsResource: false,
|
||
});
|
||
|
||
// Adaptive accent theme, derived from the playing track's album art.
|
||
// Idle default matches the SoulStream brand blue (see icons/).
|
||
const DEFAULT_ACCENT = "#1f9ad6";
|
||
const DEFAULT_ACCENT_HOVER = "#41b7ee";
|
||
|
||
export const theme = $state({
|
||
accent: DEFAULT_ACCENT,
|
||
accentHover: DEFAULT_ACCENT_HOVER,
|
||
// Where the crossfade is HEADED, set synchronously the moment it starts.
|
||
// The mini bridge streams THESE: the lerped pair above only advances via
|
||
// rAF in this window, and rAF never fires while this window is hidden —
|
||
// mirror the lerp and the mini goes stale the moment it's the only
|
||
// window on screen.
|
||
accentTarget: DEFAULT_ACCENT,
|
||
accentHoverTarget: DEFAULT_ACCENT_HOVER,
|
||
});
|
||
|
||
let accentRaf = null;
|
||
|
||
/** Animate --accent/--accent-hover to the target colors (ease-out). */
|
||
function crossfadeAccent(accent, hover, ms = 1200) {
|
||
if (accent === theme.accent && hover === theme.accentHover) return;
|
||
theme.accentTarget = accent;
|
||
theme.accentHoverTarget = hover;
|
||
if (accentRaf) cancelAnimationFrame(accentRaf);
|
||
const fromA = hexToRgb(theme.accent);
|
||
const fromH = hexToRgb(theme.accentHover);
|
||
const toA = hexToRgb(accent);
|
||
const toH = hexToRgb(hover);
|
||
const t0 = performance.now();
|
||
const root = document.documentElement.style;
|
||
const step = (now) => {
|
||
if (!ui.windowActive) {
|
||
theme.accent = accent;
|
||
theme.accentHover = hover;
|
||
root.setProperty("--accent", accent);
|
||
root.setProperty("--accent-hover", hover);
|
||
accentRaf = null;
|
||
return;
|
||
}
|
||
const t = Math.min(1, (now - t0) / ms);
|
||
const e = t * (2 - t);
|
||
theme.accent = rgbToHex(lerpRgb(fromA, toA, e));
|
||
theme.accentHover = rgbToHex(lerpRgb(fromH, toH, e));
|
||
root.setProperty("--accent", theme.accent);
|
||
root.setProperty("--accent-hover", theme.accentHover);
|
||
accentRaf = t < 1 ? requestAnimationFrame(step) : null;
|
||
};
|
||
accentRaf = requestAnimationFrame(step);
|
||
}
|
||
|
||
let accentReq = 0;
|
||
|
||
/** Set the app accent from an album-art path (crossfades; null → default). */
|
||
async function setAccentFromArt(artPath) {
|
||
const req = ++accentReq;
|
||
if (!artPath) {
|
||
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
return;
|
||
}
|
||
try {
|
||
const dataUrl = await getArt(artPath);
|
||
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
|
||
if (req !== accentReq) return; // track changed while extracting
|
||
if (colors) crossfadeAccent(colors.accent, colors.hover);
|
||
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
} catch {
|
||
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
}
|
||
}
|
||
|
||
/** Same, from a remote catalog cover URL (proxied to a data URL). */
|
||
async function setAccentFromArtUrl(url) {
|
||
const req = ++accentReq;
|
||
if (!url) {
|
||
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
return;
|
||
}
|
||
try {
|
||
const dataUrl = await getCatalogArt(url);
|
||
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
|
||
if (req !== accentReq) return;
|
||
if (colors) crossfadeAccent(colors.accent, colors.hover);
|
||
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
} catch {
|
||
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
|
||
}
|
||
}
|
||
|
||
// ---- play history ----
|
||
// Listened time is accumulated from the player's position ticks — wall-clock
|
||
// timers would count paused/buffering time, and ticks stop exactly when audio
|
||
// does. A session row is only created once a track has actually accumulated
|
||
// listened time, so instant skips never write history. Seeks are ignored via
|
||
// the delta guard (a jump or rewind isn't listening). The playId promise
|
||
// serializes the insert and its top-ups, so rapid track changes can't lose
|
||
// the remainder or write it to the wrong row.
|
||
const LISTEN_FLUSH_SECS = 15;
|
||
let listenSession = null; // { key, pending, playIdP }
|
||
let listenLastPos = null;
|
||
|
||
function listenFlush(session = listenSession) {
|
||
if (!session?.playIdP) return;
|
||
const secs = Math.floor(session.pending);
|
||
if (secs < 1) return;
|
||
session.pending -= secs;
|
||
session.playIdP
|
||
.then((id) => api.addPlaySeconds(id, secs))
|
||
.catch(() => {});
|
||
}
|
||
|
||
function listenTick(positionSecs) {
|
||
const t = player.track;
|
||
const title = t?.title ?? "";
|
||
const artist = (t?.artist ?? "").trim()
|
||
|| String(t?.sub ?? "").split(" — ")[0].trim();
|
||
if (!title || !artist || artist.includes("…")) return;
|
||
const key = `${artist}\u0000${title}`;
|
||
if (listenSession?.key !== key) {
|
||
// The outgoing track's final seconds go in here — which is the moment
|
||
// its album can cross the floor. Re-read once that write has landed;
|
||
// waiting only on the fixed timer below meant a track that crossed 45s
|
||
// late never showed up until something else happened to refresh.
|
||
listenFlush();
|
||
setTimeout(refreshRecents, 1500);
|
||
listenSession = { key, artist, title, pending: 0, playIdP: null };
|
||
listenLastPos = null;
|
||
}
|
||
const delta = listenLastPos == null ? 0 : positionSecs - listenLastPos;
|
||
listenLastPos = positionSecs;
|
||
if (delta <= 0 || delta > 3) return;
|
||
listenSession.pending += delta;
|
||
if (!listenSession.playIdP && listenSession.pending >= 1) {
|
||
// Two possible anchors, both exact ids, never a name. A local track is
|
||
// tied to its library album through tracks.path; a STREAMED one — which
|
||
// is most listening here — through the catalog album id, carrying its
|
||
// own title/artist/cover because there is no local row to read them
|
||
// back from.
|
||
const q = player.queue?.[player.queueIndex] ?? null;
|
||
listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, {
|
||
albumId: player.catalogRef?.albumId ?? null,
|
||
albumTitle: q?.album ?? null,
|
||
albumArtist: q?.artist ?? null,
|
||
coverUrl: q?.artUrl ?? null,
|
||
});
|
||
listenSession.playIdP.catch(() => {});
|
||
// The row is opened at ~1s but only earns its place in recents once it
|
||
// passes the floor, so re-read after the track has had time to count.
|
||
listenSession.playIdP
|
||
.then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000))
|
||
.catch(() => {});
|
||
}
|
||
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
|
||
}
|
||
|
||
let started = false;
|
||
let windowActivityStarted = false;
|
||
let latestPlayerPosition = 0;
|
||
let latestPlayerDuration = null;
|
||
let latestStreamReceived = 0;
|
||
let latestStreamTotal = 0;
|
||
|
||
function initWindowActivity() {
|
||
if (windowActivityStarted) return;
|
||
windowActivityStarted = true;
|
||
let active = true;
|
||
let syncGeneration = 0;
|
||
let syncTimer = null;
|
||
const nativeUnlisteners = [];
|
||
const sync = async () => {
|
||
const generation = ++syncGeneration;
|
||
let minimized = false;
|
||
try {
|
||
minimized = await api.win.isMinimized();
|
||
} catch {
|
||
// Plain-browser previews have no native window API.
|
||
}
|
||
if (!active || generation !== syncGeneration) return;
|
||
const wasActive = ui.windowActive;
|
||
// A visible, unfocused window can still be watched side-by-side. Only
|
||
// actual native minimization / DOM hiding should freeze visual state.
|
||
ui.windowActive = !document.hidden && !minimized;
|
||
if (!wasActive && ui.windowActive) {
|
||
if (!player.scrubbing) player.position = latestPlayerPosition;
|
||
if (latestPlayerDuration != null) player.duration = latestPlayerDuration;
|
||
player.streamReceived = latestStreamReceived;
|
||
player.streamTotal = latestStreamTotal;
|
||
const current = player.queue[player.queueIndex];
|
||
if (
|
||
current?.stream
|
||
&& latestStreamReceived > 0
|
||
&& latestStreamReceived < latestStreamTotal
|
||
) {
|
||
waveRefreshAt = performance.now();
|
||
void loadStreamWaveform(current);
|
||
}
|
||
}
|
||
};
|
||
const scheduleSync = () => {
|
||
clearTimeout(syncTimer);
|
||
syncTimer = setTimeout(sync, 50);
|
||
};
|
||
void sync();
|
||
const addNativeListener = (promise) => {
|
||
void promise
|
||
.then((unlisten) => {
|
||
if (active) nativeUnlisteners.push(unlisten);
|
||
else unlisten();
|
||
})
|
||
.catch(() => {});
|
||
};
|
||
addNativeListener(api.win.onResized(scheduleSync));
|
||
addNativeListener(api.win.onFocusChanged(scheduleSync));
|
||
document.addEventListener("visibilitychange", scheduleSync);
|
||
if (import.meta.hot) {
|
||
import.meta.hot.dispose(() => {
|
||
active = false;
|
||
clearTimeout(syncTimer);
|
||
document.removeEventListener("visibilitychange", scheduleSync);
|
||
for (const unlisten of nativeUnlisteners) unlisten();
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Wire backend events once, at app start. */
|
||
export function initEvents() {
|
||
if (started) return;
|
||
started = true;
|
||
initWindowActivity();
|
||
// Preview audio-focus bridge (scrollable-track-previews). Injected here
|
||
// rather than imported there: this module owns the player store, and the
|
||
// preview module must stay import-cycle-free. resumeMain goes straight to
|
||
// api — NOT through any wrapper that bumps the revision gateway, because
|
||
// a focus-internal resume must not invalidate its own token.
|
||
initPreviewBridge({
|
||
readPlayer: () => player,
|
||
resumeMain: () => {
|
||
// Close crossfade: come back from the preview at volume 0 and
|
||
// ramp to the user's level while the preview rings down.
|
||
api.playerFadeResume(PREVIEW_CROSSFADE_MS);
|
||
},
|
||
});
|
||
|
||
// The `connected` event fires once, at login. A WebView reload (dev
|
||
// iteration, crash recovery) lands AFTER it, so the fresh frontend
|
||
// believed it was offline against a logged-in backend and pushed the
|
||
// login modal at the first play attempt. Recover the real state at boot —
|
||
// retried, because the first invoke can race IPC readiness at mount.
|
||
void (async () => {
|
||
for (let attempt = 0; attempt < 3; attempt++) {
|
||
try {
|
||
const status = await api.appStatus();
|
||
if (status?.connected_as && app.connection !== "online") {
|
||
app.connection = "online";
|
||
app.username = status.connected_as;
|
||
app.showLogin = false;
|
||
}
|
||
return;
|
||
} catch {
|
||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||
}
|
||
}
|
||
})();
|
||
|
||
api.onEvent("soulseek:state", (ev) => {
|
||
if (ev.kind === "connected") {
|
||
app.connection = "online";
|
||
app.username = ev.username;
|
||
app.loginError = "";
|
||
// The connect path re-watched the buddy list server-side; pull
|
||
// the list itself for the Users tab.
|
||
void loadBuddies();
|
||
} else if (ev.kind === "disconnected") {
|
||
app.connection = "offline";
|
||
}
|
||
});
|
||
|
||
api.onEvent("soulseek:excluded-phrases", setExcludedSearchPhrases);
|
||
|
||
api.onEvent("soulseek:shares", (s) => {
|
||
network.shareFolders = s.folders;
|
||
network.shareFiles = s.files;
|
||
});
|
||
|
||
api.onEvent("soulseek:upload", (u) => {
|
||
// A FIRST event for an id is someone new taking a file — announce it
|
||
// (UploadToast). Grabbing an album lands a burst of ids from one
|
||
// user; announceUpload folds those into one counting pill.
|
||
if (!(u.id in network.uploads)) announceUpload(u.username, u.name);
|
||
network.uploads[u.id] = u;
|
||
if (TERMINAL_TRANSFER_STATES.has(u.state)) {
|
||
trimTransferHistory(network.uploads, UPLOAD_HISTORY_MAX);
|
||
}
|
||
});
|
||
|
||
api.onEvent("soulseek:responded", () => {
|
||
network.respondedCount += 1;
|
||
});
|
||
|
||
// ---- the Soulseek hub's seats: rooms, chat, buddies ----
|
||
api.onEvent("soulseek:room-list", (rooms) => {
|
||
slsk.rooms = rooms;
|
||
});
|
||
api.onEvent("soulseek:room-joined", (p) => {
|
||
const prev = slsk.joined[p.room];
|
||
slsk.joined[p.room] = { users: p.users, messages: prev?.messages ?? [] };
|
||
slsk.activeRoom ??= p.room;
|
||
});
|
||
api.onEvent("soulseek:room-left", (p) => {
|
||
delete slsk.joined[p.room];
|
||
if (slsk.activeRoom === p.room) {
|
||
slsk.activeRoom = Object.keys(slsk.joined)[0] ?? null;
|
||
}
|
||
});
|
||
api.onEvent("soulseek:room-user", (p) => {
|
||
const room = slsk.joined[p.room];
|
||
if (!room) return;
|
||
if (p.joined) {
|
||
if (!room.users.some((u) => u.username === p.user.username)) {
|
||
room.users.push(p.user);
|
||
}
|
||
} else {
|
||
room.users = room.users.filter((u) => u.username !== p.username);
|
||
}
|
||
});
|
||
api.onEvent("soulseek:room-message", (p) => {
|
||
const room = slsk.joined[p.room];
|
||
if (!room) return;
|
||
room.messages.push({ username: p.username, message: p.message, ts: p.ts });
|
||
trimFeed(room.messages, SLSK_FEED_MAX);
|
||
});
|
||
api.onEvent("soulseek:private-message", (p) => {
|
||
const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 });
|
||
chat.messages.push({
|
||
me: false,
|
||
text: p.message,
|
||
ts: (p.timestamp || 0) * 1000 || Date.now(),
|
||
});
|
||
trimFeed(chat.messages, SLSK_FEED_MAX);
|
||
if (slsk.activeChat !== p.username) chat.unread += 1;
|
||
});
|
||
api.onEvent("soulseek:user-state", (p) => {
|
||
const s = (slsk.buddyState[p.username] ??= {});
|
||
Object.assign(s, p);
|
||
});
|
||
|
||
api.onEvent("soulseek:download", (d) => {
|
||
// Preserve last known progress on terminal states that carry none.
|
||
const prev = downloads.items[d.id];
|
||
if (prev && d.total === 0) {
|
||
d.received = prev.received;
|
||
d.total = prev.total;
|
||
}
|
||
downloads.items[d.id] = d;
|
||
if (TERMINAL_TRANSFER_STATES.has(d.state)) {
|
||
// Preserve every member of an active multi-file burst so the
|
||
// title-bar aggregate remains monotonic. Prune once it drains.
|
||
trimTransferHistory(downloads.items, DOWNLOAD_HISTORY_MAX, true);
|
||
}
|
||
});
|
||
|
||
api.onEvent("soulseek:player", (ev) => {
|
||
// Revision-gateway bumps at the ONE choke point every native source
|
||
// change flows through. Each new decode ("buffering") is a new
|
||
// playback generation for the focus bridge; track-ended is the
|
||
// auto-advance revision. A pause caused by the focus coordinator
|
||
// emits "paused", which deliberately bumps nothing.
|
||
if (ev.kind === "buffering") {
|
||
notePlaybackGeneration();
|
||
bumpMainRevision("nativeSourceChange");
|
||
} else if (ev.kind === "track-ended") {
|
||
bumpMainRevision("autoAdvance");
|
||
} else if (ev.kind === "stopped") {
|
||
bumpMainRevision("userStop");
|
||
}
|
||
switch (ev.kind) {
|
||
case "buffering":
|
||
player.status = "buffering";
|
||
player.position = 0;
|
||
latestPlayerPosition = 0;
|
||
latestPlayerDuration = player.duration;
|
||
// Mirror the store rather than zeroing it, the same way
|
||
// latestPlayerDuration does directly above. These counters are
|
||
// what a window restore copies back, and a fully cached track
|
||
// is reported COMPLETE before the audio thread ever says
|
||
// "buffering" — zeroing here threw that away, so minimising and
|
||
// restoring turned a finished transfer back into the
|
||
// indeterminate "Streaming from <peer>" sweep with no further
|
||
// event coming to correct it.
|
||
latestStreamReceived = player.streamReceived;
|
||
latestStreamTotal = player.streamTotal;
|
||
player.scrubbing = false;
|
||
player.error = "";
|
||
break;
|
||
case "playing": {
|
||
player.status = "playing";
|
||
// A track re-sourced by a session restore starts at zero; put
|
||
// the reader back where they were. Taken exactly once, so an
|
||
// ordinary resume later in the same track is unaffected.
|
||
const seekTo = takeResumeSeek();
|
||
if (seekTo != null) seekPlayer(seekTo);
|
||
break;
|
||
}
|
||
case "paused":
|
||
player.status = "paused";
|
||
listenFlush();
|
||
break;
|
||
case "stopped":
|
||
player.scrubbing = false;
|
||
listenFlush();
|
||
stopPlayback();
|
||
break;
|
||
case "track-ended":
|
||
listenFlush();
|
||
if (ev.gapless) advanceGaplessly();
|
||
else nextTrack(true);
|
||
break;
|
||
case "position": {
|
||
// A set-recording slice shows slice-relative time, keeps its
|
||
// own duration (the engine reports the whole set's), and
|
||
// cuts to the next row at its end boundary.
|
||
const seg = player.queue[player.queueIndex]?.segment;
|
||
if (seg) {
|
||
const rel = Math.max(0, ev.secs - seg.start);
|
||
latestPlayerPosition = rel;
|
||
if (!player.scrubbing && ui.windowActive) player.position = rel;
|
||
listenTick(rel);
|
||
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
|
||
break;
|
||
}
|
||
latestPlayerPosition = ev.secs;
|
||
if (ev.duration != null) latestPlayerDuration = ev.duration;
|
||
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
|
||
if (ev.duration != null && player.duration !== ev.duration) {
|
||
player.duration = ev.duration;
|
||
}
|
||
listenTick(ev.secs);
|
||
break;
|
||
}
|
||
case "error":
|
||
player.status = "idle";
|
||
player.error = ev.message;
|
||
break;
|
||
}
|
||
});
|
||
|
||
api.onEvent("soulseek:stream", (s) => {
|
||
// Rate is measured here because nothing upstream reports it. A rolling
|
||
// average over ~1.5s of samples, not the instantaneous delta: Soulseek
|
||
// transfers arrive in bursts, and a per-event figure reads as a number
|
||
// flickering between 0 and several MB/s rather than a speed.
|
||
noteStreamRate(s.received);
|
||
latestStreamReceived = s.received;
|
||
latestStreamTotal = s.total;
|
||
if (ui.windowActive) {
|
||
player.streamReceived = s.received;
|
||
player.streamTotal = s.total;
|
||
player.streamSpeed = streamRate();
|
||
}
|
||
const cur = player.queue[player.queueIndex];
|
||
if (s.done) {
|
||
// Fully cached → final waveform, and the peer's slot is
|
||
// definitely free, so make sure the next track is pre-buffering.
|
||
if (cur?.stream) {
|
||
loadStreamWaveform(cur, true);
|
||
// The first attempt may have read a file too young to parse;
|
||
// the complete one carries whatever tag the peer sent.
|
||
void ensureStreamArt(cur);
|
||
}
|
||
prepareFollowing();
|
||
} else if (
|
||
ui.windowActive
|
||
&& cur?.stream
|
||
// Until the FIRST peaks land, retry fast — the earliest decodes
|
||
// race the transfer's opening seconds and often see too little
|
||
// file to succeed, and audio audibly playing over a still-empty
|
||
// lane was the gap being closed here. Once bars exist, the
|
||
// geometric decode thresholds make 3s the right cadence.
|
||
&& performance.now() - waveRefreshAt > (player.waveform.length ? 3000 : 700)
|
||
) {
|
||
// Still downloading: refresh the partial waveform so it grows
|
||
// with the transfer.
|
||
waveRefreshAt = performance.now();
|
||
loadStreamWaveform(cur);
|
||
}
|
||
});
|
||
|
||
// A next-track pre-buffer finished downloading: append it to the audio
|
||
// sink so the upcoming transition is gapless.
|
||
api.onEvent("soulseek:preload", (p) => {
|
||
if (p.done) tryAppendNext();
|
||
});
|
||
|
||
let libraryRefreshTimer = null;
|
||
api.onEvent("soulseek:library-changed", () => {
|
||
clearTimeout(libraryRefreshTimer);
|
||
libraryRefreshTimer = setTimeout(refreshLibrary, 400);
|
||
});
|
||
|
||
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
|
||
|
||
// The home warm task announces each artist it finishes; refreshing that
|
||
// one slot turns the walk into a page that fills in as it goes. A follow
|
||
// change re-walks (and re-warms) the whole set.
|
||
api.onEvent("soulseek:home-warmed", (artistId) => {
|
||
const f = homeStore.follows.find((x) => x.artist_id === artistId);
|
||
if (f && homeStore.started) void loadHomeArtist(f);
|
||
});
|
||
api.onEvent("soulseek:follows-changed", () => {
|
||
if (homeStore.started) void loadHome(true);
|
||
});
|
||
|
||
// The OS media card and the hardware media keys, arriving through Windows'
|
||
// SMTC (src-tauri/src/smtc.rs). They run the SAME functions the on-screen
|
||
// transport runs — the queue, shuffle, repeat and the re-source of a
|
||
// restored track all live here, and a second implementation over there
|
||
// would be a second set of rules to keep in step. `toggle` is what a
|
||
// single play/pause key sends; `play`/`pause` come from the card's
|
||
// separate buttons, and both are safe to send at any status because
|
||
// togglePlayPause reads the real one.
|
||
api.onEvent("soulseek:media-button", (ev) => {
|
||
switch (ev?.button) {
|
||
case "play":
|
||
if (player.status !== "playing") togglePlayPause();
|
||
break;
|
||
case "pause":
|
||
if (player.status === "playing") togglePlayPause();
|
||
break;
|
||
case "toggle":
|
||
togglePlayPause();
|
||
break;
|
||
case "next":
|
||
nextTrack(false);
|
||
break;
|
||
case "previous":
|
||
prevTrack();
|
||
break;
|
||
}
|
||
});
|
||
|
||
// The backend batches search responses (~150ms ticks) so a broad search
|
||
// can't flood the IPC bridge or trigger a reactive flush per response.
|
||
api.onEvent("soulseek:search-results", (batch) => {
|
||
for (const r of batch) {
|
||
// Every tab is live at once; the token says whose results these
|
||
// are. A background tab fills in exactly like the visible one.
|
||
const sess = searchTabs.list.find(
|
||
(s) => s.searching && s.token != null && s.token === r.token,
|
||
);
|
||
if (!sess) continue;
|
||
sess.responses += 1;
|
||
const searchFiles = filesOf(sess.id);
|
||
for (const f of r.files) {
|
||
const key = `${r.username}|${f.folder}`;
|
||
let g = sess.groups[key];
|
||
let raw = searchFiles.get(key);
|
||
if (!g) {
|
||
// Cap group count so a popular search can't flood the DOM.
|
||
if (searchFiles.size >= 300) continue;
|
||
raw = { files: [], exts: new Map() };
|
||
searchFiles.set(key, raw);
|
||
g = sess.groups[key] = {
|
||
key,
|
||
username: r.username,
|
||
folder: f.folder,
|
||
folderName: f.folder.split("\\").pop() || r.username,
|
||
hasFreeSlot: r.has_free_upload_slot,
|
||
speed: r.upload_speed,
|
||
queue: r.queue_length,
|
||
// Scalar summaries the UI renders; the file list
|
||
// itself stays out of reactive state.
|
||
fileCount: 0,
|
||
audioCount: 0,
|
||
totalSize: 0,
|
||
topExt: "",
|
||
maxBitrate: 0,
|
||
};
|
||
} else if (!raw) {
|
||
// Defensive recovery if a late batch races raw-list
|
||
// pruning. Keep the existing scalar summary intact.
|
||
raw = { files: [], exts: new Map() };
|
||
searchFiles.set(key, raw);
|
||
}
|
||
if (raw.files.length < 500) {
|
||
raw.files.push(f);
|
||
g.fileCount += 1;
|
||
g.totalSize += f.size;
|
||
if (AUDIO_EXTS.has(f.extension)) g.audioCount += 1;
|
||
if (f.bitrate && f.bitrate > g.maxBitrate) g.maxBitrate = f.bitrate;
|
||
const n = (raw.exts.get(f.extension) ?? 0) + 1;
|
||
raw.exts.set(f.extension, n);
|
||
if (n >= (raw.exts.get(g.topExt) ?? 0)) g.topExt = f.extension;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function connect(username, password, remember) {
|
||
app.connection = "connecting";
|
||
app.loginError = "";
|
||
try {
|
||
await api.connectAndLogin(username, password, remember);
|
||
app.connection = "online";
|
||
app.username = username;
|
||
app.showLogin = false;
|
||
} catch (e) {
|
||
app.connection = "offline";
|
||
app.loginError = String(e);
|
||
}
|
||
}
|
||
|
||
export async function autoConnect() {
|
||
// A reloaded WebView boots against a backend that may ALREADY be logged
|
||
// in; connect_stored then fails ("already connected") and the catch below
|
||
// used to stamp the page offline — the first play attempt raised the
|
||
// login modal against a healthy session. Adopt the live session instead.
|
||
try {
|
||
const status = await api.appStatus();
|
||
if (status?.connected_as) {
|
||
app.connection = "online";
|
||
app.username = status.connected_as;
|
||
app.showLogin = false;
|
||
return;
|
||
}
|
||
} catch {
|
||
// status unavailable — fall through to the normal connect path
|
||
}
|
||
// A failed automatic attempt arms a backoff: the server throttles rapid
|
||
// reconnects into "timed out" silence, and every dev-reload of the page
|
||
// lands here — an auto-dial during an outage only extends the cooldown
|
||
// (paid for on 2026-08-03, twice). The USER can always dial through the
|
||
// login modal; only the automatic path waits.
|
||
if (performance.now() - lastAutoConnectFailAt < AUTO_CONNECT_BACKOFF_MS) {
|
||
app.connection = "offline";
|
||
return;
|
||
}
|
||
try {
|
||
const creds = await api.getCredentials();
|
||
if (!creds.has_password) return;
|
||
app.connection = "connecting";
|
||
await api.connectStored();
|
||
app.connection = "online";
|
||
app.username = creds.username;
|
||
} catch {
|
||
// One more status probe before declaring offline: the connect may
|
||
// have lost a race with an existing session rather than failed.
|
||
try {
|
||
const status = await api.appStatus();
|
||
if (status?.connected_as) {
|
||
app.connection = "online";
|
||
app.username = status.connected_as;
|
||
return;
|
||
}
|
||
} catch {}
|
||
lastAutoConnectFailAt = performance.now();
|
||
app.connection = "offline";
|
||
}
|
||
}
|
||
const AUTO_CONNECT_BACKOFF_MS = 3 * 60 * 1000;
|
||
let lastAutoConnectFailAt = Number.NEGATIVE_INFINITY;
|
||
|
||
export async function downloadFile(username, file) {
|
||
await api.downloadFile(username, file.path, file.size);
|
||
}
|
||
|
||
/** Stream a search-result file: play while it downloads to cache. */
|
||
export async function streamFile(username, file, subLabel) {
|
||
autoAlbum = null;
|
||
player.queue = [
|
||
{
|
||
path: file.path,
|
||
title: file.name.replace(/\.[^.]+$/, ""),
|
||
sub: subLabel ?? username,
|
||
duration: file.duration ?? null,
|
||
art: null,
|
||
artUrl: null,
|
||
stream: { username, size: file.size },
|
||
quality: qualityOf(file),
|
||
},
|
||
];
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
playQueueIndex(0);
|
||
}
|
||
|
||
/** Play a finished download from disk (single track). */
|
||
export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||
bumpMainRevision("userPlay");
|
||
autoAlbum = null;
|
||
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
|
||
player.queueIndex = 0;
|
||
player.track = { title, sub: subLabel ?? "", source: "local", path };
|
||
player.status = "buffering";
|
||
player.duration = null;
|
||
player.streamTotal = 0;
|
||
resetStreamRate();
|
||
loadWaveform(path);
|
||
setAccentFromArt(artPath);
|
||
await api.playLocal(path, null);
|
||
}
|
||
|
||
/** Play an album/playlist (list of library tracks) starting at `start`.
|
||
* `artPath` is the album cover; playlist tracks carry their own art_path.
|
||
*
|
||
* A row with `needsResolve` is a playlist STREAM row: full identity, no
|
||
* source yet. It queues as a pending item that resolves through the normal
|
||
* ladder when reached (or by the look-ahead a track early), so Play on a
|
||
* mixed playlist keeps every row in order instead of skipping the streams. */
|
||
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
||
autoAlbum = null;
|
||
player.queue = tracks.map((t) => ({
|
||
path: t.path ?? null,
|
||
title: t.title,
|
||
sub: subLabel,
|
||
artist: t.artist ?? null,
|
||
album: t.album ?? null,
|
||
duration: t.duration_secs ?? null,
|
||
art: t.art_path ?? (t.needsResolve ? null : artPath),
|
||
artUrl: t.artUrl ?? null,
|
||
trackId: t.trackId ?? null,
|
||
quality: t.path ? localQuality(t) : null,
|
||
// Which playlist OCCURRENCE this queue item came from, so the playlist
|
||
// page highlights exactly the row that is playing — a recording that
|
||
// appears twice must light up once. Null for album playback.
|
||
plItemId: t.plItemId ?? null,
|
||
// A set-recording SLICE: {start, end} seconds into the file at
|
||
// `path` (end null = to the file's end). The playback layer seeks
|
||
// in at start and cuts at end; display time is slice-relative.
|
||
segment: t.segment ?? null,
|
||
}));
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
playQueueIndex(start);
|
||
}
|
||
|
||
function playQueueIndex(i) {
|
||
const item = player.queue[i];
|
||
if (!item) return;
|
||
// Every queue start is a user-command + queue + playback revision for the
|
||
// preview focus gateway (auto-advance re-enters here too — that bump is
|
||
// correct: a restore must never resurrect a track the queue moved past).
|
||
bumpMainRevision("queueJumpOrNextPrev");
|
||
player.queueIndex = i;
|
||
ensureShuffleOrder();
|
||
player.track = {
|
||
title: item.title,
|
||
sub: item.sub,
|
||
// Explicit artist when the queue builder knew it; the play-history
|
||
// tracker falls back to parsing "artist — album" out of sub.
|
||
artist: item.artist ?? null,
|
||
source: item.stream ? "stream" : "local",
|
||
path: item.path,
|
||
};
|
||
player.status = "buffering";
|
||
player.duration = item.duration;
|
||
// A playlist stream row queued without a source yet: resolve it now, then
|
||
// re-enter with the source attached. The item mutates in place, so a
|
||
// repeat visit (prev/next, repeat-all) is instant.
|
||
if (!item.path && !item.stream) {
|
||
player.track.source = "stream";
|
||
player.streamTotal = 0;
|
||
void resolvePendingQueueItem(i, item);
|
||
return;
|
||
}
|
||
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 —
|
||
// that throttled the FIRST partial waveform by up to three seconds of
|
||
// audible-but-laneless playback.
|
||
waveRefreshAt = 0;
|
||
resetStreamRate();
|
||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||
appendedNextPath = null;
|
||
setAccentFromArtUrl(item.artUrl ?? null);
|
||
// A row that arrived with no cover of its own (a setlist row the
|
||
// catalog could not verify, a by-name stream) gets one looked up
|
||
// while it plays — the file's own embedded art first.
|
||
void ensureStreamArt(item);
|
||
loadStreamWaveform(item); // instant when already fully cached
|
||
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
|
||
.then(() => {
|
||
// Ordered behind play_stream so the gapless append can't
|
||
// race the generation bump on the audio thread.
|
||
prepareFollowing();
|
||
})
|
||
.catch((e) => {
|
||
player.error = String(e);
|
||
});
|
||
scheduleAlbumFill();
|
||
return;
|
||
}
|
||
player.streamTotal = 0;
|
||
if (item.segment) {
|
||
// A set-recording slice: the lane shows the SLICE's own waveform (a
|
||
// ranged decode — a 90-minute skyline says nothing about a 4-minute
|
||
// stretch), and the decoder comes up at the slice start. The early
|
||
// cut at segment.end is enforced by the position handler.
|
||
loadWaveform(item.path, item.segment);
|
||
setAccentFromArt(item.art ?? null);
|
||
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
|
||
player.error = String(e);
|
||
});
|
||
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
|
||
// a pending row after one still resolves a track early.
|
||
prepareFollowing();
|
||
return;
|
||
}
|
||
loadWaveform(item.path);
|
||
setAccentFromArt(item.art ?? null);
|
||
api.playLocal(item.path, item.duration).catch((e) => {
|
||
player.error = String(e);
|
||
});
|
||
prepareFollowing();
|
||
}
|
||
|
||
/** Play a specific queue position (Now Playing panel track clicks). */
|
||
export function jumpToQueueIndex(i) {
|
||
if (i < 0 || i >= player.queue.length || i === player.queueIndex) return;
|
||
player.position = 0;
|
||
playQueueIndex(i);
|
||
}
|
||
|
||
function gaplessNextIndex() {
|
||
if (player.repeat === "one") return player.queueIndex;
|
||
// Shuffle included: the order is predetermined, so the engine can arm
|
||
// the SAME track the panel promises.
|
||
const i = peekNextIndex();
|
||
// Set-recording slices never join the gapless machinery: a preload or
|
||
// append would come up at the FILE's start, not the slice's, and the
|
||
// slice's early cut is driven by the position handler, not the
|
||
// engine's track-ended.
|
||
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
|
||
return -1;
|
||
}
|
||
return i;
|
||
}
|
||
|
||
function preloadFollowingTrack() {
|
||
const next = gaplessNextIndex();
|
||
if (next < 0) return;
|
||
const item = player.queue[next];
|
||
// Gapless preload is local-only; a pending stream row has no path at all.
|
||
if (item.stream || !item.path) return;
|
||
api.preloadLocal(item.path, item.duration).catch(() => {});
|
||
}
|
||
|
||
// Quietly pre-buffer the next queued stream into the cache. The backend
|
||
// no-ops when it's already cached or being fetched; single-slot peers just
|
||
// queue the request until the current transfer finishes.
|
||
// Paths already asked for this session — the window re-arms on every
|
||
// advance, and a path once requested is cached or in flight; asking again
|
||
// would only duplicate peer traffic.
|
||
let preloadRequestedPaths = new Set();
|
||
|
||
/** Pull the next few stream tracks' bytes into the cache — the on-deck
|
||
* window. Nearest first, so the imminent boundary always has the head
|
||
* start; rows with no source yet are skipped here and picked up by the
|
||
* pending-row lookahead, whose resolve calls back into this. */
|
||
function preloadUpcomingStreams() {
|
||
for (const i of peekNextIndices(STREAM_PRELOAD_DEPTH)) {
|
||
const item = player.queue[i];
|
||
// Segments play a slice of a recording that is already on disk.
|
||
if (!item?.stream || !item.path || item.segment) continue;
|
||
if (preloadRequestedPaths.has(item.path)) continue;
|
||
preloadRequestedPaths.add(item.path);
|
||
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {
|
||
// Failed to even start — let a later window retry it.
|
||
preloadRequestedPaths.delete(item.path);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Once the next queued stream is fully cached, append it to the active
|
||
// audio sink so the boundary is gapless (same mechanism as local albums).
|
||
// Keyed per target path so it fires once per upcoming transition.
|
||
let appendedNextPath = null;
|
||
|
||
async function tryAppendNext() {
|
||
const next = gaplessNextIndex();
|
||
const item = next >= 0 ? player.queue[next] : null;
|
||
if (!item?.stream || item.path === appendedNextPath) return;
|
||
if (player.status === "idle") return;
|
||
// Claim the slot BEFORE awaiting, and give it back only if the append
|
||
// fails. Setting it after the await is a check-then-act race: a fully
|
||
// cached track has two callers firing in the same tick — the stream
|
||
// event's `done` branch and play_stream's own `.then()` — and both would
|
||
// pass the guard above, append the same source to the sink twice, and
|
||
// leave the audio a whole track behind the panel from the next boundary on.
|
||
const claimed = appendedNextPath;
|
||
appendedNextPath = item.path;
|
||
try {
|
||
const ok = await api.preloadCachedStream(
|
||
item.stream.username,
|
||
item.path,
|
||
item.stream.size,
|
||
item.duration ?? null,
|
||
);
|
||
if (!ok && appendedNextPath === item.path) appendedNextPath = claimed;
|
||
} catch {
|
||
// cache incomplete or command unavailable — the normal (non-gapless)
|
||
// advance still works.
|
||
if (appendedNextPath === item.path) appendedNextPath = claimed;
|
||
}
|
||
}
|
||
|
||
/** Arm every next-track preparation that applies: a LOCAL next preloads
|
||
* into the sink, a resolved STREAM next pre-buffers and offers the
|
||
* gapless append, and a PENDING playlist row resolves a track early so
|
||
* it can join the same chain. Each is a cheap no-op when it does not
|
||
* apply — call this at every track start and every gapless boundary,
|
||
* whatever the current source is. (The playlist lesson: only the
|
||
* album-shaped paths were armed, so a mixed playlist went seamless for
|
||
* exactly one boundary and stalled on every one after it.) */
|
||
function prepareFollowing() {
|
||
preloadFollowingTrack();
|
||
preloadUpcomingStreams();
|
||
tryAppendNext();
|
||
scheduleQueueLookahead();
|
||
}
|
||
|
||
function advanceGaplessly() {
|
||
const next = gaplessNextIndex();
|
||
// The backend only labels a boundary gapless when it has this source
|
||
// ready in the same sink. If state changed meanwhile, fall back safely.
|
||
if (next < 0) return nextTrack(true);
|
||
const item = player.queue[next];
|
||
player.queueIndex = next;
|
||
player.position = 0;
|
||
player.duration = item.duration;
|
||
latestPlayerPosition = 0;
|
||
latestPlayerDuration = item.duration ?? null;
|
||
latestStreamReceived = 0;
|
||
latestStreamTotal = 0;
|
||
player.status = "playing";
|
||
appendedNextPath = null;
|
||
if (item.stream) {
|
||
// A pre-buffered stream track, playing from its completed cache file.
|
||
// Report it COMPLETE rather than zero-of-zero: `streamTotal === 0` is
|
||
// the UI's "no byte counts yet" state, so zeroing a finished track left
|
||
// the header showing the indeterminate "Streaming from <peer>" sweep
|
||
// with no further event ever coming to correct it. Gapless is only
|
||
// offered for a fully cached file — that is what made it appendable —
|
||
// so its size IS its received count.
|
||
const size = item.stream.size;
|
||
player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path };
|
||
player.streamReceived = size;
|
||
player.streamTotal = size;
|
||
latestStreamReceived = size;
|
||
latestStreamTotal = size;
|
||
resetStreamRate();
|
||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||
setAccentFromArtUrl(item.artUrl ?? null);
|
||
loadStreamWaveform(item);
|
||
prepareFollowing();
|
||
scheduleAlbumFill();
|
||
return;
|
||
}
|
||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
||
loadWaveform(item.path);
|
||
setAccentFromArt(item.art ?? null);
|
||
prepareFollowing();
|
||
}
|
||
|
||
// ---- album continuation ----
|
||
// A single catalog song remembers its album: when it starts playing, the rest
|
||
// of the album is resolved (a background Soulseek search per track) and
|
||
// appended to the queue, so the album keeps going.
|
||
//
|
||
// It used to append exactly ONE track and wait for the next track start to
|
||
// append the one after, which meant a song started from search or an artist
|
||
// page showed a queue of one or two entries however long the record was. It
|
||
// still resolves one at a time — a track cannot be queued before a peer with
|
||
// it is found — but it no longer stops after the first, so the queue fills in
|
||
// over the following minute instead of never.
|
||
let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending }
|
||
|
||
/** Start the album fill only after the queue has sat still for a moment.
|
||
*
|
||
* The fill fires a Soulseek SEARCH for every remaining track the source
|
||
* memory does not know, and each play used to start it immediately — so
|
||
* stress-clicking through a record restarted it on every click and flooded
|
||
* the server with searches (2026-08-03: a fresh login was dropped inside
|
||
* ~90 seconds of exactly that, and the reconnect throttle followed). A new
|
||
* play cancels the pending start, so rapid track-hopping sends NOTHING;
|
||
* normal listening starts the fill four seconds into the track, hours
|
||
* before it is needed. */
|
||
const ALBUM_FILL_DELAY_MS = 4000;
|
||
let albumFillTimer = null;
|
||
function scheduleAlbumFill() {
|
||
clearTimeout(albumFillTimer);
|
||
albumFillTimer = setTimeout(() => {
|
||
albumFillTimer = null;
|
||
void maybeExtendAlbumQueue();
|
||
}, ALBUM_FILL_DELAY_MS);
|
||
}
|
||
|
||
async function maybeExtendAlbumQueue() {
|
||
const ctx = autoAlbum;
|
||
if (!ctx || ctx.pending) return;
|
||
// Resume from the LAST thing queued, not from what is playing: the fill
|
||
// outlives the track it started on, and a run cut short (offline, a
|
||
// catalog hiccup) has to be able to pick up where it stopped on the next
|
||
// track start. When the album is already complete this finds nothing left
|
||
// to do and simply retires the context.
|
||
const anchor = player.queue[player.queue.length - 1];
|
||
if (!anchor?.stream) return;
|
||
ctx.pending = true;
|
||
try {
|
||
if (!ctx.tracks) {
|
||
const page = await api.catalogAlbum(ctx.albumId);
|
||
if (autoAlbum !== ctx) return;
|
||
ctx.tracks = page.tracks;
|
||
ctx.albumTitle ??= page.album.title ?? null;
|
||
ctx.cover ??= page.album.cover ?? null;
|
||
}
|
||
let idx = ctx.tracks.findIndex((t) => t.id === anchor.trackId);
|
||
if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(anchor.title));
|
||
if (idx < 0) {
|
||
autoAlbum = null;
|
||
return;
|
||
}
|
||
for (let i = idx + 1; i < ctx.tracks.length; i++) {
|
||
const t = ctx.tracks[i];
|
||
if (app.connection !== "online") return;
|
||
// A remembered, still-byte-complete source skips this track's
|
||
// background search entirely — the common case for a record that
|
||
// has streamed before. Same bail guards as the searched path.
|
||
const known = await cachedStreamSourceFor(t.id ?? null, t.artist, t.title);
|
||
if (known) {
|
||
if (autoAlbum !== ctx || !player.queue.includes(anchor)) return;
|
||
const item = {
|
||
path: known.path,
|
||
title: t.title,
|
||
sub: t.artist,
|
||
artist: t.artist ?? null,
|
||
album: ctx.albumTitle ?? null,
|
||
duration: known.duration ?? t.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: ctx.cover,
|
||
trackId: t.id,
|
||
stream: { username: known.username, size: known.size },
|
||
quality: known.quality ?? null,
|
||
};
|
||
player.queue.push(item);
|
||
preloadNextStream();
|
||
tryAppendNext();
|
||
continue;
|
||
}
|
||
// Same sanitized ladder as resolve(), inlined because this is a
|
||
// background append that must not toggle resolver.active (that
|
||
// would block user-triggered resolutions while it searches).
|
||
const goodCount = (rs) =>
|
||
songCandidates(rs, t.title, true, ctx.albumTitle, t.duration_secs, t.artist).length;
|
||
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||
let artistIncluded = true;
|
||
let responses = primary ? await collectSources(primary, 7000, goodCount) : [];
|
||
if (!responses.length && fallback && fallback !== primary) {
|
||
responses = await collectSources(fallback, 7000, goodCount);
|
||
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
|
||
} else if (primary) {
|
||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||
}
|
||
// Bail if the user moved on while we were searching. BOTH tests
|
||
// are load-bearing:
|
||
//
|
||
// - context identity, because every path that replaces the queue
|
||
// clears autoAlbum;
|
||
// - the anchor still being IN the queue, because context identity
|
||
// alone is not enough. streamSong installs autoAlbum only after
|
||
// `await waitForStreamStart`, which polls the GLOBAL player
|
||
// status; a second play command issued during that window nulls
|
||
// autoAlbum first and streamSong then re-installs its own
|
||
// context over the top. The fill would go on appending one
|
||
// album's tracks to the other album's live queue.
|
||
//
|
||
// Not the currently-playing item: this loop deliberately outlives
|
||
// the track it started on, so advancing within the same album must
|
||
// not stop it. The anchor is a queue MEMBER, so it survives our own
|
||
// appends and disappears exactly when the queue is replaced.
|
||
if (autoAlbum !== ctx || !player.queue.includes(anchor)) return;
|
||
const best = songCandidates(
|
||
responses, t.title, true, ctx.albumTitle, t.duration_secs, t.artist, !artistIncluded,
|
||
)[0];
|
||
if (!best) continue; // no streamable source — skip this track
|
||
const item = {
|
||
path: best.file.path,
|
||
title: t.title,
|
||
sub: t.artist,
|
||
artist: t.artist ?? null,
|
||
album: ctx.albumTitle ?? null,
|
||
duration: best.file.duration ?? t.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: ctx.cover,
|
||
trackId: t.id,
|
||
stream: { username: best.username, size: best.file.size },
|
||
quality: qualityOf(best.file),
|
||
};
|
||
player.queue.push(item);
|
||
// Teach the memory as the fill goes, so the album's LATER tracks
|
||
// are known even if playback never reaches them this session.
|
||
rememberStreamSource(item);
|
||
// Both are guarded against repeat work (preloadRequestedPaths,
|
||
// appendedNextPath), so calling them per append costs nothing once
|
||
// the upcoming window is already prepared.
|
||
preloadUpcomingStreams();
|
||
tryAppendNext();
|
||
}
|
||
autoAlbum = null; // every remaining track queued or skipped
|
||
} catch {
|
||
// transient (offline, catalog hiccup) — retried on the next track start
|
||
} finally {
|
||
ctx.pending = false;
|
||
}
|
||
}
|
||
|
||
// ---- pending playlist stream rows -------------------------------------------
|
||
// A mixed playlist queues its stream rows as PENDING items — identity, no
|
||
// source. They resolve through the same ladder the album fill uses: the
|
||
// remembered-source memory first (free), then the sanitized search. One
|
||
// resolution is in flight per item, shared between play-on-reach and the
|
||
// look-ahead, so hammering next can't stack searches for one row.
|
||
|
||
const pendingResolves = new WeakMap();
|
||
|
||
/** Resolve one pending descriptor to a playable source, or null.
|
||
* `streamOnly=false` ranks by the DOWNLOAD source prefs instead (quality
|
||
* floor, quality priority) — the bulk download uses it; playback never. */
|
||
async function resolveStreamSourceFor(t, budgetMs = 7000, streamOnly = true) {
|
||
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||
if (known) {
|
||
return {
|
||
path: known.path,
|
||
duration: known.duration ?? t.duration ?? null,
|
||
stream: { username: known.username, size: known.size },
|
||
quality: known.quality ?? null,
|
||
};
|
||
}
|
||
if (app.connection !== "online") return null;
|
||
const goodCount = (rs) =>
|
||
songCandidates(rs, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist).length;
|
||
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||
let artistIncluded = true;
|
||
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
|
||
if (!responses.length && fallback && fallback !== primary) {
|
||
responses = await collectSources(fallback, budgetMs, goodCount);
|
||
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
|
||
} else if (primary) {
|
||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||
}
|
||
const best = songCandidates(
|
||
responses, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||
)[0];
|
||
if (!best) return null;
|
||
return {
|
||
path: best.file.path,
|
||
duration: best.file.duration ?? t.duration ?? null,
|
||
stream: { username: best.username, size: best.file.size },
|
||
quality: qualityOf(best.file),
|
||
};
|
||
}
|
||
|
||
/** Single-flight source resolution for a queue item; mutates it in place. */
|
||
function resolveQueueItem(item) {
|
||
const inFlight = pendingResolves.get(item);
|
||
if (inFlight) return inFlight;
|
||
const p = resolveStreamSourceFor(item)
|
||
.then((src) => {
|
||
if (src) Object.assign(item, src);
|
||
return src;
|
||
})
|
||
.catch(() => null)
|
||
.finally(() => pendingResolves.delete(item));
|
||
pendingResolves.set(item, p);
|
||
return p;
|
||
}
|
||
|
||
// Two sourceless rows in a row is a signal (a whole stretch that cannot
|
||
// resolve), and auto-advancing through a long dead playlist would fire one
|
||
// Soulseek search per row — the exact flood the album fill's delay exists to
|
||
// prevent. The streak resets on any success.
|
||
let pendingFailStreak = 0;
|
||
|
||
async function resolvePendingQueueItem(i, item) {
|
||
const src = await resolveQueueItem(item);
|
||
// The world may have moved on mid-search: queue replaced, or the user is
|
||
// on another row. An enriched item keeps its source either way.
|
||
if (player.queue[i] !== item || player.queueIndex !== i) return;
|
||
if (src) {
|
||
pendingFailStreak = 0;
|
||
playQueueIndex(i);
|
||
return;
|
||
}
|
||
item.unplayable = true;
|
||
player.error = `No streamable sources found for “${item.title}”`;
|
||
pendingFailStreak += 1;
|
||
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
|
||
pendingFailStreak = 0;
|
||
stopPlayback();
|
||
return;
|
||
}
|
||
nextTrack(true);
|
||
}
|
||
|
||
// Resolve the NEXT pending row a track early, on the album fill's own
|
||
// anti-flood delay, so the boundary into a stream row starts instantly
|
||
// instead of stalling on a live search.
|
||
let queueLookaheadTimer = null;
|
||
function scheduleQueueLookahead() {
|
||
clearTimeout(queueLookaheadTimer);
|
||
queueLookaheadTimer = setTimeout(() => {
|
||
queueLookaheadTimer = null;
|
||
void resolveNextPendingItem();
|
||
}, ALBUM_FILL_DELAY_MS);
|
||
}
|
||
|
||
// Rows the LOOKAHEAD has already searched once. Without this, a row whose
|
||
// search came up dry would re-enter the window every tick and re-search on
|
||
// a 4-second loop — the precise flood the pacing exists to prevent. One
|
||
// lookahead try per row; the play-on-reach resolver still retries it when
|
||
// its turn actually comes. Queue rebuilds mint new row objects, so the set
|
||
// forgets naturally.
|
||
const lookaheadTried = new WeakSet();
|
||
|
||
async function resolveNextPendingItem() {
|
||
// The whole on-deck window wants sources, but Soulseek searches stay
|
||
// ONE at a time on the album fill's own pacing — resolve the nearest
|
||
// untried pending row, then schedule the next tick if more remain.
|
||
const pendingIn = (indices) =>
|
||
indices.find((i) => {
|
||
const it = player.queue[i];
|
||
return it && !it.path && !it.stream && !it.unplayable && !lookaheadTried.has(it);
|
||
});
|
||
const next = pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH));
|
||
if (next == null) return;
|
||
const item = player.queue[next];
|
||
lookaheadTried.add(item);
|
||
const src = await resolveQueueItem(item);
|
||
if (!player.queue.includes(item)) return;
|
||
if (src) {
|
||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||
// append, exactly as if it had been queued with its source.
|
||
preloadUpcomingStreams();
|
||
tryAppendNext();
|
||
}
|
||
if (pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)) != null) scheduleQueueLookahead();
|
||
}
|
||
|
||
// ---- waveform ----
|
||
|
||
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||
// by the time the lanes folded buckets into display bars every bar was ~2s
|
||
// averaged twice, and the transients the lane exists to show were gone before
|
||
// shaping ever ran (measured in waveshape.js's header). 2000 is ~128ms per
|
||
// bucket on a typical track — finer than any lane's bar pitch — and the
|
||
// command's ceiling is 4096, so this is only a constant.
|
||
const WAVE_BUCKETS = 2000;
|
||
let waveReqPath = null;
|
||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||
let waveRefreshAt = 0;
|
||
|
||
async function loadWaveform(path, range = null) {
|
||
player.waveform = [];
|
||
player.wavePending = !!path;
|
||
// A range = a set-recording slice's lane; each slice keys its own
|
||
// request so two slices of one file never answer for each other.
|
||
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
|
||
waveReqPath = req;
|
||
if (!path) return;
|
||
try {
|
||
const peaks = await api.waveform(
|
||
path,
|
||
WAVE_BUCKETS,
|
||
range?.start ?? null,
|
||
range?.end ?? null,
|
||
);
|
||
// Ignore if the track changed while we were computing.
|
||
if (waveReqPath === req) {
|
||
player.waveform = peaks;
|
||
player.waveformCoverage = 1;
|
||
}
|
||
} catch {
|
||
if (waveReqPath === req) player.waveform = [];
|
||
} finally {
|
||
// Either way the request is over; a failed local decode rests as dots
|
||
// rather than swelling forever at a waveform that will never come.
|
||
if (waveReqPath === req) player.wavePending = false;
|
||
}
|
||
}
|
||
|
||
/** Waveform for a streamed queue item, computed from its cache file. Empty
|
||
* (plain progress bar) until the download completes; the stream-done event
|
||
* calls this again to swap the full waveform in mid-track. */
|
||
let waveSeq = 0;
|
||
let streamWaveInFlight = false;
|
||
let queuedStreamWave = null;
|
||
|
||
async function loadStreamWaveform(item, final = false) {
|
||
const key = `stream:${item.stream.username}|${item.path}`;
|
||
if (waveReqPath !== key) {
|
||
player.waveform = [];
|
||
// Pending until the FIRST partial decode lands; after that the lane
|
||
// has bars and the streaming rise owns the story. A mid-stream refresh
|
||
// that fails just keeps the bars it already has.
|
||
player.wavePending = true;
|
||
}
|
||
waveReqPath = key;
|
||
// A late partial FLAC decode can take close to the three-second refresh
|
||
// interval. Coalesce all overlap into the newest request, retaining a
|
||
// final completion request so the full-quality waveform always wins.
|
||
if (streamWaveInFlight) {
|
||
const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final;
|
||
queuedStreamWave = { item, key, final: final || preserveFinal };
|
||
return;
|
||
}
|
||
streamWaveInFlight = true;
|
||
let next = { item, key, final };
|
||
try {
|
||
while (next) {
|
||
const request = next;
|
||
next = null;
|
||
const seq = ++waveSeq;
|
||
try {
|
||
// { coverage, peaks }: the backend reports how much of the
|
||
// track the peaks describe, measured from the SAME file read
|
||
// that produced them — the lanes' rise frontier is anchored to
|
||
// this, never to the live byte counter, so the drawn shape
|
||
// cannot stretch between refreshes.
|
||
const slice = await api.streamWaveform(
|
||
request.item.stream.username,
|
||
request.item.path,
|
||
request.item.stream.size,
|
||
WAVE_BUCKETS,
|
||
);
|
||
if (
|
||
waveReqPath === request.key
|
||
&& seq === waveSeq
|
||
&& slice.peaks.length > 0
|
||
) {
|
||
player.waveform = slice.peaks;
|
||
player.waveformCoverage = Math.max(0, Math.min(1, slice.coverage));
|
||
player.wavePending = false;
|
||
}
|
||
} catch {
|
||
// Peaks stay as they were. A failed FINAL decode is the end of
|
||
// the line though — stop the loading swell so the lane rests
|
||
// as dots instead of promising a waveform that will not come.
|
||
if (request.final && waveReqPath === request.key) {
|
||
player.wavePending = false;
|
||
}
|
||
}
|
||
const queued = queuedStreamWave;
|
||
queuedStreamWave = null;
|
||
// Only the newest still-visible track matters. A queued final call
|
||
// is never dropped; the backend independently confirms completion.
|
||
if (queued && waveReqPath === queued.key) next = queued;
|
||
}
|
||
} finally {
|
||
streamWaveInFlight = false;
|
||
}
|
||
}
|
||
|
||
// ---- queue navigation, shuffle, repeat ----
|
||
|
||
/** A Fisher-Yates permutation of 0..n, optionally anchored so `firstIndex`
|
||
* leads — the track already playing when an order is (re)built stays where
|
||
* it is and the shuffle happens to everything after it. */
|
||
function shuffledIndices(n, firstIndex = null) {
|
||
const idx = Array.from({ length: n }, (_, i) => i);
|
||
for (let i = n - 1; i > 0; i -= 1) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[idx[i], idx[j]] = [idx[j], idx[i]];
|
||
}
|
||
if (firstIndex != null) {
|
||
const at = idx.indexOf(firstIndex);
|
||
if (at > 0) {
|
||
idx.splice(at, 1);
|
||
idx.unshift(firstIndex);
|
||
}
|
||
}
|
||
return idx;
|
||
}
|
||
|
||
/** Keep the predetermined order valid. Called from the MUTATION points —
|
||
* every track start, the shuffle/repeat toggles, session restore — never
|
||
* from render-time reads: `peekNextIndex` runs inside $derived, where
|
||
* writing state is (rightly) an error. */
|
||
function ensureShuffleOrder() {
|
||
if (!player.shuffle) return;
|
||
const n = player.queue.length;
|
||
if (n === 0) {
|
||
player.shuffleOrder = [];
|
||
player.shuffleNextOrder = null;
|
||
return;
|
||
}
|
||
if (player.shuffleOrder.length !== n || !player.shuffleOrder.includes(player.queueIndex)) {
|
||
player.shuffleOrder = shuffledIndices(n, player.queueIndex);
|
||
player.shuffleNextOrder = null;
|
||
}
|
||
// Within a preload-window of the cycle's end with repeat "all": the
|
||
// future crosses into a reshuffle, so make that reshuffle NOW and keep
|
||
// it — the up-next display, the 3-deep pre-buffer and the advance all
|
||
// need the SAME answer.
|
||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||
if (pos >= n - STREAM_PRELOAD_DEPTH && player.repeat === "all") {
|
||
if (!player.shuffleNextOrder || player.shuffleNextOrder.length !== n) {
|
||
const next = shuffledIndices(n);
|
||
// A cycle boundary should not replay the same track back to back.
|
||
if (n > 1 && next[0] === player.queueIndex) {
|
||
[next[0], next[1]] = [next[1], next[0]];
|
||
}
|
||
player.shuffleNextOrder = next;
|
||
}
|
||
} else {
|
||
player.shuffleNextOrder = null;
|
||
}
|
||
}
|
||
|
||
/** How many upcoming tracks keep their bytes ON DECK in the stream cache.
|
||
* One was the minimum for gapless boundaries; three gives a slow peer two
|
||
* whole songs of runway, and the window slides — every advance tops it
|
||
* back up. The SEARCHES that find sources stay one-at-a-time regardless
|
||
* (the album fill's own anti-flood posture); only the byte-pulling widens. */
|
||
const STREAM_PRELOAD_DEPTH = 3;
|
||
|
||
/** The next `count` queue indices in PLAY order — through the shuffle
|
||
* order when it's on, across the repeat-all boundary when the pre-made
|
||
* next cycle exists. Pure: reads state only. Duplicates collapse (a
|
||
* two-track queue on repeat has one upcoming track, not two entries). */
|
||
export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) {
|
||
const n = player.queue.length;
|
||
if (n === 0) return [];
|
||
let seq;
|
||
let pos;
|
||
if (player.shuffle) {
|
||
seq = player.shuffleOrder;
|
||
if (player.repeat === "all" && player.shuffleNextOrder?.length === n) {
|
||
seq = seq.concat(player.shuffleNextOrder);
|
||
}
|
||
pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||
if (pos < 0) return [];
|
||
} else {
|
||
seq = Array.from({ length: n }, (_, i) => i);
|
||
if (player.repeat === "all") seq = seq.concat(seq);
|
||
pos = player.queueIndex;
|
||
}
|
||
const out = [];
|
||
const seen = new Set([player.queueIndex]);
|
||
for (let at = pos + 1; at < seq.length && out.length < count; at += 1) {
|
||
const i = seq[at];
|
||
if (seen.has(i)) continue;
|
||
seen.add(i);
|
||
out.push(i);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Debug-overlay window into the on-deck machinery. Read-only: the indices
|
||
* the pre-buffer is arming, plus the module-private bits a QUEUE readout
|
||
* needs — which path is already appended to the sink, whether a path has
|
||
* been asked of the backend this session, and whether the lookahead already
|
||
* spent its one search try on a still-pending row. The membership checks are
|
||
* closures because both containers are reassigned/forgotten on queue
|
||
* rebuilds — a copied array would go stale mid-tick. */
|
||
export function onDeckSnapshot() {
|
||
return {
|
||
indices: peekNextIndices(STREAM_PRELOAD_DEPTH),
|
||
appendedNextPath,
|
||
isRequested: (path) => preloadRequestedPaths.has(path),
|
||
wasLookaheadTried: (item) => (item ? lookaheadTried.has(item) : false),
|
||
};
|
||
}
|
||
|
||
/** The queue index that plays after the current track, or -1 when playback
|
||
* would stop there. THE one answer for what comes next — the up-next card,
|
||
* the gapless preload/append, the pending-row lookahead and the advance
|
||
* itself all read this, so they cannot disagree. Pure: reads state only. */
|
||
export function peekNextIndex() {
|
||
const n = player.queue.length;
|
||
if (n === 0) return -1;
|
||
if (player.shuffle) {
|
||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||
if (pos < 0) return -1; // order not built yet — a mutation point will
|
||
if (pos + 1 < player.shuffleOrder.length) return player.shuffleOrder[pos + 1];
|
||
if (player.repeat !== "all") return -1;
|
||
return player.shuffleNextOrder?.length === n ? player.shuffleNextOrder[0] : -1;
|
||
}
|
||
const next = player.queueIndex + 1;
|
||
return next < n ? next : player.repeat === "all" ? 0 : -1;
|
||
}
|
||
|
||
export function nextTrack(auto = false) {
|
||
const n = player.queue.length;
|
||
if (n === 0) {
|
||
stopPlayback();
|
||
return;
|
||
}
|
||
// Auto-advance on a repeat-one track replays it.
|
||
if (auto && player.repeat === "one") {
|
||
playQueueIndex(player.queueIndex);
|
||
return;
|
||
}
|
||
|
||
let next;
|
||
if (player.shuffle) {
|
||
ensureShuffleOrder();
|
||
next = peekNextIndex();
|
||
if (next < 0) return stopPlayback();
|
||
// Crossing the cycle boundary consumes the pre-made reshuffle.
|
||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||
if (pos === player.shuffleOrder.length - 1 && player.shuffleNextOrder) {
|
||
player.shuffleOrder = player.shuffleNextOrder;
|
||
player.shuffleNextOrder = null;
|
||
}
|
||
} else {
|
||
next = player.queueIndex + 1;
|
||
if (next >= n) {
|
||
if (player.repeat === "all") next = 0;
|
||
else return stopPlayback();
|
||
}
|
||
}
|
||
playQueueIndex(next);
|
||
}
|
||
|
||
/** Engine time for a TRACK-time value. A set-recording slice's engine
|
||
* clock is the whole file's, so the slice offset is added exactly here —
|
||
* every transport call that hands the engine a time (seek, scratch,
|
||
* scratch release) must convert through this door. */
|
||
export function engineTime(secs) {
|
||
const seg = player.queue[player.queueIndex]?.segment;
|
||
return seg ? seg.start + Math.max(0, secs) : secs;
|
||
}
|
||
|
||
/** Seek in TRACK time; see `engineTime`. */
|
||
export function seekPlayer(secs) {
|
||
api.playerControl("seek", engineTime(secs));
|
||
}
|
||
|
||
export function prevTrack() {
|
||
// Restart the current track if we're more than 3s in.
|
||
if (player.position > 3) {
|
||
seekPlayer(0);
|
||
return;
|
||
}
|
||
// Under shuffle, "previous" is the order's previous — the track that
|
||
// actually just played — not the neighbouring playlist row.
|
||
if (player.shuffle) {
|
||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||
if (pos > 0) playQueueIndex(player.shuffleOrder[pos - 1]);
|
||
else seekPlayer(0);
|
||
return;
|
||
}
|
||
const prev = player.queueIndex - 1;
|
||
if (prev >= 0) playQueueIndex(prev);
|
||
else seekPlayer(0);
|
||
}
|
||
|
||
export function toggleShuffle() {
|
||
player.shuffle = !player.shuffle;
|
||
player.shuffleOrder = [];
|
||
player.shuffleNextOrder = null;
|
||
ensureShuffleOrder();
|
||
}
|
||
|
||
export function cycleRepeat() {
|
||
player.repeat =
|
||
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
|
||
// Repeat decides whether a cycle boundary has a future — recheck it.
|
||
ensureShuffleOrder();
|
||
}
|
||
|
||
function stopPlayback() {
|
||
clearTimeout(albumFillTimer);
|
||
albumFillTimer = null;
|
||
player.status = "idle";
|
||
player.track = null;
|
||
player.position = 0;
|
||
player.duration = null;
|
||
player.queue = [];
|
||
player.queueIndex = -1;
|
||
player.waveform = [];
|
||
player.waveformCoverage = 1;
|
||
player.wavePending = false;
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
latestPlayerPosition = 0;
|
||
latestPlayerDuration = null;
|
||
latestStreamReceived = 0;
|
||
latestStreamTotal = 0;
|
||
autoAlbum = null;
|
||
appendedNextPath = null;
|
||
preloadRequestedPaths = new Set();
|
||
setAccentFromArt(null);
|
||
}
|
||
|
||
export async function downloadFolder(group) {
|
||
for (const f of filesForGroup(group.key)) {
|
||
await api.downloadFile(group.username, f.path, f.size);
|
||
}
|
||
}
|
||
|
||
// Dev-only handle to THIS module instance for CDP-driven verification —
|
||
// a dynamic import from the console can resolve to a second instance when
|
||
// Vite has HMR-stamped the app's own import URL.
|
||
if (import.meta.env.DEV && typeof window !== "undefined") {
|
||
window.__state = {
|
||
app, player, resolver, catalog, ui, discovery, downloads, libraryStore,
|
||
streamSong, streamCatalogAlbum, autoConnect, nextTrack, prevTrack, loadDiscovery,
|
||
saveStream, refreshLibrary, openCatalogArtist, openCatalogAlbum,
|
||
playAlbum, jumpToQueueIndex, toggleNowPlaying,
|
||
artBroken, markArtBroken,
|
||
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
|
||
catalogGo, goToView, openEntry, navBack, navForward, navToCrumb, nav,
|
||
slsk,
|
||
};
|
||
}
|
||
|
||
/** Bring a tab forward. The whole Search UI reads through `search`, so this
|
||
* assignment IS the switch. */
|
||
export function selectSearchTab(id) {
|
||
if (sessionById(id)) searchTabs.activeId = id;
|
||
}
|
||
|
||
/** Close one tab: stop its search on the wire and free its file map. */
|
||
export function closeSearchTab(id) {
|
||
const i = searchTabs.list.findIndex((s) => s.id === id);
|
||
if (i < 0) return;
|
||
const [sess] = searchTabs.list.splice(i, 1);
|
||
clearTimeout(searchStopTimers.get(id));
|
||
searchStopTimers.delete(id);
|
||
searchFilesById.delete(id);
|
||
if (sess.token != null) api.stopSearch(sess.token).catch(() => {});
|
||
if (searchTabs.activeId === id) {
|
||
// Prefer the neighbour to the left — Qt's behaviour, and it keeps a
|
||
// rapid close-close-close from jumping across the strip.
|
||
const next = searchTabs.list[Math.max(0, i - 1)];
|
||
searchTabs.activeId = next ? next.id : null;
|
||
}
|
||
}
|
||
|
||
/** Retire the oldest tab that is no longer live, so the strip stays bounded
|
||
* without ever killing a search that is still trickling in. */
|
||
function evictOldestSearchTab() {
|
||
if (searchTabs.list.length < SEARCH_TABS_MAX) return;
|
||
const victim =
|
||
searchTabs.list.find((s) => !s.searching && s.id !== searchTabs.activeId)
|
||
?? searchTabs.list.find((s) => s.id !== searchTabs.activeId)
|
||
?? searchTabs.list[0];
|
||
if (victim) closeSearchTab(victim.id);
|
||
}
|
||
|
||
export async function runSearch(query) {
|
||
// Re-running a query that already owns a tab focuses it instead of
|
||
// opening a duplicate — Qt does the same, and it keeps the strip honest.
|
||
const existing = searchTabs.list.find((s) => s.query === query);
|
||
if (existing) {
|
||
searchTabs.activeId = existing.id;
|
||
if (existing.searching || existing.token != null) return;
|
||
// A closed-out tab re-runs in place, keeping its position.
|
||
existing.groups = {};
|
||
filesOf(existing.id).clear();
|
||
existing.responses = 0;
|
||
existing.searching = true;
|
||
try {
|
||
await armSearch(existing, query);
|
||
} catch (err) {
|
||
// Keep the tab — it still holds the previous run's results.
|
||
existing.searching = false;
|
||
throw err;
|
||
}
|
||
return;
|
||
}
|
||
evictOldestSearchTab();
|
||
searchTabs.list.push({
|
||
id: ++searchSeq,
|
||
token: null,
|
||
query,
|
||
searching: true,
|
||
responses: 0,
|
||
// key: `${username}|${folder}` -> group summary (scalars only — the
|
||
// file lists live outside reactive state, see searchFilesById).
|
||
groups: {},
|
||
});
|
||
// Read the entry back out: THIS is the reactive proxy the store holds, and
|
||
// the one every later write (token, searching) has to land on.
|
||
const sess = searchTabs.list[searchTabs.list.length - 1];
|
||
searchTabs.activeId = sess.id;
|
||
searchTabs.seeded = "";
|
||
try {
|
||
await armSearch(sess, query);
|
||
} catch (err) {
|
||
// The wire refused (offline, mid-reconnect). A brand-new tab holds
|
||
// nothing worth keeping, and a tab stuck "live" forever is a lie.
|
||
closeSearchTab(sess.id);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Ask the wire for a token and hold this session's 45s routing window. */
|
||
async function armSearch(sess, query) {
|
||
const token = await api.startSearch(query);
|
||
if (!sessionById(sess.id)) {
|
||
// Closed while we awaited the token — nothing left to route into.
|
||
api.stopSearch(token).catch(() => {});
|
||
return;
|
||
}
|
||
sess.token = token;
|
||
// Soulseek searches trickle in; stop routing after 45s.
|
||
clearTimeout(searchStopTimers.get(sess.id));
|
||
searchStopTimers.set(
|
||
sess.id,
|
||
setTimeout(() => {
|
||
searchStopTimers.delete(sess.id);
|
||
if (sess.token !== token) return;
|
||
sess.searching = false;
|
||
// The UI exposes only the top 120 groups. Keep their full file
|
||
// lists and discard invisible raw payloads after routing closes;
|
||
// scalar summaries remain available for the result count.
|
||
trimHiddenSearchFiles(sess);
|
||
api.stopSearch(token).catch(() => {});
|
||
}, 45_000),
|
||
);
|
||
}
|
||
|
||
// Dev-only: expose the LIVE module instance for the CDP verification loop.
|
||
// Vite's HMR graph can serve a probe's bare-URL dynamic import as a SECOND
|
||
// instance of this module (?t=-stamped vs bare), so a probe that imports by
|
||
// URL may read/mutate a phantom copy while the app runs another. Probes must
|
||
// use this global instead of importing.
|
||
/* ------------------------------------------------------------------ session
|
||
* Where you were, and what you were listening to, across a reload.
|
||
*
|
||
* The audio thread outlives the webview. A dev reload — or a crash, or a
|
||
* WebView2 restart — leaves it playing while the page comes back with an empty
|
||
* store, so the player bar drew a MOVING scrubber under the words "Nothing
|
||
* playing" and the sidebar showed nothing playing either: position events kept
|
||
* arriving, but `status`, `track` and `queue` had gone with the page.
|
||
*
|
||
* Two halves, and they have different owners:
|
||
*
|
||
* TRANSPORT — playing/paused and the position — belongs to the audio thread,
|
||
* and is asked for at boot (`api.playerState`). Never guessed
|
||
* from the snapshot: only the audio thread knows whether the
|
||
* sound survived.
|
||
* EVERYTHING ELSE — which song, the queue around it, where you had navigated
|
||
* to and how far down the page — is the frontend's own and is
|
||
* the part worth writing down.
|
||
*
|
||
* `app_status` already recovers the Soulseek connection this way at boot; this
|
||
* is the same repair for playback and navigation.
|
||
*/
|
||
const SESSION_KEY = "soulstream.session.v1";
|
||
/** Crumbs kept. The trail only ever renders a handful; the rest is history for
|
||
* the back arrow, and 60 entries of it is not worth the quota. */
|
||
const SESSION_NAV_MAX = 20;
|
||
const SESSION_TICK_MS = 3000;
|
||
/** Position moves constantly and is not part of the change signature, so a
|
||
* long-playing track still gets its progress written down periodically. */
|
||
const SESSION_MAX_STALE_MS = 12_000;
|
||
|
||
let sessionRestoring = false;
|
||
let sessionSignature = "";
|
||
let sessionWrittenAt = 0;
|
||
|
||
/** A crumb, minus anything that would be silly to store. `art` is frequently a
|
||
* data: URL for library records — megabytes each, and they re-resolve from the
|
||
* art cache on their own. */
|
||
function packCrumb(entry) {
|
||
if (!entry) return null;
|
||
const art = typeof entry.art === "string" && entry.art.startsWith("data:") ? null : entry.art;
|
||
return { ...entry, art };
|
||
}
|
||
|
||
function writeSession() {
|
||
if (sessionRestoring) return;
|
||
try {
|
||
// Puts the reader's scroll position on the entry they are standing on,
|
||
// which is the same thing the back arrow relies on.
|
||
captureScroll();
|
||
const snapshot = {
|
||
v: 1,
|
||
view: ui.view,
|
||
albumId: ui.albumId,
|
||
playlistId: ui.playlistId,
|
||
nav: {
|
||
stack: nav.stack.slice(-SESSION_NAV_MAX).map(packCrumb),
|
||
origin: packCrumb(nav.origin),
|
||
},
|
||
player: {
|
||
queue: player.queue,
|
||
queueIndex: player.queueIndex,
|
||
track: player.track,
|
||
position: player.position,
|
||
duration: player.duration,
|
||
catalogRef: player.catalogRef,
|
||
shuffle: player.shuffle,
|
||
shuffleOrder: player.shuffleOrder,
|
||
repeat: player.repeat,
|
||
volume: player.volume,
|
||
muted: player.muted,
|
||
},
|
||
};
|
||
localStorage.setItem(SESSION_KEY, JSON.stringify(snapshot));
|
||
sessionWrittenAt = Date.now();
|
||
} catch {
|
||
// Quota, or a profile with storage disabled. The session is a
|
||
// convenience; nothing downstream may depend on it having been written.
|
||
// Still stamp the attempt: with the stamp only on success, a failing
|
||
// setItem left `stale` permanently true and this whole function —
|
||
// captureScroll's forced layout included — re-ran on every 3s tick.
|
||
sessionWrittenAt = Date.now();
|
||
}
|
||
}
|
||
|
||
/** Cheap "has anything worth saving changed" probe, run on a timer rather than
|
||
* from a reactive effect: the alternative is subscribing to a store that ticks
|
||
* five times a second, and the only field that does is deliberately excluded. */
|
||
let sessionWritePending = false;
|
||
/** The tick's writes go through idle scheduling: writeSession's
|
||
* captureScroll reads scrollTop — a forced style+layout while scroll-driven
|
||
* animations hold style dirty (the exact read the artist page's scroll
|
||
* machinery avoids) — so it must not land mid-scroll on a 3s clock.
|
||
* Direct writeSession callers (unload-style flushes) are unaffected. */
|
||
function scheduleSessionWrite() {
|
||
if (sessionWritePending) return;
|
||
sessionWritePending = true;
|
||
const write = () => {
|
||
sessionWritePending = false;
|
||
writeSession();
|
||
};
|
||
if (typeof requestIdleCallback === "function") {
|
||
requestIdleCallback(write, { timeout: 2000 });
|
||
} else {
|
||
setTimeout(write, 250);
|
||
}
|
||
}
|
||
|
||
function sessionTick() {
|
||
if (sessionRestoring) return;
|
||
const signature = [
|
||
ui.view, ui.albumId, ui.playlistId, nav.stack.length,
|
||
player.status, player.queueIndex, player.queue.length,
|
||
player.shuffle, player.repeat, player.volume, player.muted,
|
||
].join("|");
|
||
// Periodic position refresh only while PLAYING: a paused track's position
|
||
// does not move, and pausing itself flips the signature, so the paused
|
||
// position is captured once and the 12s forced rewrite (scroll read +
|
||
// full-queue stringify + sync localStorage) stops.
|
||
const stale = player.status === "playing" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS;
|
||
if (signature === sessionSignature && !stale) return;
|
||
sessionSignature = signature;
|
||
scheduleSessionWrite();
|
||
}
|
||
|
||
/** Re-source a track that was restored without its audio — an app restart,
|
||
* where the queue survived in the snapshot but the sound did not. Resuming a
|
||
* live track is the ordinary path and goes straight through. */
|
||
let resumeSeekSecs = null;
|
||
export function resumePlayback() {
|
||
if (player.status === "paused" && !player.needsResource) {
|
||
api.playerControl("resume");
|
||
return;
|
||
}
|
||
const index = player.queueIndex;
|
||
if (index < 0 || index >= player.queue.length) return;
|
||
// Pick the track up where it was left rather than at zero; applied once the
|
||
// audio thread reports it is actually playing (see the `playing` case).
|
||
resumeSeekSecs = player.position > 1 ? player.position : null;
|
||
player.needsResource = false;
|
||
playQueueIndex(index);
|
||
}
|
||
|
||
/** The play/pause button's whole behaviour, in one place — the player bar's
|
||
* control and the OS media card's both call this, so a media key can never do
|
||
* something subtly different from clicking the button. Not a bare `resume`:
|
||
* a track restored from the last session has no audio loaded behind it and
|
||
* has to be re-sourced from the queue, which resumePlayback handles. */
|
||
export function togglePlayPause() {
|
||
if (player.status === "playing") api.playerControl("pause");
|
||
else if (player.status === "paused") resumePlayback();
|
||
}
|
||
|
||
/** Consumed by the player-event handler the first time a restored track
|
||
* reaches `playing`. */
|
||
export function takeResumeSeek() {
|
||
const secs = resumeSeekSecs;
|
||
resumeSeekSecs = null;
|
||
return secs;
|
||
}
|
||
|
||
/**
|
||
* Put back what the last page left behind. Called once at boot, after the
|
||
* stores exist.
|
||
*/
|
||
export async function restoreSession() {
|
||
let saved = null;
|
||
try {
|
||
saved = JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null");
|
||
} catch {
|
||
saved = null;
|
||
}
|
||
if (!saved || saved.v !== 1) {
|
||
setInterval(sessionTick, SESSION_TICK_MS);
|
||
return;
|
||
}
|
||
sessionRestoring = true;
|
||
try {
|
||
// THE AUDIO THREAD IS THE AUTHORITY. A reload finds it still playing; a
|
||
// restart finds it idle, and the snapshot's own status would be a lie.
|
||
let transport = null;
|
||
try {
|
||
transport = await api.playerState();
|
||
} catch {
|
||
// Not running under Tauri, or IPC not up yet: treat as idle.
|
||
}
|
||
const saveState = saved.player ?? {};
|
||
if (typeof saveState.volume === "number") player.volume = saveState.volume;
|
||
player.muted = !!saveState.muted;
|
||
player.shuffle = !!saveState.shuffle;
|
||
player.repeat = saveState.repeat ?? "off";
|
||
// The predetermined order survives a restart with its queue; anything
|
||
// stale is rebuilt at the next mutation point.
|
||
player.shuffleOrder = Array.isArray(saveState.shuffleOrder) ? saveState.shuffleOrder : [];
|
||
|
||
const item = saveState.queue?.[saveState.queueIndex] ?? null;
|
||
if (item && saveState.track) {
|
||
player.queue = saveState.queue;
|
||
player.queueIndex = saveState.queueIndex;
|
||
player.track = saveState.track;
|
||
player.duration = saveState.duration ?? null;
|
||
player.catalogRef = saveState.catalogRef ?? { albumId: null, trackId: null };
|
||
const live = transport && transport.status !== "idle";
|
||
player.status = live ? transport.status : "paused";
|
||
player.position = live ? (transport.position ?? 0) : (saveState.position ?? 0);
|
||
if (live && transport.duration != null) player.duration = transport.duration;
|
||
// Nothing is loaded in the audio thread, so the play button has to
|
||
// re-source rather than resume — see `resumePlayback`.
|
||
player.needsResource = !live;
|
||
ensureShuffleOrder();
|
||
// The bar's artwork and the app accent follow the track exactly as
|
||
// they would had it just been started.
|
||
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
|
||
else setAccentFromArt(item.art ?? null);
|
||
// The lane and the stream badge are rebuilt from the BACKEND's
|
||
// truth, not the snapshot. Neither survives a reload on its own:
|
||
// the waveform was measured out of the audio bytes, and the byte
|
||
// counters reset to 0 — which left the lane resting as dots and
|
||
// the header claiming "Streaming from <peer>" for a track whose
|
||
// cache file finished downloading sessions ago (Kyle, 2026-08-30,
|
||
// after an overnight sleep). A final-quality waveform request
|
||
// answers from whatever the cache holds — full, partial, or
|
||
// nothing, each rendering honestly — and the counters are set
|
||
// only when the cache is COMPLETE, so "Cached" is never claimed
|
||
// for a prefix.
|
||
if (item.stream) {
|
||
void api
|
||
.streamCacheLen(item.stream.username, item.path)
|
||
.then((len) => {
|
||
// By value, not identity: player.queue is a $state
|
||
// proxy, so reading the item back never === the raw
|
||
// snapshot object this closure holds.
|
||
const cur = player.queue[player.queueIndex];
|
||
if (cur?.path !== item.path || cur?.stream?.username !== item.stream.username) return;
|
||
if ((len ?? 0) >= item.stream.size) {
|
||
player.streamTotal = item.stream.size;
|
||
player.streamReceived = len;
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
void loadStreamWaveform(item, true);
|
||
} else {
|
||
void loadWaveform(item.path, item.segment ?? null);
|
||
}
|
||
}
|
||
|
||
// Where you were. The trail and the history arrows are restored first so
|
||
// that re-opening the page finds its own crumb already on top —
|
||
// `recordVisit` then updates it in place instead of pushing a duplicate.
|
||
const stack = (saved.nav?.stack ?? []).filter((e) => e && e.key);
|
||
if (stack.length) {
|
||
nav.stack = stack;
|
||
nav.future = [];
|
||
nav.origin = saved.nav?.origin ?? null;
|
||
ui.albumId = saved.albumId ?? null;
|
||
ui.playlistId = saved.playlistId ?? null;
|
||
const here = stack[stack.length - 1];
|
||
try {
|
||
await openEntry(here);
|
||
} catch {
|
||
ui.view = saved.view ?? "home";
|
||
}
|
||
// Back where the reader was on the page, once it has height to
|
||
// scroll — the same staged restore the back arrow uses.
|
||
const target = here.scroll ?? 0;
|
||
if (target > 0) {
|
||
requestAnimationFrame(() =>
|
||
restoreScroll(document.querySelector(".content"), target),
|
||
);
|
||
}
|
||
} else if (saved.view) {
|
||
ui.view = saved.view;
|
||
}
|
||
} finally {
|
||
sessionRestoring = false;
|
||
sessionSignature = "";
|
||
setInterval(sessionTick, SESSION_TICK_MS);
|
||
// A reload is the case this whole thing exists for, and it is the one
|
||
// moment the position is exactly known.
|
||
if (typeof window !== "undefined") {
|
||
window.addEventListener("beforeunload", writeSession);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (import.meta.env.DEV) {
|
||
globalThis.__soulstream = {
|
||
app, ui, player, catalog, resolver, nav,
|
||
libraryStore, recentsStore,
|
||
streamCatalogAlbum, streamSong, openCatalogArtist,
|
||
// The local counterparts of the two above, plus the transport. Lets a
|
||
// probe drive playback end to end without Soulseek — which matters
|
||
// beyond convenience: the server ignores the handshake after a burst
|
||
// of reconnects, so a verification run that streams costs a login.
|
||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||
jumpToQueueIndex,
|
||
catalogGo, refreshLibrary, refreshRecents,
|
||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||
slsk, search, runSearch, uploadToast, announceUpload,
|
||
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
|
||
downloads, network, sendDm, openChat,
|
||
};
|
||
}
|