// 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 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 . 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> /** 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 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 * " 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, // "'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 " 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 " 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 " 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, }; }