// Shared reactive app state (Svelte 5 runes module). import * as api from "./api.js"; import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.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, }); export const ui = $state({ view: "home", albumId: null, // selected album in Library view playlistId: null, // selected playlist in Playlist view nowPlaying: true, // right-side Now Playing panel open sidebarCollapsed: false, // left nav collapsed to an icon rail fullscreenArt: false, // Spotify-style full-window now-playing view // 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, }); export function toggleNowPlaying() { ui.nowPlaying = !ui.nowPlaying; } export function toggleSidebar() { ui.sidebarCollapsed = !ui.sidebarCollapsed; } export const libraryStore = $state({ albums: [], loaded: false, }); export const playlistStore = $state({ playlists: [], }); export async function refreshPlaylists() { try { playlistStore.playlists = await api.listPlaylists(); } catch { // backend not available } } 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); } export async function getArt(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; } // 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; 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. Kicks off an * idempotent high-res upgrade for its album and returns the best URL to show * now — the verified upgrade once resolved, else the instant Deezer cover. The * player surfaces proxy the returned URL through getCatalogArt; because this * reads the reactive coverUpgrades map, their effect re-runs and re-proxies at * full resolution the moment the upgrade lands. */ export function playerArtSource(item) { const url = item?.artUrl; if (!url) return null; if (item.album) { resolveCovers( [{ cover: url, title: item.album }], item.artist ?? "", { sticky: true }, ); } return coverUpgrades[url] ?? 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; const CATALOG_HERO_GPU_CACHE_MAX = 2; 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 = 32 * MIB; const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * 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(); libraryStore.albums = await api.libraryAlbums(); libraryStore.loaded = true; window.__libErr = null; } catch (e) { // not connected to backend (browser preview) — leave empty window.__libErr = String(e); } } // ---- 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, albumLoading: false, albumPage: null, // { album, tracks } pendingTrackId: null, // a track to scroll to/flash on the album page error: "", }); 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, ); 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. const PAGE_REPLAY_RECIPE = "replay-v2"; 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(); /** Run a catalog search and show the results view. */ export async function catalogGo(query) { cancelCatalogArtistWarmup(); const q = query.trim(); if (!q) return; const request = ++catalogSearchRequest; ui.view = "catalog"; ui.albumId = null; catalog.query = q; catalog.results = null; catalog.loading = true; catalog.error = ""; try { const results = await api.catalogSearch(q); if (request !== catalogSearchRequest) return; catalog.results = results; 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]); } } catch (e) { if (request === catalogSearchRequest) catalog.error = String(e); } finally { if (request === catalogSearchRequest) catalog.loading = false; } } /** 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; // 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)), ); }; const extra = identityAlbums.filter( (a) => a?.title && !seen.has(normText(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. const mbTitles = new Set(identityAlbums.map((a) => normText(a.title))); const ownAlbums = identityAlbums.length ? (current.albums ?? []).filter( (a) => !a?.appears_on || mbTitles.has(normText(a.title)), ) : (current.albums ?? []); const merged = { ...current, identity_merged: true, 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, }; catalog.artistPage = merged; // 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. } } /** 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(); 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"; 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(() => {}); 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 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; // A verified-id navigation supersedes any still-running name search. albumResolveRequest += 1; const request = ++albumPageRequest; ui.view = "catalogAlbum"; catalog.albumPage = null; catalog.albumLoading = true; 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; // 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); } finally { if (request === albumPageRequest) catalog.albumLoading = false; } } 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. */ 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; } } /** Resolve an album by artist + title (via catalog search) and open its page — * used by the now-playing album/song links when there's no catalog albumId * (e.g. a local library track). */ export async function openAlbumByName(artist, title, opts = {}) { const request = ++albumResolveRequest; if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts); if (!artist || !title) return; try { const res = await api.catalogSearch(`${artist} ${title}`); if (request !== albumResolveRequest) return; const na = catNorm(artist); const nt = catNorm(title); // 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 match = res.albums?.find( (album) => catNorm(album.artist) === na && catNorm(album.title) === nt, ); if (match) openCatalogAlbum(match.id, opts); } catch { // no album match — leave the view as-is } } /** 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" }; } } /** Parse "Artist — Album" (or just "Artist") from the current track's sub. */ function currentArtistAlbum() { const sub = player.track?.sub ?? ""; return sub.includes(" — ") ? sub.split(" — ").map((s) => s.trim()) : [sub.trim(), ""]; } /** Now-playing meta links: jump to the artist / album / song's catalog page. */ export function openCurrentArtist() { const [artist] = currentArtistAlbum(); if (artist) openArtistByName(artist); } export function openCurrentAlbum() { if (player.catalogRef.albumId != null) { openCatalogAlbum(player.catalogRef.albumId); return; } const [artist, album] = currentArtistAlbum(); if (album) openAlbumByName(artist, album); } export function openCurrentSong() { const trackTitle = player.track?.title; if (player.catalogRef.albumId != null) { openCatalogAlbum(player.catalogRef.albumId, { trackId: player.catalogRef.trackId, 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" }); 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; } /** 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; } } /** 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.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.pageLoaded = true; discovery.pageWanted = false; } } 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: "", }); 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. const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]); const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]); // 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 } } void refreshSourcePrefs(); /** 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. */ 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. */ async function collectSources(query, maxMs, goodCount) { 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 }); } } out.sort((a, b) => b.score - a.score); 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 }); } out.sort((a, b) => b.score - a.score); 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", ); } /** Responsive card-sized variant of a canonical cover URL. The resolver keeps * its original/high-resolution winner for the player and album expander; the * 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */ export function gridCover(url) { if (!url || url.startsWith("data:")) return url; 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, "/500x500$1.$2", ); } if (parsed.hostname.endsWith("mzstatic.com")) { parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/500x500bb.jpg"); return parsed.toString(); } if (parsed.hostname.endsWith("coverartarchive.org")) { parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-500"); 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; 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; } if (!requireOnline()) return; autoAlbum = null; player.track = { title, sub: "finding best source…", source: "stream" }; player.status = "buffering"; 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; resolver.error = `No streamable sources found for “${title}”`; return; } for (const c of candidates.slice(0, 4)) { player.queue = [ { path: c.file.path, title, sub: 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), }, ]; player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; playQueueIndex(0); if (await waitForStreamStart(15000)) { // Keep the album going: resolve + pre-buffer the next album // track in the background, Spotify-style. if (opts.albumId) { autoAlbum = { albumId: opts.albumId, albumTitle: opts.album ?? null, cover: hqCover(opts.cover) ?? null, tracks: null, pending: false, }; maybeExtendAlbumQueue(); } 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 } } 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; } for (const folder of candidates.slice(0, 3)) { 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), }; }); player.catalogRef = { albumId: album.id ?? null, trackId: null }; playQueueIndex(0); if (await waitForStreamStart(15000)) 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; } export const search = $state({ token: null, query: "", searching: false, responses: 0, // key: `${username}|${folder}` -> group summary (scalars only — the file // lists live outside reactive state, see searchFiles below). groups: {}, }); // Raw per-group file lists. 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(). const searchFiles = new Map(); let searchStopTimer = null; let searchRequest = 0; function trimHiddenSearchFiles() { const visibleKeys = new Set( Object.values(search.groups) .sort( (a, b) => (b.hasFreeSlot - a.hasFreeSlot) || (b.speed - a.speed), ) .slice(0, 120) .map((group) => group.key), ); for (const key of searchFiles.keys()) { if (!visibleKeys.has(key)) searchFiles.delete(key); } } export function filesForGroup(key) { return searchFiles.get(key)?.files ?? []; } export const AUDIO_EXTS = new Set([ "flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff", ]); 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 }); 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]; } 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, error: "", // album play queue (local tracks) queue: [], queueIndex: -1, shuffle: false, repeat: "off", // off | all | one // waveform peaks (0..1) for the current local track, [] while loading waveform: [], // Which catalog entity is playing (drives now-playing badges). catalogRef: { albumId: null, trackId: null }, }); // 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, }); 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; 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) { listenFlush(); 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) { listenSession.playIdP = api.recordPlay(artist, title); listenSession.playIdP.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 = ""; } 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) => { 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; }); 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; latestStreamReceived = 0; latestStreamTotal = 0; player.scrubbing = false; player.error = ""; break; case "playing": player.status = "playing"; 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": 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) => { latestStreamReceived = s.received; latestStreamTotal = s.total; if (ui.windowActive) { player.streamReceived = s.received; player.streamTotal = s.total; } 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); preloadNextStream(); tryAppendNext(); } else if ( ui.windowActive && cur?.stream && performance.now() - waveRefreshAt > 3000 ) { // 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 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) => { let matched = 0; for (const r of batch) { if (!search.searching || r.token !== search.token) continue; matched += 1; for (const f of r.files) { const key = `${r.username}|${f.folder}`; let g = search.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 = search.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; } } } if (matched > 0) search.responses += matched; }); } 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 } 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 {} app.connection = "offline"; } } 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; 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. */ export function playAlbum(tracks, subLabel, start = 0, artPath = null) { autoAlbum = null; player.queue = tracks.map((t) => ({ path: t.path, title: t.title, sub: subLabel, artist: t.artist ?? null, duration: t.duration_secs ?? null, art: t.art_path ?? artPath, quality: localQuality(t), })); 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; 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; if (item.stream) { // Remote track streamed from a peer while it downloads to cache. player.streamReceived = 0; player.streamTotal = item.stream.size; if ("trackId" in item) player.catalogRef.trackId = item.trackId; appendedNextPath = null; setAccentFromArtUrl(item.artUrl ?? null); 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. preloadNextStream(); tryAppendNext(); }) .catch((e) => { player.error = String(e); }); maybeExtendAlbumQueue(); return; } player.streamTotal = 0; loadWaveform(item.path); setAccentFromArt(item.art ?? null); api.playLocal(item.path, item.duration).catch((e) => { player.error = String(e); }); preloadFollowingTrack(); } /** 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.shuffle) return -1; if (player.repeat === "one") return player.queueIndex; const next = player.queueIndex + 1; return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1; } function preloadFollowingTrack() { const next = gaplessNextIndex(); if (next < 0) return; const item = player.queue[next]; if (item.stream) return; // gapless preload is local-only 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. let lastPreloadedPath = null; function preloadNextStream() { const next = gaplessNextIndex(); const item = next >= 0 ? player.queue[next] : null; if (!item?.stream || item.path === lastPreloadedPath) return; lastPreloadedPath = item.path; api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {}); } // 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; try { const ok = await api.preloadCachedStream( item.stream.username, item.path, item.stream.size, item.duration ?? null, ); if (ok) appendedNextPath = item.path; } catch { // cache incomplete or command unavailable — the normal (non-gapless) // advance still works. } } 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 — no transfer to report. player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path }; player.streamReceived = 0; player.streamTotal = 0; if ("trackId" in item) player.catalogRef.trackId = item.trackId; setAccentFromArtUrl(item.artUrl ?? null); loadStreamWaveform(item); preloadNextStream(); tryAppendNext(); maybeExtendAlbumQueue(); return; } player.track = { title: item.title, sub: item.sub, source: "local", path: item.path }; loadWaveform(item.path); setAccentFromArt(item.art ?? null); preloadFollowingTrack(); } // ---- album continuation ---- // A single catalog song remembers its album: when it starts playing, the // album's next track is resolved (background Soulseek search) and appended // to the queue, one track per track start, so the album keeps going. let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending } async function maybeExtendAlbumQueue() { const ctx = autoAlbum; if (!ctx || ctx.pending) return; if (player.queueIndex !== player.queue.length - 1) return; // next already queued const cur = player.queue[player.queueIndex]; if (!cur?.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 === cur.trackId); if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(cur.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; // 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. if (autoAlbum !== ctx || player.queue[player.queueIndex] !== cur) 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 player.queue.push({ 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), }); preloadNextStream(); tryAppendNext(); return; } autoAlbum = null; // album finished } catch { // transient (offline, catalog hiccup) — retried on the next track start } finally { ctx.pending = false; } } // ---- waveform ---- const WAVE_BUCKETS = 400; let waveReqPath = null; // Throttle for progressive stream-waveform recomputes (see soulseek:stream). let waveRefreshAt = 0; async function loadWaveform(path) { player.waveform = []; waveReqPath = path; if (!path) return; try { const peaks = await api.waveform(path, WAVE_BUCKETS); // Ignore if the track changed while we were computing. if (waveReqPath === path) player.waveform = peaks; } catch { if (waveReqPath === path) player.waveform = []; } } /** 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 = []; 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 { const peaks = await api.streamWaveform( request.item.stream.username, request.item.path, request.item.stream.size, WAVE_BUCKETS, ); if ( waveReqPath === request.key && seq === waveSeq && peaks.length > 0 ) { player.waveform = peaks; } } catch { // stays empty — the buffer/progress bar renders instead } 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 ---- // Indices already played this shuffle cycle (module-private). let shuffleSeen = new Set(); function pickShuffleIndex(reset = false) { const n = player.queue.length; if (reset) shuffleSeen = new Set(); const remaining = []; for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i); if (remaining.length === 0) return -1; return remaining[Math.floor(Math.random() * remaining.length)]; } 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) { shuffleSeen.add(player.queueIndex); next = pickShuffleIndex(); if (next < 0) { if (player.repeat === "all") next = pickShuffleIndex(true); else return stopPlayback(); } } else { next = player.queueIndex + 1; if (next >= n) { if (player.repeat === "all") next = 0; else return stopPlayback(); } } playQueueIndex(next); } export function prevTrack() { // Restart the current track if we're more than 3s in. if (player.position > 3) { api.playerControl("seek", 0); return; } const prev = player.queueIndex - 1; if (prev >= 0) playQueueIndex(prev); else api.playerControl("seek", 0); } export function toggleShuffle() { player.shuffle = !player.shuffle; shuffleSeen = new Set([player.queueIndex]); } export function cycleRepeat() { player.repeat = player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off"; } function stopPlayback() { player.status = "idle"; player.track = null; player.position = 0; player.duration = null; player.queue = []; player.queueIndex = -1; player.waveform = []; player.catalogRef = { albumId: null, trackId: null }; latestPlayerPosition = 0; latestPlayerDuration = null; latestStreamReceived = 0; latestStreamTotal = 0; autoAlbum = null; appendedNextPath = null; lastPreloadedPath = null; 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, }; } export async function runSearch(query) { const request = ++searchRequest; clearTimeout(searchStopTimer); const previousToken = search.token; // Invalidate routing before awaiting the new backend token, otherwise // late batches from the previous search can populate the freshly-cleared // result set during this gap. search.token = null; if (previousToken != null) { api.stopSearch(previousToken).catch(() => {}); } search.query = query; search.groups = {}; searchFiles.clear(); search.responses = 0; search.searching = true; const token = await api.startSearch(query); if (request !== searchRequest) { api.stopSearch(token).catch(() => {}); return; } search.token = token; // Soulseek searches trickle in; stop routing after 45s. searchStopTimer = setTimeout(() => { if (search.token === token) { search.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(); 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. if (import.meta.env.DEV) { globalThis.__soulstream = { app, ui, player, catalog, resolver, streamCatalogAlbum, streamSong, openCatalogArtist, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, }; }