// Shared reactive app state (Svelte 5 runes module). import * as api from "./api.js"; import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js"; import { nav, recordVisit, refreshVisit, captureScroll, restoreScroll, goBack, goForward, goToCrumb, } from "./navigation.svelte.js"; import { initPreviewBridge, bumpMainRevision, notePlaybackGeneration, } from "./preview/previewSession.svelte.js"; import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js"; export const app = $state({ connection: "offline", // offline | connecting | online username: "", loginError: "", showLogin: false, }); /** Now Playing panel width: the default the magnet snaps back to, and the * bounds the drag handle clamps between. Exported so the panel and the grid * can never disagree about them. */ export const NP_WIDTH_DEFAULT = 340; export const NP_WIDTH_MIN = 272; export const NP_WIDTH_MAX = 560; const NP_WIDTH_KEY = "soulstream.nowPlayingWidth"; function readPanelWidth() { try { const raw = Number(localStorage.getItem(NP_WIDTH_KEY)); if (!Number.isFinite(raw) || raw <= 0) return NP_WIDTH_DEFAULT; return Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, raw)); } catch { return NP_WIDTH_DEFAULT; } } export const ui = $state({ view: "home", albumId: null, // selected album in Library view playlistId: null, // selected playlist in Playlist view // The Now Playing panel is EXPANDED vs COLLAPSED, never gone: collapsing it // leaves the rail (see NowPlaying.svelte), the same way the left nav // collapses to an icon rail rather than disappearing. nowPlaying: true, sidebarCollapsed: false, // left nav collapsed to an icon rail // User-dragged width of the expanded panel. Persisted, unlike the other ui // flags: a width you set by hand and lost on every launch would be worse // than not being able to set it at all. nowPlayingWidth: readPanelWidth(), // True only while the grip is held. The panel's width and the grid column // it sits in share one clock (see App.svelte), so BOTH have to leave the // 0.28s ease for a drag — the panel alone would outgrow its own column. nowPlayingDragging: false, // Full-window artwork lightbox. `null` when closed; otherwise a descriptor // of whatever cover to show — { src, local, title, subtitle } — so any // surface can open it, not only the playing track. artwork: null, // 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; } /** Set the expanded panel's width, clamped and remembered. */ export function setNowPlayingWidth(px) { const w = Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, Math.round(px))); ui.nowPlayingWidth = w; try { localStorage.setItem(NP_WIDTH_KEY, String(w)); } catch { // A blocked storage quota must not break resizing for the session. } } export function toggleSidebar() { ui.sidebarCollapsed = !ui.sidebarCollapsed; } 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 } } // ---- recently played (the sidebar's list) ---- // A play row is opened after ~1s of audio, so without a floor this list is a // log of skips. 45s is the floor the live table was measured against. export const RECENTS_FLOOR_SECS = 45; const RECENTS_LIMIT = 20; export const recentsStore = $state({ albums: [], // [{ album_id, artist, title, art_path, last_played_at }] loaded: false, }); export async function refreshRecents() { try { recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS); } catch { // backend not available } finally { recentsStore.loaded = true; } } const MIB = 1024 * 1024; function isPending(value) { return value && typeof value.then === "function"; } function touchCache(cache, key) { if (!cache.has(key)) return undefined; const value = cache.get(key); cache.delete(key); cache.set(key, value); return value; } function cacheValueChars(value) { if (typeof value === "string") return value.length; if (typeof value?.data_url === "string") return value.data_url.length; if (Number.isFinite(value?.byteLength)) return value.byteLength; return 0; } /** Keep resolved base64 payloads on a byte-like character budget while never * evicting in-flight promises. Visible elements retain their own src, * so dropping a cold cache reference cannot degrade an already-painted image. */ function trimResolvedCache(cache, maxEntries, maxChars) { let entries = 0; let chars = 0; for (const value of cache.values()) { if (isPending(value)) continue; entries += 1; chars += cacheValueChars(value); } if (entries <= maxEntries && chars <= maxChars) return; for (const [key, value] of cache) { if (isPending(value)) continue; cache.delete(key); entries -= 1; chars -= cacheValueChars(value); if (entries <= maxEntries && chars <= maxChars) break; } } function setResolvedCache(cache, key, value, maxEntries, maxChars) { cache.delete(key); cache.set(key, value); trimResolvedCache(cache, maxEntries, maxChars); } function setBoundedMap(cache, key, value, maxEntries) { cache.delete(key); cache.set(key, value); while (cache.size > maxEntries) cache.delete(cache.keys().next().value); } // path -> data URL, bounded so browsing a large library cannot retain every // base64 cover for the entire WebView lifetime. const artCache = new Map(); const ART_CACHE_MAX = 128; const ART_CACHE_MAX_CHARS = 32 * MIB; export function evictArt(path) { artCache.delete(path); } 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; /** The proxied data URL for `url` if it is ALREADY resolved, synchronously. * * Exists so a second consumer can paint art the first one has already fetched * without a round trip through a promise — one microtask is still a frame with * the raw CDN url in `src`, which is a network request for a file we already * hold. Returns null while a fetch is in flight (the cache holds the promise * then) and null for anything unproxied. */ export function peekCatalogArt(url) { if (!url) return null; if (url.startsWith("data:")) return url; const hit = catalogArtCache.get(url); return typeof hit === "string" ? hit : null; } export async function getCatalogArt(url) { if (!url) return null; // Already-inline art (e.g. a cover-derived logo) has nothing to proxy. if (url.startsWith("data:")) return url; if (catalogArtCache.has(url)) return touchCache(catalogArtCache, url); // Cache the promise immediately, not only its result. Multiple components // often request the same art during one render; one IPC/base64 conversion // should satisfy every waiter. const pending = api.catalogArt(url).then( (dataUrl) => { if (catalogArtCache.get(url) === pending) { setResolvedCache( catalogArtCache, url, dataUrl, CATALOG_ART_CACHE_MAX, CATALOG_ART_CACHE_MAX_CHARS, ); } return dataUrl; }, () => { // CDN/provider failures are transient; allow the next visit to retry. if (catalogArtCache.get(url) === pending) catalogArtCache.delete(url); return null; }, ); catalogArtCache.set(url, pending); return pending; } // Remote catalog cover URLs that failed to load — a dead CDN link, a Cover Art // Archive 404 (MusicBrainz-supplemented cards are given a `front-500` URL // whether or not CAA actually holds art for that release group), or bytes that // won't decode. Cards read `artBroken[url]` and fall back to their disc // placeholder, so a dead provider URL renders the placeholder instead of the // browser's broken-image glyph. Session-scoped and keyed by URL (a dead URL // stays dead); bounded so a marathon browse can't retain unbounded strings. export const artBroken = $state({}); export function markArtBroken(url) { if (!url || artBroken[url]) return; const keys = Object.keys(artBroken); if (keys.length > 2000) for (const k of keys) delete artBroken[k]; artBroken[url] = true; // Remote art hosts flake transiently (Cover Art Archive redirects into // archive.org, which routinely times out under load) — one failed load // must not pin the placeholder for the whole session. Re-probe off-DOM // and un-mark on success; cards restore reactively. if (/^https?:/i.test(url) && !artRetriesStarted.has(url)) { artRetriesStarted.add(url); retryBrokenArt(url, 0); } } // Cover Art Archive redirects into archive.org, which can be down for minutes // at a time, so the old two-shot 4s/20s ladder simply exhausted itself inside // half a minute and pinned the placeholder for the rest of the session. Retry // further out, and — crucially — RELEASE the url from `artRetriesStarted` when // the ladder ends, so revisiting the artist later starts a fresh cycle instead // of being permanently locked out. const ART_RETRY_DELAYS = [3_000, 10_000, 30_000, 90_000]; const artRetriesStarted = new Set(); function retryBrokenArt(url, attempt) { if (attempt >= ART_RETRY_DELAYS.length) { artRetriesStarted.delete(url); return; } setTimeout(() => { if (!artBroken[url]) { artRetriesStarted.delete(url); return; } const probe = new Image(); probe.onload = () => { delete artBroken[url]; artRetriesStarted.delete(url); }; probe.onerror = () => retryBrokenArt(url, attempt + 1); probe.src = url; }, ART_RETRY_DELAYS[attempt]); } // Catalog cover upgrades: original cover URL -> a verified higher-resolution // URL from the no-MusicBrainz backend resolver (Apple / Deezer native max /, // for MusicBrainz-supplemented cards only, Cover Art Archive). The grid paints // the instant Deezer cover, then swaps to the upgrade when it resolves. Keyed // by URL so a shared cover resolves once; bounded for a long browse. export const coverUpgrades = $state({}); const coverUpgradeInflight = new Map(); // identity + original url -> Promise const coverUpgradeResults = new Map(); // bounded LRU: request key -> result context const coverUpgradeContexts = new Map(); // original url -> disk-cache producer const coverUpgradeMisses = new Map(); // original url -> attempted-at const coverUpgradeQueue = []; let coverUpgradeActive = 0; let coverUpgradeScope = 0; let coverUpgradeArtistId = null; const COVER_UPGRADE_CONCURRENCY = 4; const COVER_UPGRADE_MAX = 400; const COVER_UPGRADE_MISS_TTL_MS = 2 * 60 * 1000; function advanceCoverUpgradeScope(artistId) { coverUpgradeArtistId = artistCacheKey(artistId) || null; coverUpgradeScope += 1; // Queued work has not crossed IPC yet, so it is safe to cancel when its // artist is no longer visible. At most four already-running jobs finish; // the new artist never sits behind an arbitrarily long stale backlog. for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) { const job = coverUpgradeQueue[index]; if (job.sticky || job.scope === coverUpgradeScope) continue; coverUpgradeQueue.splice(index, 1); if (coverUpgradeInflight.get(job.key) === job.promise) { coverUpgradeInflight.delete(job.key); } job.resolve(null); } } function activateCoverUpgradeScope(artistId) { if (sameArtistId(coverUpgradeArtistId, artistId)) return; advanceCoverUpgradeScope(artistId); } /** Cancel artist-grid jobs that have not crossed IPC when that view leaves. * Sticky currently-playing art and the four already-running calls are kept. */ export function leaveCoverUpgradeScope(artistId) { if (!sameArtistId(coverUpgradeArtistId, artistId)) return; advanceCoverUpgradeScope(null); } function coverUpgradeRequestKey(artistId, url) { return `${artistCacheKey(artistId)}\u0000${url}`; } function rememberCoverUpgrade(key, url, best, artistId, artist, title) { const result = { key, url, best, artistId, artist, title }; coverUpgradeResults.delete(key); coverUpgradeResults.set(key, result); coverUpgradeContexts.set(url, result); coverUpgrades[url] = best; while (coverUpgradeResults.size > COVER_UPGRADE_MAX) { const oldest = coverUpgradeResults.keys().next().value; const removed = coverUpgradeResults.get(oldest); coverUpgradeResults.delete(oldest); if (coverUpgradeContexts.get(removed?.url)?.key === oldest) { coverUpgradeContexts.delete(removed.url); if (coverUpgrades[removed.url] === removed.best) delete coverUpgrades[removed.url]; } } } function rememberCoverUpgradeMiss(key) { coverUpgradeMisses.delete(key); coverUpgradeMisses.set(key, Date.now()); while (coverUpgradeMisses.size > COVER_UPGRADE_MAX) { coverUpgradeMisses.delete(coverUpgradeMisses.keys().next().value); } } function pumpCoverUpgradeQueue() { while (coverUpgradeActive < COVER_UPGRADE_CONCURRENCY && coverUpgradeQueue.length) { // Current-page viewport work wins over retained player/album jobs. const currentIndex = coverUpgradeQueue.findIndex( (candidate) => !candidate.sticky && candidate.scope === coverUpgradeScope, ); const job = currentIndex > 0 ? coverUpgradeQueue.splice(currentIndex, 1)[0] : coverUpgradeQueue.shift(); coverUpgradeActive += 1; api.catalogCover(job.artistId, job.artist, job.title, job.url) .then((best) => { if (best && best !== job.url) { coverUpgradeMisses.delete(job.key); rememberCoverUpgrade( job.key, job.url, best, job.artistId, job.artist, job.title, ); } else { rememberCoverUpgradeMiss(job.key); } job.resolve(best ?? null); }) .catch(() => { rememberCoverUpgradeMiss(job.key); job.resolve(null); }) .finally(() => { if (coverUpgradeInflight.get(job.key) === job.promise) { coverUpgradeInflight.delete(job.key); } coverUpgradeActive -= 1; pumpCoverUpgradeQueue(); }); } } function keepOnlyLatestStickyCoverJob(url) { for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) { const job = coverUpgradeQueue[index]; if (!job.sticky || job.url === url) continue; coverUpgradeQueue.splice(index, 1); if (coverUpgradeInflight.get(job.key) === job.promise) { coverUpgradeInflight.delete(job.key); } job.resolve(null); } } function scheduleCoverUpgrade(artistId, artist, title, url, sticky = false) { const key = coverUpgradeRequestKey(artistId, url); // Player changes supersede older queued player-art polish. Running calls // finish safely, but rapid track skipping cannot accumulate an immortal // sticky backlog across every artist scope. if (sticky) keepOnlyLatestStickyCoverJob(url); if (coverUpgradeResults.has(key)) { const result = touchCache(coverUpgradeResults, key); coverUpgradeContexts.set(url, result); if (coverUpgrades[url] !== result.best) coverUpgrades[url] = result.best; return Promise.resolve(result.best); } const published = coverUpgradeContexts.get(url); if (published?.key === key && coverUpgrades[url]) { return Promise.resolve(coverUpgrades[url]); } const attemptedAt = coverUpgradeMisses.get(key); if (attemptedAt && Date.now() - attemptedAt < COVER_UPGRADE_MISS_TTL_MS) { return Promise.resolve(null); } if (coverUpgradeInflight.has(key)) { const existing = coverUpgradeInflight.get(key); if (sticky) { const index = coverUpgradeQueue.findIndex((job) => job.promise === existing); if (index >= 0) { const [job] = coverUpgradeQueue.splice(index, 1); job.sticky = true; coverUpgradeQueue.unshift(job); } } return existing; } let complete; const promise = new Promise((resolve) => { complete = resolve; }); const job = { key, artistId: artistId ?? null, artist: artist ?? "", title: title ?? "", url, resolve: complete, promise, sticky, scope: coverUpgradeScope, }; // Register before enqueueing: staged overview/full-page calls can no longer // create separate worker pools containing the same not-yet-started URL. coverUpgradeInflight.set(key, promise); if (sticky) coverUpgradeQueue.unshift(job); else coverUpgradeQueue.push(job); pumpCoverUpgradeQueue(); return promise; } /** Resolve higher-resolution covers for catalog albums, in parallel with a * concurrency cap. The backend does NO MusicBrainz lookup, so this fans out * freely. Idempotent — already-resolved or in-flight covers are skipped. */ export async function resolveCovers( albums, artist, { sticky = false, artistId = null } = {}, ) { const requests = []; const seen = new Set(); for (const a of albums ?? []) { const url = a?.cover; if (!url || seen.has(url)) continue; seen.add(url); requests.push(scheduleCoverUpgrade(artistId, artist, a.title ?? "", url, sticky)); } await Promise.all(requests); } /** A catalog cover img failed to load. If it was an upgraded URL, revert to the * instant Deezer cover; if the original itself failed, show the placeholder. */ export function coverLoadError( originalCover, failedUrl = null, artist = "", title = "", artistId = null, ) { if (!originalCover) return null; if (coverUpgrades[originalCover] && coverUpgrades[originalCover] !== originalCover) { const producer = coverUpgradeContexts.get(originalCover); delete coverUpgrades[originalCover]; // Do not let the bounded LRU immediately republish the URL that just // failed in an . A short miss window keeps the working original // in place while still allowing transient CDN failures to retry later. const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover); coverUpgradeResults.delete(requestKey); if (coverUpgradeContexts.get(originalCover)?.key === requestKey) { coverUpgradeContexts.delete(originalCover); } rememberCoverUpgradeMiss(requestKey); const cacheArtistId = producer?.artistId ?? artistId; const cacheArtist = producer?.artist ?? artist; const cacheTitle = producer?.title ?? title; if (cacheArtist && cacheTitle) { void api .invalidateCatalogCover( cacheArtistId, cacheArtist, cacheTitle, originalCover, ) .catch(() => {}); } return originalCover; } else { // A card intentionally requests a 500px CDN derivative. If only that // resized route failed, retry the known-good canonical source before // declaring the album itself broken. if (failedUrl) { try { if (new URL(failedUrl).href !== new URL(originalCover).href) { return originalCover; } } catch { if (failedUrl !== originalCover) return originalCover; } } markArtBroken(originalCover); return null; } } /** * Cover URL to display for a currently-playing catalog item — the record's OWN * asset, at the largest size that asset comes in. * * It used to also kick off a cover "upgrade", which resolves a better image by * ARTIST + TITLE. That is the identity trap: an artist can have several records * under one title — every self-titled Weezer album is "Weezer" — and the * resolver hands back whichever one the provider answers with, so all seven * played back showing the Green Album's art. The upgrade map is keyed by the * original cover URL and was innocent; the value put in it was wrong. * * A name cannot identify a record, so the substitution is gone. `hqCover` * rewrites the same Deezer asset to its 1000x1000 variant, which is a genuine * quality win with no identity risk, and is more than the panel needs even * dragged to its widest. */ export function playerArtSource(item) { const url = item?.artUrl; if (!url) return null; return hqCover(url); } // Artist heroes have their own proxy because provider fallbacks may be finished // as baked 4K JPEGs. Never share this cache with covers/logos. const catalogHeroPreviewCache = new Map(); const catalogHeroArtCache = new Map(); const catalogHeroGpuCache = new Map(); const CATALOG_HERO_PREVIEW_CACHE_MAX = 16; const CATALOG_HERO_ART_CACHE_MAX = 10; 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(); // Saving a song adds tracks to an album this map has already answered // for; a stale answer would leave the new SAVED badge off until reload. libraryAlbumTitles.clear(); libraryStore.albums = await api.libraryAlbums(); libraryStore.loaded = true; window.__libErr = null; } 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 } // What the thing you clicked already knew about the record — cover, title, // artist, kind. You reach an album page from a card that was ALREADY // showing the artwork, so there is no reason for the page to be colourless // while the request is out: the skeleton paints this immediately and the // fetch only has to fill in the words. Null when nothing was known (a // history restore of a record whose art was never stored, say), and the // skeleton falls back to a placeholder block. albumSeed: null, // { cover, title, artist, kind } | null pendingTrackId: null, // a track to scroll to/flash on the album page error: "", // ---- search page supplements ------------------------------------------ // Three things the results page shows that a Deezer search does not // return. Each lands on its own schedule and each is additive: the page // is complete without any of them, and none of them ever re-orders or // removes a row that is already on screen. // // Records only MusicBrainz knows about (Death Grips' Exmilitary, MBV's // Loveless). Merged into Albums with no badge and no section of their // own — see `catalogSearchIdentityAlbums`. searchAlbumExtras: [], // { [albumId]: "YYYY-MM-DD" }. Deezer's album SEARCH carries no release // dates at all — 0 of 12 on a live "panda bear" — while the MusicBrainz // supplement carries one for every row. Sorting the merged list by date // without this therefore did the exact opposite of what it was for: every // supplemented record floated above every result Deezer actually returned, // and provenance decided position again. The dates come from the artist // OVERVIEW, which `warmCatalogArtist` already fetches on every search, so // this costs nothing. searchAlbumDates: {}, // { [trackId]: listeners } — Last.fm counts for the top artist's songs, // lifted from the same command the artist page's Popular list uses. searchListeners: {}, // { [trackId]: true } for result songs already saved in the library. searchOwned: {}, // { listeners, plays, tags } for the artist the hero is about. searchArtistProfile: null, // The hero album's own page — its real tracklist and headline totals. Only // fetched when an ALBUM is the top result, which is the only time the hero // has anything to do with it. searchAlbumPage: null, }); let artistPageRequest = 0; let artistWarmGeneration = 0; let catalogSearchRequest = 0; let albumPageRequest = 0; let albumResolveRequest = 0; const ARTIST_REQUEST_CACHE_MAX = 24; const ARTIST_IDENTITY_CACHE_MAX = 128; const ARTIST_LOGO_CACHE_MAX = 64; const artistIdCache = new Map(); // normalized name → Deezer id (or null) const artistSeedCache = new Map(); // normalized name → search-result seed const artistSeedById = new Map(); // Deezer id → latest known artist seed const catalogArtistOverviewCache = new Map(); const catalogArtistHeroSeedCache = new Map(); const catalogArtistArtDataCache = new Map(); const catalogArtistLogoDataCache = new Map(); const catalogArtistPageCache = new Map(); // One canonical representation for all frontend artist cache keys. Route ids // can arrive as strings while Tauri payload ids are numbers; treating those as // separate keys wastes work and makes otherwise-correct late-result guards fail. const artistCacheKey = (id) => (id == null ? "" : String(id).trim()); const albumCacheKey = (id) => (id == null ? "" : String(id).trim()); const sameArtistId = (left, right) => { const leftKey = artistCacheKey(left); return !!leftKey && leftKey === artistCacheKey(right); }; const sameAlbumId = (left, right) => { const leftKey = albumCacheKey(left); return !!leftKey && leftKey === albumCacheKey(right); }; const artistIdentityKey = (value) => { const text = typeof value === "string" ? value : value?.name; return (text ?? "") .normalize("NFKD") .replace(/\p{M}/gu, "") .toLocaleLowerCase() .replace(/[^\p{L}\p{N}]+/gu, ""); }; /** Accept an exact artist credit or an explicit collaboration credit containing * that exact artist. A mere prefix is never enough ("Crazy P" must not match * "Crazy Penis"). Missing credit metadata is retained because there is no * identity signal to evaluate. */ function recordBelongsToArtist(record, artistName) { const credited = typeof record?.artist === "string" ? record.artist : record?.artist?.name; if (!credited) return true; const expected = artistIdentityKey(artistName); if (!expected) return true; if (artistIdentityKey(credited) === expected) return true; return credited .split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i) .some((part) => artistIdentityKey(part) === expected); } function artistScopedRecords(records, artistName) { return Array.isArray(records) ? records.filter((record) => recordBelongsToArtist(record, artistName)) : []; } /** Sanitize every staged/full artist payload before it enters a shared cache. * Backend endpoints already anchor on id; this cheap name-credit boundary is a * second line of defense for additive provider records such as Apple/MB data. */ function artistScopedPayload(payload) { const artistName = payload?.artist?.name ?? ""; return { ...payload, ...(Object.hasOwn(payload ?? {}, "albums") ? { albums: artistScopedRecords(payload.albums, artistName) } : {}), ...(Object.hasOwn(payload ?? {}, "popular_tracks") ? { popular_tracks: artistScopedRecords(payload.popular_tracks, artistName) } : {}), }; } function checkedArtistPayload(artistId, payload, stage) { if (!sameArtistId(payload?.artist?.id, artistId)) { throw new Error(`${stage} returned a mismatched artist identity`); } return artistScopedPayload(payload); } function rememberArtistSeed(seed) { if (!seed?.id) return null; const idKey = artistCacheKey(seed.id); const prior = artistSeedById.get(idKey); const remembered = { id: seed.id, name: seed.name ?? prior?.name ?? "", picture: seed.picture ?? prior?.picture ?? null, fans: seed.fans ?? prior?.fans ?? null, album_count: seed.album_count ?? seed.albumCount ?? prior?.album_count ?? null, }; setBoundedMap( artistSeedById, idKey, remembered, ARTIST_IDENTITY_CACHE_MAX, ); // If this IS the artist being viewed, its crumb gains the real name and // portrait it was opened without. Guarded inside refreshVisit, so seeds // remembered for rows in a list cannot rewrite where you are. refreshVisit({ view: "catalogArtist", label: remembered.name, artistId: remembered.id, art: remembered.picture ?? null, round: true, }); const norm = remembered.name.trim().toLowerCase(); if (norm) { const existing = artistSeedCache.get(norm); const sameIdentity = sameArtistId(existing?.id, remembered.id); const moreCanonical = (remembered.fans ?? -1) > (existing?.fans ?? -1); // Deezer can return several exact-name matches. Preserve the most- // followed canonical seed instead of letting the last obscure namesake // in a search response poison all future name-only navigation. if (!existing || sameIdentity || moreCanonical) { setBoundedMap( artistSeedCache, norm, remembered, ARTIST_IDENTITY_CACHE_MAX, ); setBoundedMap( artistIdCache, norm, remembered.id, ARTIST_IDENTITY_CACHE_MAX, ); } } return remembered; } function trimArtistRequestCache(cache) { let resolved = 0; for (const value of cache.values()) { if (!isPending(value)) resolved += 1; } for (const [key, value] of cache) { if (resolved <= ARTIST_REQUEST_CACHE_MAX) break; if (isPending(value)) continue; cache.delete(key); resolved -= 1; } } function cachedArtistRequest(cache, artistId, load) { const key = artistCacheKey(artistId); if (!key) return Promise.reject(new Error("artist id is required")); if (cache.has(key)) return Promise.resolve(touchCache(cache, key)); const pending = load().then( (result) => { if (cache.get(key) === pending) { cache.delete(key); cache.set(key, result); trimArtistRequestCache(cache); } return result; }, (error) => { if (cache.get(key) === pending) cache.delete(key); throw error; }, ); cache.set(key, pending); trimArtistRequestCache(cache); return pending; } function resolvedArtistCacheValue(cache, artistId) { const value = cache.get(artistCacheKey(artistId)); return value && typeof value.then !== "function" ? value : null; } export const getCatalogArtistOverview = (artistId) => cachedArtistRequest(catalogArtistOverviewCache, artistId, () => api.catalogArtistOverview(artistId).then((payload) => checkedArtistPayload(artistId, payload, "artist overview")), ); export const getCatalogArtistHeroSeed = (artistId) => cachedArtistRequest(catalogArtistHeroSeedCache, artistId, () => api.catalogArtistHeroSeed(artistId), ).then((seed) => { // The backend applies a short negative TTL. Do not turn a transient // no-seed result into a frontend-session-long miss. if (!seed) catalogArtistHeroSeedCache.delete(artistCacheKey(artistId)); return seed; }); export const getCatalogArtistIdentityArt = (artistId) => cachedArtistRequest(catalogArtistArtDataCache, artistId, () => api.catalogArtistArt(artistId).then((payload) => checkedArtistPayload(artistId, payload, "artist art")), ).then((art) => { // Empty provider/Bing results have a short backend TTL and may recover // on a later visit. Keep the shared promise for the whole in-flight // request, but do not turn a valid no-art payload into a session-long // frontend miss. Positive banners, custom art and logos stay cached. const hasArt = Boolean( art?.banner_custom || art?.banner || art?.banners?.length || art?.logos?.length ); if (!hasArt) catalogArtistArtDataCache.delete(artistCacheKey(artistId)); return art; }); export const getCatalogArtistProviderLogos = (artistId) => cachedArtistRequest(catalogArtistLogoDataCache, artistId, () => api.catalogArtistLogos(artistId).then((logos) => { if (!Array.isArray(logos) || logos.some((logo) => typeof logo !== "string")) { throw new Error("artist logos returned an invalid response"); } rememberProviderLogoStatus(artistId, logos.length > 0); if (!logos.length) { // Backend provider-art misses intentionally have a short TTL. // Keep this page's settled negative decision, but do not pin it // in the frontend request cache for the rest of the session. catalogArtistLogoDataCache.delete(artistCacheKey(artistId)); } return logos; }), ); export const getCatalogArtistPage = (artistId) => cachedArtistRequest(catalogArtistPageCache, artistId, () => api.catalogArtist(artistId).then((payload) => checkedArtistPayload(artistId, payload, "artist page")), ); export function invalidateCatalogArtist(artistId) { const key = artistCacheKey(artistId); catalogArtistOverviewCache.delete(key); catalogArtistHeroSeedCache.delete(key); catalogArtistArtDataCache.delete(key); catalogArtistLogoDataCache.delete(key); catalogArtistPageCache.delete(key); providerLogoStatusById.delete(key); for (const logoKey of coverLogoByInput.keys()) { if (logoKey.startsWith(`${key}\u0000`)) coverLogoByInput.delete(logoKey); } } // ---- Stale-while-revalidate page replay ----------------------------------- // A revisit after an app restart used to start from the loading shell even // though the previous session had the artist's FINAL merged page. The last // merged page per artist is persisted (localStorage, MRU-capped) and painted // immediately as a stale stand-in — paint-only: every stage of the normal // cold flow still runs and replaces it wholesale, so this can never repeat // the old "cached page skipped the MB pass" bug. Replayed pages keep // `art_preview: true` (the full page still lands) and set `replayed: true` // (the overview stage must not overwrite final classified albums with its // raw first-50). // v2: pages saved before guest appearances were filtered are stamped // `identity_merged`, so they would skip the pass that drops them and keep // rendering another artist's records. Cheap to discard — the replay only buys // an instant first paint, and a miss is one ordinary load. // v3: same argument for release ownership. A v2 replay carries a fused // record's co-tenants and its wrong portrait, both stamped as merged. // v4: same argument for original-album attribution. A v3 replay carries // Deezer's licensed attribution on every popular row — "Bohemian Rhapsody" on // the 2018 movie soundtrack, with that poster as its cover — and painting a // known-wrong album name instantly is worse than one ordinary load. const PAGE_REPLAY_RECIPE = "replay-v4"; const PAGE_REPLAY_LIMIT = 16; // Matches the backend's provider disk-cache TTL on purpose: a replay must // never outlive the MusicBrainz warmth it was saved with, or the identity // re-merge that replaces it stops being instant. const PAGE_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; const PAGE_REPLAY_INDEX = "ss-page-replay-index"; const pageReplayKey = (key) => `ss-page-replay:${key}`; function loadPageReplay(artistId) { try { const key = artistCacheKey(artistId); if (!key) return null; const raw = localStorage.getItem(pageReplayKey(key)); if (!raw) return null; const entry = JSON.parse(raw); if (entry?.recipe !== PAGE_REPLAY_RECIPE) return null; if (!entry.at || Date.now() - entry.at > PAGE_REPLAY_TTL_MS) return null; const page = entry.page; if (!sameArtistId(page?.artist?.id, artistId)) return null; if (!Array.isArray(page?.albums) || !page.albums.length) return null; return page; } catch { return null; } } function savePageReplay(artistId, page) { try { const key = artistCacheKey(artistId); if (!key || !page?.albums?.length) return; // Only durable content fields. Art (banners/logos/cover_logo) is // deliberately excluded: those pipelines have their own caches and // settled/override semantics that a replay must not preempt. const slim = { artist: page.artist, popular_tracks: page.popular_tracks ?? [], albums: page.albums, top_genre: page.top_genre ?? null, socials: page.socials ?? [], typeHints: page.typeHints ?? [], identity_merged: true, }; localStorage.setItem( pageReplayKey(key), JSON.stringify({ recipe: PAGE_REPLAY_RECIPE, at: Date.now(), page: slim }), ); const index = JSON.parse(localStorage.getItem(PAGE_REPLAY_INDEX) ?? "[]"); const next = [key, ...index.filter((k) => k !== key)]; for (const evicted of next.slice(PAGE_REPLAY_LIMIT)) { localStorage.removeItem(pageReplayKey(evicted)); } localStorage.setItem(PAGE_REPLAY_INDEX, JSON.stringify(next.slice(0, PAGE_REPLAY_LIMIT))); } catch { // Storage full or unavailable — the replay is purely optional. } } /** Warm only the two visible stages while a user reads search results. The * expensive exhaustive page remains click-driven. */ export function cancelCatalogArtistWarmup() { artistWarmGeneration += 1; } export function warmCatalogArtist(seed) { const remembered = rememberArtistSeed(seed); if (!remembered) return; const warmGeneration = ++artistWarmGeneration; const heroModeReady = getHeroWarmMode(); void getCatalogArtistOverview(remembered.id).catch(() => {}); void getCatalogArtistHeroSeed(remembered.id) .then(async (seed) => { if (warmGeneration !== artistWarmGeneration) return; if (seed?.banner && !seed.banner.startsWith("data:")) { // Warm exactly the asset the selected renderer consumes. Live // GPU never paints the legacy HD data URL, so preparing it would // waste an extra decode and up to 12 MiB of WebView cache. const mode = await heroModeReady; if (warmGeneration !== artistWarmGeneration) return; if (mode === "live_gpu") { void getCatalogHeroGpuSource(seed.banner, null).catch(() => {}); } else { void getCatalogHeroPreview(seed.banner).catch(() => {}); } } }) .catch(() => {}); void getCatalogArtistProviderLogos(remembered.id) .then((logos) => { const primaryLogo = logos?.[0]; if (primaryLogo) void getCatalogArt(primaryLogo); }) .catch(() => {}); // Also warm the MusicBrainz identity chain — the slowest piece of a cold // artist page (3-5 requests behind MB's mandatory 1 req/s gate). Gated // three ways so live-search churn can never flood MusicBrainz: only after // 600ms of the artist staying the top hit (a newer warm generation // supersedes the timer), only once per artist per session, and only for // this single seed — never a result list. const identityKey = artistCacheKey(remembered.id); if (identityKey && remembered.name && !identityWarmupDone.has(identityKey)) { setTimeout(() => { if (warmGeneration !== artistWarmGeneration) return; if (identityWarmupDone.has(identityKey)) return; identityWarmupDone.add(identityKey); void api.catalogArtistIdentity(remembered.id, remembered.name).catch(() => {}); }, 600); } } const identityWarmupDone = new Set(); /** Move to a top-level view (the sidebar's destinations). Routed through here * rather than assigning `ui.view` directly so history and the trail see it — * a root also STARTS a journey, which is what the first crumb reports. */ export function goToView(view, opts = {}) { captureScroll(); ui.view = view; if (opts.albumId !== undefined) ui.albumId = opts.albumId; if (opts.playlistId !== undefined) ui.playlistId = opts.playlistId; recordVisit({ view, label: opts.label ?? VIEW_LABELS[view] ?? view, glyph: VIEW_GLYPHS[view] ?? null, ...opts }); } const VIEW_LABELS = { home: "Home", search: "Search", library: "Your Library", downloads: "Downloads", settings: "Settings", playlist: "Playlist", }; const VIEW_GLYPHS = { home: "home", search: "search", library: "library", downloads: "download", settings: "settings", playlist: "music", }; /** Re-open a recorded place. The one function the history arrows and the trail * both drive; every case must land on exactly the view the entry describes. */ export async function openEntry(entry) { switch (entry.view) { case "catalogArtist": return openCatalogArtist(entry.artistId); case "catalogAlbum": // A local record lives on this same view but is composed from the // library rather than fetched — the back arrow has to route on the // id's realm or it would ask the catalog for a `lib:` id. if (isLibraryAlbumId(entry.albumId)) { return openLibraryAlbum(libraryAlbumIdOf(entry.albumId), { seed: entry.art ? { cover: entry.art, title: entry.label ?? null, artist: entry.artist ?? null, kind: null, } : null, }); } // `recordVisit` stored this record's cover, so going back paints // the skeleton with the artwork rather than a grey block. Older // entries predate that field and simply seed nothing. return openCatalogAlbum(entry.albumId, { seed: entry.art ? { cover: entry.art, title: entry.label ?? null, artist: entry.artist ?? null, kind: null, } : null, }); case "catalog": return catalogGo(entry.query ?? entry.label); case "playlist": ui.playlistId = entry.playlistId ?? null; ui.view = "playlist"; return; case "library": ui.albumId = entry.albumId ?? null; ui.view = "library"; return; default: ui.view = entry.view; } } export const navBack = () => goBack(openEntry); export const navForward = () => goForward(openEntry); export const navToCrumb = (entry) => goToCrumb(entry, openEntry); export { nav }; /** Run a catalog search and show the results view. */ export async function catalogGo(query) { captureScroll(); cancelCatalogArtistWarmup(); const q = query.trim(); if (!q) return; const request = ++catalogSearchRequest; ui.view = "catalog"; ui.albumId = null; recordVisit({ view: "catalog", label: `“${q}”`, query: q, glyph: "search" }); catalog.query = q; catalog.results = null; catalog.loading = true; catalog.error = ""; catalog.searchAlbumExtras = []; catalog.searchAlbumDates = {}; catalog.searchListeners = {}; catalog.searchOwned = {}; catalog.searchArtistProfile = null; catalog.searchAlbumPage = null; try { const results = await api.catalogSearch(q); if (request !== catalogSearchRequest) return; catalog.results = results; // Only a query that found something is worth offering back, or learning // as a correctly-spelled name — see `recentSearches`. if (results?.artists?.length || results?.albums?.length || results?.tracks?.length) { fruitfulSearches.add(q); } else { fruitfulSearches.delete(q); } for (const artist of results?.artists ?? []) rememberArtistSeed(artist); // Search results are normally read for a moment before the first artist // is opened. Spend that otherwise-idle time warming its identity/art // lookup; the keyed backend single-flight shares this exact request if // the user clicks immediately. const likelyArtistId = results?.artists?.[0]?.id; if (likelyArtistId != null) { warmCatalogArtist(results.artists[0]); } // Every supplement is fired AFTER the results are on screen and none is // awaited: the page must never wait on MusicBrainz's 1 req/s gate or on // Last.fm to paint what Deezer already answered. const stillCurrent = () => request === catalogSearchRequest; void applySearchAlbumDates(results, stillCurrent); void applySearchIdentityAlbums(results, stillCurrent); void applySearchListeners(results, stillCurrent); void applySearchOwnership(results, stillCurrent); } catch (e) { if (request === catalogSearchRequest) catalog.error = String(e); } finally { if (request === catalogSearchRequest) catalog.loading = false; } } /* The two hero supplements. Both are driven by the VIEW rather than fired from * `catalogGo`, because only the view knows which kind won the hero — and each * costs a rate-limited lookup that would be wasted on the other two kinds. The * ranking rule stays in one place instead of being mirrored here. */ let searchProfileRequest = 0; /** Genres + Last.fm totals for an ARTIST hero. */ export async function loadSearchArtistProfile(artistId) { const request = ++searchProfileRequest; catalog.searchArtistProfile = null; if (artistId == null) return; try { const profile = await api.catalogArtistProfile(artistId); if (request === searchProfileRequest) catalog.searchArtistProfile = profile; } catch { // The hero reads fine on Deezer's own two figures alone. } } let searchAlbumPageRequest = 0; /** The real tracklist and headline totals for an ALBUM hero. The search's own * track results are a different list entirely — they are what matched the * query, not what is on the record. */ export async function loadSearchAlbumPage(albumId) { const request = ++searchAlbumPageRequest; catalog.searchAlbumPage = null; if (albumId == null) return; try { const page = await api.catalogAlbum(albumId); if (request === searchAlbumPageRequest) catalog.searchAlbumPage = page; } catch { // Falls back to the matched songs, which is what it showed before. } } /** Hand the query off to the Soulseek network file search. * * A catalogue miss is not the end of the road in a Soulseek client — the * network routinely carries what no catalogue lists — so every dead end on the * results page offers this rather than nothing. */ export async function searchNetworkFor(query) { const q = (query ?? "").trim(); if (!q) return; // Seeded before the view swaps: Search.svelte reads `search.query` once, at // mount, to show what it is looking for. search.query = q; goToView("search"); if (app.connection !== "online") { app.showLogin = true; return; } try { await runSearch(q); } catch (err) { app.loginError = String(err); app.showLogin = true; } } /** The records a Deezer SEARCH cannot see, for the artist a search is about. * * Deezer's search index is not a discography: Death Grips' Exmilitary, My * Bloody Valentine's Loveless and half of Panda Bear's early run are simply * absent from it. The artist page already solves this — it merges a * MusicBrainz supplement over the Deezer list — so the results page runs the * SAME merge (`mergeArtistIdentity`) rather than a second, disagreeing one. * * Returns only the rows the merge INJECTED, so the caller can fold them into * the album list it already has. The albums handed in are narrowed to the top * artist's own first: an MB release group belongs to that artist, and letting * a namesake's row block one would delete a real record from the page. */ export async function catalogSearchIdentityAlbums(seedArtist, searchAlbums) { const name = seedArtist?.name ?? ""; if (seedArtist?.id == null || !name) return []; const identity = await api.catalogArtistIdentity(seedArtist.id, name); if (!identity?.albums?.length) return []; const key = catNorm(name); const mine = (searchAlbums ?? []).filter((a) => catNorm(a?.artist) === key); const merged = mergeArtistIdentity( { artist: { name }, albums: mine, socials: [], typeHints: [] }, identity, ); const known = new Set(mine.map((a) => a?.id)); return (merged?.albums ?? []).filter((a) => a && !known.has(a.id)); } /** Release dates for the albums a search returned. * * `/search/album` omits `release_date` entirely; `/artist/{id}/albums` — which * the artist OVERVIEW wraps, and which `warmCatalogArtist` has already asked * for by the time this runs — carries one for every release. Matched by album * id, so this is a lookup and never a title guess. Albums by an artist other * than the one searched for keep no date, and simply show none. */ /** At most this many artists get a discography lookup for their dates. An * album-title query returns rows from tribute bands, karaoke acts and piano * covers; only the acts that actually hold several rows here are worth one. */ const ALBUM_DATE_ARTIST_BUDGET = 3; async function applySearchAlbumDates(results, active) { const undated = (results?.albums ?? []).filter((a) => a?.id != null && !a.release_date); if (!undated.length) return; // Grouped by the album's OWN artist rather than by the search's top artist. // Deezer's ARTIST search answers nothing at all for "weezer blue album", so // keying this on `results.artists[0]` left every row on an album-title // query dateless — which is what showed "Weezer · — · 10 tracks". const byArtist = new Map(); for (const album of undated) { const key = catNorm(album.artist); if (!key) continue; if (!byArtist.has(key)) byArtist.set(key, { name: album.artist, rows: [] }); byArtist.get(key).rows.push(album); } const groups = [...byArtist.values()] .sort((a, b) => b.rows.length - a.rows.length) .slice(0, ALBUM_DATE_ARTIST_BUDGET); const dates = {}; await Promise.all( groups.map(async (group) => { try { const seed = await resolveArtistSeedByName(group.name); if (seed?.id == null || !active()) return; const overview = await getCatalogArtistOverview(seed.id); if (!active()) return; const byId = new Map(); const byTitle = new Map(); for (const album of overview?.albums ?? []) { if (album?.id == null || !album.release_date) continue; byId.set(String(album.id), album.release_date); // First listing wins: the discography arrives newest-first, // and the original pressing is the date a card should show. const key = normText(album.title); if (key && !byTitle.has(key)) byTitle.set(key, album.release_date); } for (const row of group.rows) { // A search hit and the same record in the artist's own // discography are frequently DIFFERENT Deezer ids — two // pressings of one album — so an id miss falls back to the // title. Safe here by construction: the only titles in play // are this one artist's own. const date = byId.get(String(row.id)) ?? byTitle.get(normText(row.title)); if (date) dates[String(row.id)] = date; } } catch { // One artist failing to date does not stop the others. } }), ); if (!active() || !Object.keys(dates).length) return; catalog.searchAlbumDates = dates; } async function applySearchIdentityAlbums(results, active) { const seed = results?.artists?.[0]; if (!seed) return; try { let extras = await catalogSearchIdentityAlbums(seed, results.albums); // A COLD MusicBrainz call answers with nothing under per-IP contention, // and "no albums" is indistinguishable from "this artist has none". One // delayed retry, exactly as the artist page's identity pass does — for a // genuinely album-less artist both calls answer from the URL caches, so // the retry costs the delay and no MusicBrainz traffic. if (!extras.length) { await new Promise((resolve) => setTimeout(resolve, 8000)); if (!active()) return; extras = await catalogSearchIdentityAlbums(seed, results.albums); } if (!active() || !extras.length) return; catalog.searchAlbumExtras = extras; } catch { // Best-effort supplement: the Deezer list is a complete page without it. } } /** Last.fm listener counts for the result songs. * * Search results carry no counts — `catalog_search` is three Deezer calls and * nothing else, deliberately, because the typeahead calls it per keystroke. * The counts are a separate, deferred pass over EVERY row, resolved per * performing artist by that row's own Deezer artist id (see * `MetadataClient::track_listeners`). A row the backend has no count for keeps * `null` — a dash in the UI, never a fabricated zero. * * An earlier version asked only the TOP artist's popular list and matched * titles against it. That left every row credited to somebody else blank — * Daft Punk's "Doin' it Right (feat. Panda Bear)" showed a dash on a Panda Bear * search while the album page showed its 464k listeners. */ async function applySearchListeners(results, active) { const tracks = results?.tracks ?? []; if (!tracks.length) return; try { const counts = await api.catalogTrackListeners( tracks.map((t) => ({ artistId: t.artist_id ?? null, title: t.title })), ); if (!active() || !Array.isArray(counts)) return; const out = {}; tracks.forEach((t, i) => { if (counts[i] != null) out[t.id] = counts[i]; }); if (!active() || !Object.keys(out).length) return; catalog.searchListeners = out; } catch { // No counts is a page without bars, not a broken page. } } const libraryAlbumTitles = new Map(); // library album id -> Promise> /** Which of these catalog songs are already saved, as `{ [trackId]: true }`. * * Ownership is per TRACK and the library index is per album, so this walks only * the library albums whose artist appears in the list — usually none, at most a * handful — and memoizes each album's tracklist for the session. Shared by the * results page and the typeahead so one SAVED badge cannot contradict another. */ export async function catalogTrackOwnership(tracks) { const rows = tracks ?? []; if (!rows.length || !libraryStore.albums.length) return {}; const wanted = new Set(rows.map((t) => normText(t?.artist)).filter(Boolean)); const albums = libraryStore.albums.filter((al) => wanted.has(normText(al.artist))); if (!albums.length) return {}; const titlesByArtist = new Map(); await Promise.all( albums.map(async (al) => { if (!libraryAlbumTitles.has(al.id)) { libraryAlbumTitles.set( al.id, api .albumTracks(al.id) .then((r) => new Set(r.map((row) => normText(row.title || "")))) .catch(() => new Set()), ); } const titles = await libraryAlbumTitles.get(al.id); const key = normText(al.artist); if (!titlesByArtist.has(key)) titlesByArtist.set(key, new Set()); const into = titlesByArtist.get(key); for (const t of titles) into.add(t); }), ); const out = {}; for (const t of rows) { if (titlesByArtist.get(normText(t?.artist))?.has(normText(t?.title))) out[t.id] = true; } return out; } async function applySearchOwnership(results, active) { try { const owned = await catalogTrackOwnership(results?.tracks); if (!active() || !Object.keys(owned).length) return; catalog.searchOwned = owned; } catch { // No badge is a fine answer; a wrong badge is not. } } /** Queries that actually found something this session. * * History alone is the wrong list to offer back: a typo you made two minutes * ago is in it, and re-running a known miss is not a suggestion. It is also the * wrong vocabulary for the results page's spelling correction, which would * otherwise learn the misspelling as a word and stop correcting it. */ const fruitfulSearches = new Set(); /** The searches you ran this session that returned something, newest first — * read out of the app's own history rather than a second store, and never * written to disk. */ export function recentSearches(exclude = "", limit = 6) { const out = []; for (let i = nav.stack.length - 1; i >= 0 && out.length < limit; i--) { const e = nav.stack[i]; if (e.view !== "catalog" || !e.query) continue; if (e.query === exclude || out.includes(e.query)) continue; if (!fruitfulSearches.has(e.query)) continue; out.push(e.query); } return out; } /** Open an artist as a four-stage page: synchronous identity shell, fast * Deezer overview, independent provider artwork, then exhaustive refinement. * Every ID gets a shell; known search/discovery seeds add the name + blurred * portrait without waiting for IPC or the network. */ // Provider presence is an identity-scoped, monotonic decision. Once any exact // provider pass finds a curated logo, a later empty/error result may not expose // a cover-derived fallback in front of it. const providerLogoStatusById = new Map(); // Cover-derived results are keyed by the exact identity-filtered cover input, // not only by artist id. This prevents a negative or wordmark generated from an // older/polluted discography from being reused after the candidate set changes. // Values are either an in-flight Promise, a finalized data URL, or null. const coverLogoByInput = new Map(); const ARTIST_LOGO_CACHE_MAX_CHARS = 12 * MIB; function rememberProviderLogoStatus(artistId, hasProviderLogo) { const key = artistCacheKey(artistId); if (!key) return; const prior = providerLogoStatusById.get(key); setBoundedMap( providerLogoStatusById, key, prior === true || hasProviderLogo === true, ARTIST_LOGO_CACHE_MAX, ); } /** Apply a fast, definitive provider-logo decision. With no provider logo, an * exact-input cached result or generation starts once the hardened final cover * list is supplied. */ async function resolveCoverLogoAvailability(artistId, page, providerLogosReady, active) { const logos = await providerLogosReady; const statusKey = artistCacheKey(artistId); if (!Array.isArray(logos)) { if ( providerLogoStatusById.get(statusKey) === false && page && active() ) { await fetchCoverLogo(artistId, page, active); return { provider: false }; } return { provider: null }; } rememberProviderLogoStatus(artistId, logos.length > 0); if (!active()) return { provider: logos.length > 0 }; const current = catalog.artistPage; if (logos.length) { if (sameArtistId(current?.artist?.id, artistId)) { catalog.artistPage = { ...current, logos: current.logos?.length ? current.logos : logos, provider_logos_settled: true, cover_logo: null, cover_logo_pending: false, }; } return { provider: true }; } if (sameArtistId(current?.artist?.id, artistId) && !current.provider_logos_settled) { catalog.artistPage = { ...current, provider_logos_settled: true }; } // Do not use the old artist-id-only persisted restore here. Its candidate // fingerprint is unavailable, so it could have been generated from the // polluted discography we are hardening against. The exact-input request // below still hits the backend's versioned memory/disk cache on revisits. if (page) await fetchCoverLogo(artistId, { ...page, logos: [] }, active); return { provider: false }; } function mergeCatalogArtistArt(artistId, art, active) { if (!sameArtistId(art?.artist?.id, artistId) || !active()) return; const current = catalog.artistPage; if (!current || !sameArtistId(current.artist?.id, artistId)) return; const finalBanners = art.banners ?? []; const artLogos = Array.isArray(art.logos) ? art.logos : []; const finalLogos = current.logos?.length ? current.logos : artLogos; const mergedArtist = { ...current.artist, ...art.artist, // The completed identity-filtered page owns this count. A late art // profile may still carry Deezer's broader/fused total; use it only // while the shell has no count at all. album_count: current.artist?.album_count ?? art.artist?.album_count ?? null, }; rememberArtistSeed(mergedArtist); rememberProviderLogoStatus(artistId, finalLogos.length > 0); catalog.artistPage = { ...current, artist: mergedArtist, banner: art.banner ?? null, banners: finalBanners, banner_custom: art.banner_custom ?? false, logos: finalLogos, provider_logos_settled: true, cover_logo: finalLogos.length ? null : current.cover_logo ?? null, cover_logo_pending: finalLogos.length ? false : current.cover_logo_pending ?? false, hero_seed_banner: finalBanners.length ? null : current.hero_seed_banner, loading_shell: false, placeholder_picture: current.placeholder_picture ?? art.artist?.picture ?? null, }; } /** Fire the deferred cover-logo extraction for an artist with no provider logo, * merging the result into its own page field. Backend-cached, so ~instant on a * revisit. Called from BOTH the cold-load and warm-cache paths of * openCatalogArtist — the warm path early-returns and would otherwise skip it. */ /** Lower ranks first. Studio-album covers print the bare band name; singles, * EPs and anything with a remix/live/mix in the title print tracklists and * credits — poison for a wordmark. So OCR the clean covers first. */ function albumLogoRank(a) { const type = a.record_type ?? ""; const typeRank = type === "album" ? 0 : type === "ep" ? 2 : 3; const noisy = /\b(remix|remixes|mix|live|edit|dub|version|bonus|instrumental)\b/i.test( a.title ?? "", ) ? 1 : 0; return typeRank + noisy; } // Must not undercut the backend's own cap (COVER_LOGO_MAX_COVERS): whatever is // trimmed here the extractor can never see. A prolific artist's wordmark is // frequently on a low-ranked remix single — all seven of Poolside's readable // covers sat past position 14 — and the backend stops early once it has enough // hits, so a longer list costs nothing for artists whose top covers work. function coverLogoCandidates(page) { return [...(page.albums ?? [])] .sort((a, b) => albumLogoRank(a) - albumLogoRank(b)) .map((a) => ({ title: a.title ?? "", url: a.cover })) .filter((c) => c.url) .slice(0, 40); } function coverLogoInputKey(artistId, page, covers) { const idKey = artistCacheKey(artistId); const artistKey = artistIdentityKey(page.artist?.name); const candidates = covers .map(({ title, url }) => `${title}\u0001${url}`) .join("\u0002"); return `${idKey}\u0000${artistKey}\u0000${candidates}`; } function readCoverLogoForPage(artistId, page) { if (providerLogoStatusById.get(artistCacheKey(artistId)) !== false) return null; const covers = coverLogoCandidates(page); if (!covers.length) return null; const value = touchCache(coverLogoByInput, coverLogoInputKey(artistId, page, covers)); return typeof value === "string" ? value : null; } function coverLogoRequest(artistId, page, covers) { const inputKey = coverLogoInputKey(artistId, page, covers); if (coverLogoByInput.has(inputKey)) { return Promise.resolve(touchCache(coverLogoByInput, inputKey)); } const pending = api .catalogCoverLogo(artistId, page.artist?.name ?? "", covers) .then( (dataUrl) => { if (dataUrl != null && typeof dataUrl !== "string") { throw new Error("cover logo returned an invalid response"); } const result = dataUrl ?? null; if (coverLogoByInput.get(inputKey) === pending) { setResolvedCache( coverLogoByInput, inputKey, result, ARTIST_LOGO_CACHE_MAX, ARTIST_LOGO_CACHE_MAX_CHARS, ); } return result; }, (error) => { if (coverLogoByInput.get(inputKey) === pending) { coverLogoByInput.delete(inputKey); } throw error; }, ); coverLogoByInput.set(inputKey, pending); trimResolvedCache( coverLogoByInput, ARTIST_LOGO_CACHE_MAX, ARTIST_LOGO_CACHE_MAX_CHARS, ); return pending; } async function fetchCoverLogo(artistId, page, active) { const statusKey = artistCacheKey(artistId); if ( !sameArtistId(page?.artist?.id, artistId) || page.logos?.length || providerLogoStatusById.get(statusKey) !== false || !active() ) { return null; } const covers = coverLogoCandidates(page); if (!covers.length) return null; const current = catalog.artistPage; if ( sameArtistId(current?.artist?.id, artistId) && !current.cover_logo_pending && !readCoverLogoForPage(artistId, page) ) { catalog.artistPage = { ...current, cover_logo_pending: true }; } try { const dataUrl = await coverLogoRequest(artistId, page, covers); if (!dataUrl || !active()) return dataUrl; const latest = catalog.artistPage; if ( !sameArtistId(latest?.artist?.id, artistId) || latest.logos?.length || providerLogoStatusById.get(statusKey) !== false ) { return dataUrl; } // Its OWN field, never `logos`: the provider-art pass sets `logos` // (often to []) and would otherwise clobber it. The view prefers // provider `logos` and falls back to this. catalog.artistPage = { ...latest, cover_logo: dataUrl }; return dataUrl; } catch { return null; } finally { const latest = catalog.artistPage; if ( active() && sameArtistId(latest?.artist?.id, artistId) && latest.cover_logo_pending ) { catalog.artistPage = { ...latest, cover_logo_pending: false }; } } } /** MusicBrainz "last pass" for an open artist page. MB is rate-limited to * 1 req/s by policy, which used to cost the release grid ~2-3s; Apple's * unthrottled artist lookup now supplies the release supplement, so MB runs * AFTER the page is on screen and merges in whenever it lands. Deliberately * additive-only: it contributes the socials and the few deep cuts (practice * tapes, BBC sessions) neither Deezer nor Apple carries, and never edits a * record those two already own. * * Two failure modes this function owns: * - MB can 503 under per-IP contention. A fully empty result gets one * delayed retry — failures are not cached backend-side, so it is real. * - The session page cache used to hold the RAW backend page forever, and * the cached fast path skipped this pass entirely — so a warm revisit * silently LOST the supplement it had on the cold visit ("A ? of WHEN" * vanishing from Panda Bear). The merged page is now written back into * the cache and stamped `identity_merged`, and the fast path re-runs this * pass for cached pages that lack the stamp. */ async function applyArtistIdentity(artistId, active) { try { const name = catalog.artistPage?.artist?.name ?? ""; let identity = await api.catalogArtistIdentity(artistId, name); // Socials are a separate deferred command now, so the identity // payload's socials are ALWAYS empty — they can no longer prove "MB // knows this artist" here, and waiting for the retry below would // delay the footer links 8 s for artists MB carries without release // groups. Fire them independently before the retry decision. void applyArtistSocials(artistId, active); const empty = !(identity?.albums?.length || identity?.type_hints?.length); if (empty) { // Transient-failure retry. For a genuinely album-less MB artist // both calls answer from the URL caches, so the second pass // costs no MusicBrainz traffic — only this deliberate delay. await new Promise((resolve) => setTimeout(resolve, 8000)); if (!active()) return; identity = await api.catalogArtistIdentity(artistId, name); } if (!active()) return; const current = catalog.artistPage; if (!current || !sameArtistId(current.artist?.id, artistId)) return; const merged = mergeArtistIdentity(current, identity); catalog.artistPage = merged; if (merged.fused_record) void disownFusedPortrait(artistId, merged); // The wordmark is lifted from THESE albums' covers, and this pass is // fired beside the one above rather than after it — so on a fused record // it normally wins the race and reads the unfiltered list. Wand's page // wore a "wand." lifted from an Indonesian co-tenant's single that way. // Re-run whenever ownership actually changed the covers on offer; the // request is keyed by the exact list, so an unchanged one is a cache hit // and costs nothing. const coversChanged = (current.albums ?? []).length !== merged.albums.length || (current.albums ?? []).some((a, i) => a?.cover !== merged.albums[i]?.cover); if (coversChanged && !merged.logos?.length) { void resolveCoverLogoAvailability( artistId, merged, Promise.resolve(merged.logos ?? []), active, ); } // Write the supplemented page back into the session cache: a warm // revisit must not render less than the cold visit did. Overwriting // a pending entry is safe — cachedArtistRequest only installs its // own result while its promise is still the stored value. const key = artistCacheKey(artistId); if (key) { catalogArtistPageCache.delete(key); catalogArtistPageCache.set(key, merged); trimArtistRequestCache(catalogArtistPageCache); } savePageReplay(artistId, merged); } catch { // Best-effort supplement: the page is complete without it. } } /** The identity merge itself, as a pure function of the page and the identity * payload. * * Extracted so the Now Playing panel can run the SAME merge the artist page * runs. It used to live inside `applyArtistIdentity`, which only the artist * page calls — so the panel showed the raw Deezer list: My Bloody Valentine * appeared with four releases because Loveless and Isn't Anything exist only * as MusicBrainz supplements, and Weezer filed differently because the panel * never received `type_hints` either. Both surfaces read one implementation * now; a second copy would only guarantee they disagree in new ways. */ function mergeArtistIdentity(current, identity) { // MB identity extras are always albums, so only rows that could BE // that album may block one: a same-title SINGLE is a different // release (the "Phantom Island" shape — a 2024 single and a 2025 // album), and letting it occupy the title made the real album vanish // from the page. const seen = new Set( (current.albums ?? []) .filter((a) => a.record_type !== "single") .map((a) => normText(a.title)), ); const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name); // An MB row whose title is a PREFIX of an existing row's full title // is the same work Deezer carries as a qualified variant — // "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night // (Deluxe)". Injecting it adds a second card with no track count. // Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay // distinct: a bare numeral/volume marker never counts as a variant // qualifier. normText already strips brackets, so compare against // the raw title normalized without stripping. const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, ""); // Same single-typed exemption as `seen`: only album-like rows may // claim to be a variant of an MB album. const existingRaw = (current.albums ?? []) .filter((a) => a.record_type !== "single") .map((a) => rawNorm(a.title)); const sequelTail = (tail) => /^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test( tail.trim(), ); const coveredByVariant = (title) => { const base = rawNorm(title); if (base.length < 8) return false; return existingRaw.some( (t) => t.length > base.length && t.startsWith(base) && !sequelTail(t.slice(base.length)), ); }; // `normText` DELETES bracket contents, which is right for peer file // matching and wrong here: MusicBrainz writes a qualifier after a colon // where Deezer puts it in parentheses, so "World War 3 (Lean)" reduced // to "worldwar3" while MB's "World War 3: Lean" reduced to // "worldwar3lean" and the two never collided. The result was a second // card for a record already on the page — and once mixtapes had a tab // of their own, the same work appeared in Albums AND Mixtapes. // // This key keeps the qualifier and instead folds the three things the // providers genuinely disagree about: the separator, the words // introducing a number, and how that number is written. Measured on // Gucci Mane it collapses ten such pairs, among them // "Wilt Chamberlain (Pt. 5)" / "Wilt Chamberlain, Part Five", // "Bird Flu 2" / "Bird Flu, Part Two" and "Trap House 4" / // "Trap House Four". // // Single-letter roman numerals are deliberately NOT folded: "X", "V" // and "I" are real album titles, and reading them as 10, 5 and 1 would // merge unrelated records. const ORDINALS = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10", ii: "2", iii: "3", iv: "4", vi: "6", vii: "7", viii: "8", ix: "9", }; const releaseKey = (title) => asciiFold(title ?? "") .toLowerCase() .replace(/[‘’']/g, "") .replace(/\b(?:part|pt|vol|volume)\b\.?/g, " ") .replace(/[^a-z0-9]+/g, " ") .trim() .split(" ") .map((word) => ORDINALS[word] ?? word) .join(""); // Every album-like row already on the page, by that key, so a matched // MB row can be recognised as the one on screen rather than injected // beside it. // Every row sharing a key, not just the first: a provider sometimes // carries the same record twice under its own spellings ("Brick Factory // 2" beside "Brick Factory, Vol. 2"). Typing only one of them would put // one copy in Mixtapes and leave the other in Albums, which is a worse // page than leaving both alone. const existingByKey = new Map(); for (const album of current.albums ?? []) { if (album.record_type === "single") continue; const key = releaseKey(album.title); if (!key) continue; if (!existingByKey.has(key)) existingByKey.set(key, []); existingByKey.get(key).push(album); } // A MusicBrainz mixtape that IS a row already on the page contributes // its TYPE, not a card. The Deezer row wins the slot because it is the // playable one — it carries a real album id and tracklist — and MB's // Mixtape/Street verdict re-files it, exactly as `type_hints` re-files // a mislabelled compilation. That is what moves "World War 3 (Lean)" // out of Albums instead of leaving one copy in each tab. const adoptedMixtapeIds = new Set(); const extra = identityAlbums.filter((a) => { if (!a?.title) return false; const twins = a.is_mixtape ? existingByKey.get(releaseKey(a.title)) : undefined; if (twins?.length) { for (const twin of twins) { if (!twin.is_mixtape) adoptedMixtapeIds.add(twin.id); } return false; } return ( !seen.has(normText(a.title)) && !existingByKey.has(releaseKey(a.title)) && !coveredByVariant(a.title) ); }); const socials = identity?.socials ?? []; // MB's authoritative primary/secondary types. The view matches these // to displayed releases by title and re-files any the provider // mislabeled "album" (compilations, soundtracks…). const typeHints = identity?.type_hints ?? []; // Guest appearances, dropped only where MusicBrainz can arbitrate. // // Deezer's /artist/{id}/albums returns records the artist merely // contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air // Thief's discography that way. The backend flags them (`appears_on`: // the release's primary artist is someone else), but that flag ALONE // must never delete a row: Deezer marks every contributor "Main", so a // guest spot looks exactly like a genuine joint album — Kanye West's // "Watch the Throne" carries primary artist JAY-Z and would be wrongly // erased from his page. // // MusicBrainz separates them, because it credits a real collaboration // under BOTH artists: querying Kanye's own MBID returns "Watch the // Throne", while Mid-Air Thief's returns only his two real albums. So a // flagged row survives if MB credits it and is dropped if MB does not // — and ONLY when MB actually answered for this artist. No MB albums // means no arbiter, and every row is kept rather than guessed at, which // keeps thin-coverage artists (the obscure end, where this matters // most) from losing real releases. // // The title check alone is not enough, and Kanye is the proof. MB files // a collaboration under both parties only when the credit NAMES both // ("Watch the Throne"); when the collaboration is its own MB entity it // does not, and neither VULTURES 1, VULTURES 2 nor KIDS SEE GHOSTS // appears anywhere in Ye's 315 release groups. All three were being // erased from his page by the very rule written to protect them. // `owned_album_ids` is the backend's wider answer to the same question // — MB release groups of every type plus the MB-verified Apple // catalogue — so a real collaboration survives on evidence instead of // on MusicBrainz happening to duplicate the credit. // // `foreign_album_ids` is the other half: releases that belong to a // DIFFERENT act sharing this Deezer artist record. Deezer files several // unrelated performers under one id (there is exactly one Deezer // "Wand", and it holds an LA psych band, a Brazilian funk MC, an // Indonesian act and four more), which no amount of search ranking or // credit-role reading can undo. The backend only fills this in when it // can NAME the co-tenant, so on an ordinary artist it is empty and the // page is untouched. const mbTitles = new Set(identityAlbums.map((a) => normText(a.title))); const owned = new Set(identity?.owned_album_ids ?? []); const foreign = new Set(identity?.foreign_album_ids ?? []); const ownAlbums = (current.albums ?? []) .filter((a) => { if (foreign.has(a?.id)) return false; if (!a?.appears_on) return true; if (owned.has(a?.id)) return true; // No MB albums = no arbiter = keep everything, so thin-coverage // artists never lose real releases. return identityAlbums.length ? mbTitles.has(normText(a.title)) : true; }) // Rows MusicBrainz typed Mixtape/Street under a title Deezer spells // differently. A new object rather than a mutation: `current.albums` // is shared with the page replay cache and with whatever is already // rendered, and this must not edit either in place. .map((a) => adoptedMixtapeIds.has(a?.id) ? { ...a, is_mixtape: true } : a, ); const merged = { ...current, identity_merged: true, // A fused record's Deezer PORTRAIT is untrustworthy for exactly the // reason its releases are: it is whichever co-tenant uploaded last. // Wand's is a different artist entirely, and `deezerPortraitMissing` // cannot catch it — it is a real photograph, just not of this band. fused_record: !!identity?.fused_record, albums: extra.length ? [...ownAlbums, ...extra].sort((a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")) : ownAlbums, socials: socials.length ? socials : current.socials, typeHints: typeHints.length ? typeHints : current.typeHints, }; return merged; } /** Deferred footer links: merged whenever they arrive, never blocking the * identity pass. Cached back into the session page cache like the identity * merge, so a warm revisit keeps its links. */ async function applyArtistSocials(artistId, active) { try { const socials = await api.catalogArtistSocials(artistId); if (!socials?.length || !active()) return; const current = catalog.artistPage; if (!current || !sameArtistId(current.artist?.id, artistId)) return; if (current.socials?.length) return; const merged = { ...current, socials }; catalog.artistPage = merged; const key = artistCacheKey(artistId); if (key) { catalogArtistPageCache.delete(key); catalogArtistPageCache.set(key, merged); trimArtistRequestCache(catalogArtistPageCache); } savePageReplay(artistId, merged); } catch { // Footer links are decorative; missing them is fine. } } export async function openCatalogArtist(artistId, seedArtist = null) { cancelCatalogArtistWarmup(); captureScroll(); catalogSearchRequest += 1; const request = ++artistPageRequest; const artistKey = artistCacheKey(artistId); if (!artistKey) return; // After the bail-out above, so a request we never act on cannot scroll the // page the reader is still on. catalog.artistOpenSeq += 1; activateCoverUpgradeScope(artistId); ui.view = "catalogArtist"; recordVisit({ view: "catalogArtist", label: seedArtist?.name ?? "Artist", artistId, art: seedArtist?.picture ?? null, round: true }); const active = () => request === artistPageRequest && ui.view === "catalogArtist" && sameArtistId(catalog.artistPage?.artist?.id, artistId); const providedSeed = sameArtistId(seedArtist?.id, artistId) ? rememberArtistSeed(seedArtist) : null; const remembered = providedSeed ?? touchCache(artistSeedById, artistKey) ?? null; const providerLogosReady = getCatalogArtistProviderLogos(artistId).catch(() => null); const loadFullArtistArt = () => getCatalogArtistIdentityArt(artistId) .then((art) => mergeCatalogArtistArt(artistId, art, active)) .catch(() => {}); let completed = resolvedArtistCacheValue(catalogArtistPageCache, artistId); if (completed && !sameArtistId(completed.artist?.id, artistId)) { catalogArtistPageCache.delete(artistKey); completed = null; } if (completed) { rememberArtistSeed(completed.artist); if (completed.logos?.length) rememberProviderLogoStatus(artistId, true); catalog.artistPage = { ...completed, art_preview: false, loading_shell: false, partial_error: null, hero_seed_banner: null, // A positive curated set is identity-safe to reuse. An earlier // negative is not: wait for this generation's short-TTL provider // request before authorizing any cover-derived fallback. provider_logos_settled: Boolean(completed.logos?.length), cover_logo: null, placeholder_picture: remembered?.picture ?? completed.artist?.picture ?? null, }; catalog.artistLoading = false; catalog.artistStageError = ""; catalog.error = ""; void resolveCoverLogoAvailability(artistId, completed, providerLogosReady, active); void loadFullArtistArt(); // A cached page from before the MB pass landed (or from a visit // where it failed) is missing its supplement — the fast path must // not render less than the cold path did. if (!completed.identity_merged) void applyArtistIdentity(artistId, active); else if (!completed.socials?.length) void applyArtistSocials(artistId, active); return; } // The search portrait is a small square image — it must never enter the // real hero-source list (banner/banners feed the carousel and 4K finisher). // It stays a presentation-only placeholder threaded through every state. const seedPicture = remembered?.picture ?? null; const replay = loadPageReplay(artistId); catalog.artistPage = replay ? { // Stale stand-in from the last visit: the final classified album // list paints immediately while every cold stage below re-runs // and replaces it. Art fields start empty on purpose — the art // pipeline owns them. ...replay, logos: [], provider_logos_settled: false, banner: null, banners: [], banner_credit: null, banner_custom: false, art_preview: true, loading_shell: false, replayed: true, partial_error: null, hero_seed_banner: null, cover_logo: null, placeholder_picture: seedPicture ?? replay.artist?.picture ?? null, } : { artist: { id: remembered?.id ?? artistId, name: remembered?.name ?? "", picture: seedPicture, fans: remembered?.fans ?? null, album_count: remembered?.album_count ?? null, }, logos: [], provider_logos_settled: false, banner: null, banners: [], banner_credit: null, banner_custom: false, popular_tracks: [], albums: [], top_genre: null, socials: [], art_preview: true, loading_shell: true, partial_error: null, hero_seed_banner: null, placeholder_picture: seedPicture, }; catalog.artistLoading = true; catalog.artistStageError = ""; catalog.error = ""; // A persisted positive mark can paint over the identity shell as soon as // the fast provider-logo check confirms this artist has no provider logo. // Provider artists start their complete artwork stage immediately; a cold // no-provider artist reserves the network for cover generation first. void resolveCoverLogoAvailability(artistId, null, providerLogosReady, active); // Bing discovery is independent of cover-logo OCR. Run both under their own // bounded backend limits so neither user-visible asset sits behind the // other's multi-second pipeline. void loadFullArtistArt(); // A single identity-safe source can arrive before the complete provider // tier/list. It feeds only the first preview; the final artwork response // still owns carousel selection and 4K finishing. void getCatalogArtistHeroSeed(artistId) .then((seed) => { if (!active() || !seed?.banner) return; const current = catalog.artistPage; if ( !current || !sameArtistId(current.artist?.id, artistId) || current.banners?.length || current.banner_custom ) { return; } catalog.artistPage = { ...current, hero_seed_banner: seed.banner, banner_custom: seed.banner_custom ?? false, loading_shell: false, }; }) .catch(() => {}); // Warm the MusicBrainz identity pass NOW instead of after the full page // lands. Its 4-6 requests serialize behind MusicBrainz's mandatory // 1 req/s gate (measured 3.7-8.5 s — most of a cold page's // time-to-fully-sorted), so starting it beside the page load hides // almost all of that behind work that was happening anyway. Fire and // forget: applyArtistIdentity later re-asks with the authoritative name // and rides the warmed URL caches; results here are deliberately unused. let identityWarmed = false; const warmArtistIdentity = (name) => { if (identityWarmed || !name) return; identityWarmed = true; void api.catalogArtistIdentity(artistId, name).catch(() => {}); }; warmArtistIdentity(remembered?.name); // One-round-trip content stage: popular tracks and the first curated // releases become usable while exhaustive classification continues. void getCatalogArtistOverview(artistId) .then((overview) => { warmArtistIdentity(overview?.artist?.name); if (!active()) return; const current = catalog.artistPage; if ( !current || !sameArtistId(current.artist?.id, artistId) || current.art_preview === false || !sameArtistId(overview.artist?.id, artistId) ) return; rememberArtistSeed(overview.artist); catalog.artistPage = { ...current, artist: { ...current.artist, ...overview.artist }, // A replayed page already shows the last visit's FINAL // classified albums; the overview's raw first-50 would be a // strict downgrade. Fresh albums arrive with the full page. ...(current.replayed ? {} : { popular_tracks: overview.popular_tracks ?? [], albums: overview.albums ?? [], }), loading_shell: false, placeholder_picture: current.placeholder_picture ?? overview.artist?.picture ?? null, }; }) .catch(() => {}); // Popular's fast lane. The overview paints the section immediately (its // supplements are budgeted), so a cold page's rows start in Deezer's // order with no listener counts. The real list — Last.fm order and // counts — needs none of the dates/recordings work the full page also // waits for, so it is asked for on its own and patched in the moment it // lands (~2s cold, instant warm). Last writer wins against the overview: // warm, the two lists are computed from identical inputs and cannot // differ. The full page stays authoritative once it arrives, and a // replayed page already shows the last visit's FINAL list — patching a // pool-shaped list over either would be a downgrade. void api.catalogArtistPopular(artistId) .then((tracks) => { if (!active() || !tracks?.length) return; const current = catalog.artistPage; if (!current || !sameArtistId(current.artist?.id, artistId)) return; if (current.art_preview === false || current.replayed) return; catalog.artistPage = { ...current, popular_tracks: tracks }; }) .catch(() => {}); try { // Overview, identity art, and the first hero all remain immediate. // Give rapid back-to-back navigation a brief chance to settle before // starting the exhaustive release-classification request; stale pages // should not consume provider/backend work merely to fill a cache. await new Promise((resolve) => setTimeout(resolve, 180)); if (!active()) return; const page = await getCatalogArtistPage(artistId); if (active() && sameArtistId(page?.artist?.id, artistId)) { rememberArtistSeed(page.artist); const staged = catalog.artistPage; const stagedHasArt = sameArtistId(staged?.artist?.id, artistId); const finalBanners = page.banner_custom ? page.banners ?? [] : stagedHasArt && staged?.banners?.length ? staged.banners : page.banners ?? []; const finalLogos = stagedHasArt && staged?.logos?.length ? staged.logos : page.logos ?? []; rememberProviderLogoStatus(artistId, finalLogos.length > 0); catalog.artistPage = { ...page, // A replayed page carries the last visit's MB type hints AND // socials; the fresh page always arrives with both empty // (socials are a deferred command now), so dropping them here // would let filing/footer visibly regress for the moment // before the identity/socials passes re-merge them. ...(stagedHasArt && staged?.typeHints?.length ? { typeHints: staged.typeHints } : {}), ...(stagedHasArt && staged?.socials?.length ? { socials: staged.socials } : {}), banner: page.banner_custom ? page.banner : stagedHasArt ? staged?.banner ?? page.banner ?? null : page.banner ?? null, banners: finalBanners, logos: finalLogos, provider_logos_settled: true, art_preview: false, loading_shell: false, partial_error: null, hero_seed_banner: finalBanners.length ? null : staged?.hero_seed_banner ?? null, placeholder_picture: staged?.placeholder_picture ?? page.artist?.picture ?? null, cover_logo: finalLogos.length ? null : staged?.cover_logo ?? null, cover_logo_pending: staged?.cover_logo_pending ?? false, }; // MusicBrainz last pass — shared with the cached fast path, see // applyArtistIdentity for the semantics. void applyArtistIdentity(artistId, active); // No provider logo for this artist → try to lift a wordmark from the // album covers (band name printed as clean text). Deferred, cached, // additive-only; on failure the plain text name stays. void resolveCoverLogoAvailability(artistId, page, providerLogosReady, active); } } catch (e) { if (active()) { void loadFullArtistArt(); catalog.error = String(e); catalog.artistStageError = "Some release details could not be loaded."; if (catalog.artistPage) { catalog.artistPage = { ...catalog.artistPage, art_preview: false, loading_shell: false, partial_error: catalog.artistStageError, }; } } } finally { if (request === artistPageRequest) catalog.artistLoading = false; } } /** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a * track to highlight/scroll to (used by the now-playing song link). */ export async function openCatalogAlbum(albumId, opts = {}) { const albumKey = albumCacheKey(albumId); if (!albumKey) return; captureScroll(); // A verified-id navigation supersedes any still-running name search. albumResolveRequest += 1; const request = ++albumPageRequest; ui.view = "catalogAlbum"; // `keepCurrent` holds the page that is already on screen until the new one // lands, instead of clearing to the loading state. Switching between two // editions of the same record is a swap, not a navigation — blanking the // whole page for it reads as a flash. The album view crossfades on top of // this; every other caller still gets the plain loading state. if (!opts.keepCurrent) { catalog.albumPage = null; catalog.albumLoading = true; // Only on a real navigation. An edition swap holds the page it is // already showing, so seeding a skeleton it will never render would // just leave stale artwork behind for the next navigation to flash. catalog.albumSeed = opts.seed ?? null; } catalog.pendingTrackId = opts.trackId ?? null; catalog.error = ""; const active = () => request === albumPageRequest && ui.view === "catalogAlbum"; try { const page = await api.catalogAlbum(albumId); if (!sameAlbumId(page?.album?.id, albumKey)) { throw new Error("catalog album returned a mismatched album identity"); } if (!active()) return; catalog.albumPage = page; recordVisit({ view: "catalogAlbum", label: page.album.title, albumId: page.album.id, art: page.album.cover ?? null, artist: page.album.artist ?? null, }); // No catalog trackId (e.g. local playback) — match the song by title. if (catalog.pendingTrackId == null && opts.trackTitle) { const t = page.tracks.find( (tr) => catNorm(tr.title) === catNorm(opts.trackTitle), ); if (t) catalog.pendingTrackId = t.id; } } catch (e) { if (active()) { catalog.error = String(e); // A held page would swallow the failure — the old edition stays up // and the click looks like it did nothing. Clear so the error is // the thing on screen, exactly as a normal navigation would. if (opts.keepCurrent) catalog.albumPage = null; } } finally { if (request === albumPageRequest) { catalog.albumLoading = false; // The real page owns the artwork from here. Held any longer, the // seed would still be on screen behind the next navigation's first // frame, showing the previous record's cover under a new skeleton. catalog.albumSeed = null; } } } /** * Open the album a preview-modal item declares, landing on the track that was * being previewed. * * The record comes from the item's own provider `albumId` — never a title * match — so a top-tracks preview lands on the single or compilation the * modal's metadata line was already naming, not on a same-titled record. * * The conversion is the reason this is one function rather than a lambda at * each call site: `contracts.js` freezes every manifest id as a STRING, while * `catalog_album` takes a `u64` (a JSON string fails to deserialize) and the * album page's flash compares track ids with `===`. Both cross back to numbers * here, once. */ export function openAlbumFromPreviewItem(item) { const albumId = Number(item?.albumId); if (!Number.isFinite(albumId)) return; const trackId = Number(item?.sources?.[0]?.providerTrackId); return openCatalogAlbum(albumId, { trackId: Number.isFinite(trackId) ? trackId : null, }); } const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); /** Resolve an artist by name (cached) and open their catalog page — used by * the now-playing artist link, which only knows the name. */ /** Resolve an artist NAME to their catalog seed — `{ id, name, picture, fans, * album_count }` — or null when no artist by that name exists. * * Shares the very caches `openArtistByName` fills, so a page that shows an * artist's portrait beside a name has already paid for the click that * navigates to them, and vice versa. In-flight lookups are deduped by name: * an album page mounting can otherwise fire the same search its own back * button is about to. */ const artistSeedRequests = new Map(); // normalized name → Promise export function resolveArtistSeedByName(name) { const norm = (name || "").trim().toLowerCase(); if (!norm) return Promise.resolve(null); const cached = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null; if (cached) return Promise.resolve(cached); // A remembered miss stays a miss — the negative is cached as a null id. if (artistIdCache.has(norm) && touchCache(artistIdCache, norm) == null) { return Promise.resolve(null); } const inflight = artistSeedRequests.get(norm); if (inflight) return inflight; const promise = (async () => { try { const res = await api.catalogSearch(name); const artists = res.artists ?? []; // Same rule as openArtistByName: the most-followed exact match, so // a shared name resolves to the canonical artist rather than an // obscure namesake the search happened to list first. const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm); const pool = exact.length ? exact : artists; const best = pool.reduce( (top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top), null, ); if (!best) { setBoundedMap(artistIdCache, norm, null, ARTIST_IDENTITY_CACHE_MAX); return null; } return rememberArtistSeed(best); } catch { // Transport failures stay retryable — never negative-cached. return null; } finally { artistSeedRequests.delete(norm); } })(); artistSeedRequests.set(norm, promise); return promise; } export async function openArtistByName(name) { const norm = (name || "").trim().toLowerCase(); if (!norm) return; let id = artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined; if (id !== undefined) { const cachedSeed = artistSeedCache.has(norm) ? touchCache(artistSeedCache, norm) : null; if (id != null) openCatalogArtist(id, cachedSeed); return; } // A name-only Now Playing link still navigates immediately. Identity // resolution replaces this shell in place instead of holding the old view // behind a catalog search. const resolveRequest = ++artistPageRequest; ui.view = "catalogArtist"; catalog.artistLoading = true; catalog.error = ""; catalog.artistStageError = ""; catalog.artistPage = { artist: { id: null, name, picture: null, fans: null, album_count: null }, logos: [], provider_logos_settled: false, banner: null, banners: [], banner_custom: false, popular_tracks: [], albums: [], top_genre: null, socials: [], art_preview: true, loading_shell: true, partial_error: null, placeholder_picture: null, }; try { const res = await api.catalogSearch(name); const artists = res.artists ?? []; // Prefer the most-followed match so a shared name ("Pond", "Panda // Bear") resolves to the canonical artist, not an obscure namesake // that Deezer's search happens to list first. const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm); const pool = exact.length ? exact : artists; const best = pool.reduce( (top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top), null, ); id = best?.id ?? null; setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX); if (best) rememberArtistSeed(best); if ( id != null && resolveRequest === artistPageRequest && ui.view === "catalogArtist" ) { return openCatalogArtist(id, best); } if (resolveRequest === artistPageRequest && id == null) { catalog.error = "Artist not found."; catalog.artistPage = null; } } catch (error) { // Only a successful empty search is safe to negative-cache. A // transport/provider failure must remain retryable on the next click. if (resolveRequest === artistPageRequest) { catalog.error = String(error); catalog.artistPage = null; } } finally { if (resolveRequest === artistPageRequest) catalog.artistLoading = false; } } /** Local library albums are rendered by the SAME page as catalog records — * there is no second album view. Their ids are namespaced so they can never * collide with a Deezer album id in the page cache or an identity check. * `openCatalogAlbum` refuses these; they go through `openLibraryAlbum`. */ const LIB_ALBUM_PREFIX = "lib:"; export const isLibraryAlbumId = (id) => typeof id === "string" && id.startsWith(LIB_ALBUM_PREFIX); export const libraryAlbumIdOf = (id) => isLibraryAlbumId(id) ? Number(id.slice(LIB_ALBUM_PREFIX.length)) : null; /** * Open a LOCAL album on the album page. Same view, same shape — a library * record simply has no catalog counterpart to fetch, so the page is composed * from what the library itself knows: its tracks, their durations and * bitrates, and the cover already on disk. * * The remote-only sections (listeners, editions, prose) are absent rather than * empty; the page hides what it has no data for. Playback stays LOCAL — the * point of a library record is the file you already have. */ export async function openLibraryAlbum(albumId, opts = {}) { const id = Number(albumId); if (!Number.isFinite(id)) return; captureScroll(); // A verified navigation supersedes any still-running name search, exactly // as openCatalogAlbum does — otherwise a slow resolve can land on top. albumResolveRequest += 1; const request = ++albumPageRequest; ui.view = "catalogAlbum"; ui.albumId = null; catalog.albumPage = null; catalog.albumLoading = true; catalog.albumSeed = opts.seed ?? null; catalog.error = ""; const active = () => request === albumPageRequest && ui.view === "catalogAlbum"; try { if (!libraryStore.loaded) await refreshLibrary(); const meta = libraryStore.albums.find((a) => a.id === id) ?? null; if (!meta) throw new Error("album is not in your library"); const [tracks, cover] = await Promise.all([ api.albumTracks(id), getArt(meta.art_path).catch(() => null), ]); if (!active()) return; catalog.albumPage = { local: true, libraryAlbumId: id, libraryTracks: tracks, artPath: meta.art_path ?? null, album: { id: `${LIB_ALBUM_PREFIX}${id}`, title: meta.title, artist: meta.artist, cover: cover ?? null, release_date: meta.year ? String(meta.year) : null, record_type: "album", track_count: tracks.length, is_soundtrack: false, }, // Shaped like catalog tracks so the tracklist renders unchanged; // `path`/`bitrate_kbps` ride along for the local row and playback. tracks: tracks.map((t) => ({ id: `${LIB_ALBUM_PREFIX}t${t.id}`, title: t.title, artist: t.artist || meta.artist, album: meta.title, album_id: null, cover: cover ?? null, duration_secs: t.duration_secs ?? null, preview: null, track_no: t.track_no ?? null, unreleased: false, path: t.path, bitrate_kbps: t.bitrate_kbps ?? null, })), album_plays: null, album_listeners: null, totals_summed: false, }; recordVisit({ view: "catalogAlbum", label: meta.title, albumId: `${LIB_ALBUM_PREFIX}${id}`, artist: meta.artist, art: cover ?? null, }); } catch (error) { if (active()) { catalog.error = String(error); catalog.albumPage = null; } } finally { if (active()) catalog.albumLoading = false; } } /** Confirmed answers from `openAlbumByName`, keyed on the normalized pair: * an album id, or `null` for a record no catalog has. Both are worth keeping — * a local-only album misses every time, and remembering the miss is what makes * a second click instant instead of re-running the search. Only SETTLED * answers land here; a transport failure stays retryable. */ const albumNameResolves = new Map(); /** Resolve an album by artist + title (via catalog search) and open its page — * used by the now-playing album/song links and the sidebar's recently-played * rows when there's no catalog albumId (e.g. a local library track, which * carries no catalog identity of any kind). * * Returns the album id it opened, or `null` when nothing matched — callers * that have a fallback destination need to know which happened. */ export async function openAlbumByName(artist, title, opts = {}) { const request = ++albumResolveRequest; if (opts.albumId != null) { openCatalogAlbum(opts.albumId, opts); return opts.albumId; } if (!artist || !title) return null; const na = catNorm(artist); const nt = catNorm(title); const key = `${na}|${nt}`; if (albumNameResolves.has(key)) { const id = albumNameResolves.get(key); if (id != null) openCatalogAlbum(id, opts); return id; } try { const res = await api.catalogSearch(`${artist} ${title}`); if (request !== albumResolveRequest) return null; // A title-only or first-result fallback can cross into a namesake's // catalog. Without a verified id, both artist and title must match. let match = res.albums?.find( (album) => catNorm(album.artist) === na && catNorm(album.title) === nt, ); // Deezer's SEARCH index is not the whole discography. The artist page // merges a MusicBrainz supplement on top of it, so records Deezer does // not carry at all still have a real id, a real release date and full // Last.fm figures there — Panda Bear's "A ? of WHEN" is one, and it is // exactly what made this resolve give up and fall through to a bare // local page. Ask the ARTIST'S OWN discography before concluding the // record does not exist; it is the same list the artist page's album // expander opens from, and it is already cached whenever that page has // been visited. if (!match) { const seed = await resolveArtistSeedByName(artist); if (request !== albumResolveRequest) return null; if (seed?.id != null) { const page = await getCatalogArtistPage(seed.id).catch(() => null); if (request !== albumResolveRequest) return null; match = page?.albums?.find((album) => catNorm(album.title) === nt); // The cached page is only SUPPLEMENTED once the artist page // has been visited — cold, it is the raw Deezer list and the // MB-only records are still missing. Run the same merge the // artist page and the Now Playing panel run, rather than // concluding the record does not exist. if (!match) { const identity = await api .catalogArtistIdentity(seed.id, seed.name ?? artist) .catch(() => null); if (request !== albumResolveRequest) return null; if (identity?.albums?.length) { const merged = mergeArtistIdentity( { artist: { name: seed.name ?? artist }, albums: page?.albums ?? [], socials: [], typeHints: [], }, identity, ); match = merged?.albums?.find( (album) => catNorm(album.title) === nt, ); } } } } // A settled miss is an answer ABOUT THE RECORD: neither the catalog nor // the artist's own discography has it. Negative-cache that. A throw // below is an answer about the network, and is not cached. albumNameResolves.set(key, match ? match.id : null); if (!match) return null; openCatalogAlbum(match.id, opts); return match.id; } catch { // no album match — leave the view as-is return null; } } /** Open a row from the recently-played list. * * Shared, because two surfaces show that list — the sidebar's history and the * search page's "Jump back in" — and the rules below are identity rules. A * second copy would be a second opinion about which record a history row is, * which is exactly the mistake the namesake guard in `openAlbumByName` exists * to prevent. */ export async function openRecentAlbum(a) { if (a?.kind === "catalog" && a.catalog_album_id) { // The catalog page, not the old library album view — streamed records // live in the catalog. The seed paints title/artist/cover immediately // instead of flashing a skeleton. openCatalogAlbum(a.catalog_album_id, { seed: { id: a.catalog_album_id, title: a.title, artist: a.artist, cover: a.art_url ?? null, }, }); return; } // A library row has no catalog id to open with — local albums carry no // catalog identity at all (`albums.mbid` is null and never surfaced) — so // the record is resolved by artist + title, where BOTH must match // normalized or nothing opens. That namesake guard lives in // openAlbumByName and is the reason this can't just take a first hit. // // Either way it is the SAME album page: a record no catalog has (Panda // Bear's "A ? of WHEN", verified live) opens from the library's own data // instead. There is no second album view to fall back to. if (await openAlbumByName(a.artist, a.title)) return; void openLibraryAlbum(a.album_id); } /** Save the file(s) you're already streaming into the library — no re-search, * no re-source. `scope`: "track" (current) or "album" (all streamed tracks in * the queue). Downloads the KNOWN peer file(s) directly (skipping the search * that made the old button fail) → tag/organize pipeline. Returns { ok, msg } * for a UI toast. */ export async function saveStream(scope) { const items = scope === "album" ? player.queue.filter((it) => it.stream) : player.queue[player.queueIndex]?.stream ? [player.queue[player.queueIndex]] : []; if (items.length === 0) return { ok: false, msg: "Nothing streaming" }; try { // Cached tracks organize instantly; only un-streamed ones download. const [organized, queued] = await api.saveToLibrary( items.map((it) => ({ username: it.stream.username, path: it.path, size: it.stream.size, })), ); let msg; if (queued > 0) { msg = organized > 0 ? `Saved ${organized} · downloading ${queued}` : `Downloading ${queued}…`; } else { msg = organized > 1 ? `Saved ${organized} to library` : "Saved to library"; } return { ok: true, msg }; } catch { return { ok: false, msg: "Couldn’t save" }; } } /** 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" // Declared links for the artist ({ kind, url }), already fetched as part of // the artist page below — kept rather than discarded so the Now Playing // panel can show the same chips the artist page does. socials: [], // MusicBrainz's authoritative primary/secondary types, merged in by // `applyDiscoveryIdentity`. The panel files its releases by these exactly // as the artist page does; without them the same discography sorted two // different ways depending on which surface you were looking at. typeHints: [], }); const bioCache = new Map(); // normalized name → bio text (or null) const BIO_CACHE_MAX = 64; /** Deezer's "this artist has no photo" portrait is the md5 of the EMPTY * STRING. Crucially the API still returns a normal-looking per-artist hash * that merely REDIRECTS there, and the placeholder has the same dimensions as * a real portrait — so only the redirect target identifies it. The response's * final URL is readable (the CDN sends CORS headers), and the fetch warms the * same image the card is about to display. */ const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e"; const portraitProbeCache = new Map(); // picture url → is-placeholder async function deezerPortraitMissing(url) { if (!url) return true; if (portraitProbeCache.has(url)) return touchCache(portraitProbeCache, url); let missing = false; try { const response = await fetch(url); missing = response.ok && response.url.includes(DEEZER_EMPTY_PICTURE_HASH); } catch { // Offline or blocked: keep the portrait rather than guess it away. missing = false; } setBoundedMap(portraitProbeCache, url, missing, 64); return missing; } /** Replace the portrait of an artist whose Deezer record was proven to hold * more than one act. * * `deezerPortraitMissing` above catches the grey silhouette. It cannot catch * this: a fused record's `picture` is a genuine photograph at genuine * dimensions of a genuine artist — just not the one the page is about. Wand's * is the co-tenant who uploaded last, sitting above a biography of the LA * psych band. The hero the artist page already resolved is identity-gated, so * it is the honest replacement; if there is none, no picture beats a wrong one. * * Both surfaces are corrected — the page's blurred instant-shell seed and the * Now Playing "About the artist" card, which is where the wrong face is * actually legible. */ async function disownFusedPortrait(artistId, merged) { const suspect = merged.artist?.picture ?? null; const seed = await getCatalogArtistHeroSeed(artistId).catch(() => null); const replacement = seed?.banner ?? merged.banner ?? null; const current = catalog.artistPage; if (current && sameArtistId(current.artist?.id, artistId)) { const corrected = { ...current, artist: { ...current.artist, picture: replacement }, placeholder_picture: replacement, }; catalog.artistPage = corrected; // Same reason the identity merge writes itself back: a warm revisit // must not resurrect what this pass just disproved. const key = artistCacheKey(artistId); if (key) { catalogArtistPageCache.delete(key); catalogArtistPageCache.set(key, corrected); trimArtistRequestCache(catalogArtistPageCache); } rememberArtistSeed(corrected.artist); savePageReplay(artistId, corrected); } if ( discovery.picture && suspect && discovery.picture === suspect && sameArtistId(discovery.artistId, artistId) ) discovery.picture = replacement; // Hand the verdict to the backend so the surfaces that CANNOT afford to // re-derive it agree too — above all the suggestion dropdown, which is // typed against per keystroke and is where the wrong face is met first. try { await api.rememberFusedArtistPortrait(artistId, replacement); } catch { // Best-effort: this session is already correct without it. } } /** Short artist bio, resolved by the backend through the artist's OWN * MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw * browser fetch that displayed Wikipedia's FIRST hit for * " musician band" with no verification, which is how the panel came * to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under * Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the * catalog id to anchor identity, so it runs after the id resolves. */ async function fetchArtistBio(artistId, name) { try { return (await api.catalogArtistBio(artistId, name)) ?? null; } catch { return null; } } /** Run the artist page's identity merge against the panel's own list. * * Deliberately NOT `applyArtistIdentity`: that one writes `catalog.artistPage`, * which would overwrite whatever artist page the user is actually looking at. * Only the merge is shared — see [`mergeArtistIdentity`] — so the panel gains * the supplemented releases and the type hints without touching the page. * * Guarded on the normalized artist name, like everything else here, so a track * change mid-flight cannot land one artist's supplement on another's list. */ async function applyDiscoveryIdentity(artistId, norm, name) { try { const identity = await api.catalogArtistIdentity(artistId, name ?? ""); if (discovery.artist !== norm) return; if (!(identity?.albums?.length || identity?.type_hints?.length)) return; const merged = mergeArtistIdentity( { // `artist.name` is load-bearing: `artistScopedRecords` uses it to // reject rows belonging to a co-tenant of a fused Deezer record. artist: { name }, albums: discovery.albums, socials: discovery.socials, typeHints: discovery.typeHints, }, identity, ); if (discovery.artist !== norm) return; discovery.albums = merged.albums ?? discovery.albums; discovery.typeHints = merged.typeHints ?? discovery.typeHints; if (merged.socials?.length) discovery.socials = merged.socials; } catch { // Best-effort: the panel is complete without the supplement. } } /** Resolve the playing artist's catalog id, then load their discography + * related artists for the Now Playing discovery sections. Guarded by the * normalized name so rapid track changes don't clobber each other. */ export async function loadDiscovery(artistName) { const norm = (artistName || "").trim().toLowerCase(); if (!norm) { discovery.artist = null; return; } const resuming = discovery.artist === norm; if (resuming && discovery.pageLoaded) return; if (resuming && discovery.loading) { // If the panel reopened after the delayed page check already skipped, // let the in-flight overview request perform one exhaustive retry // before it settles instead of losing refinement permanently. if (ui.nowPlaying) discovery.pageWanted = true; return; } discovery.artist = norm; discovery.loading = true; discovery.pageWanted = ui.nowPlaying; if (!resuming) { discovery.pageLoaded = false; discovery.artistId = null; discovery.picture = null; discovery.fans = null; discovery.albumCount = null; discovery.albums = []; discovery.related = []; discovery.socials = []; discovery.typeHints = []; discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null; } try { let id = discovery.artistId ?? (artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined); if (id === undefined) { const res = await api.catalogSearch(artistName); const match = res.artists?.find((a) => a.name.trim().toLowerCase() === norm) ?? res.artists?.[0] ?? null; id = match?.id ?? null; setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX); if (match) rememberArtistSeed(match); } if (discovery.artist !== norm) return; // track changed while searching if (id == null) return; discovery.artistId = id; // The bio is identity-anchored, so it can only start once the catalog // id is known. if (!bioCache.has(norm)) { fetchArtistBio(id, artistName).then((bio) => { // Only PIN a real answer. A null is usually a transient // MusicBrainz throttle (its 1 req/s gate is shared with the // identity pass and the library warmer), and caching that // would blank the card for the rest of the session even // though the very next attempt succeeds. Retries are cheap — // every tier is disk cached for 24 h. if (bio) setBoundedMap(bioCache, norm, bio, BIO_CACHE_MAX); if (discovery.artist === norm) discovery.bio = bio; }); } const overviewReady = discovery.albums.length ? Promise.resolve(null) : getCatalogArtistOverview(id).catch(() => null); // The Now Playing panel can paint its useful discovery content from // the overview. Delay exhaustive classification until the artist and // open panel have remained stable, avoiding invisible work after a // rapid skip or panel close. const pageReady = (async () => { await new Promise((resolve) => setTimeout(resolve, 250)); if (discovery.artist !== norm || !ui.nowPlaying) return null; discovery.pageWanted = false; return getCatalogArtistPage(id).catch(() => null); })(); const [overview, related] = await Promise.all([ overviewReady, api.catalogRelated(id).catch(() => []), ]); if (discovery.artist !== norm) return; if (overview) { rememberArtistSeed(overview.artist); discovery.picture = overview.artist.picture ?? null; // A Deezer "picture" can exist and still be the grey silhouette // (see deezerPortraitMissing). Those artists usually DO have // provider/Bing hero art — the artist page already resolves it — // so borrow that instead of showing a placeholder. void (async () => { const portrait = discovery.picture; if (portrait && !(await deezerPortraitMissing(portrait))) return; const seed = await getCatalogArtistHeroSeed(id).catch(() => null); if ( discovery.artist === norm && discovery.picture === portrait && seed?.banner ) discovery.picture = seed.banner; })(); discovery.fans = overview.artist.fans ?? null; discovery.albumCount = overview.artist.album_count ?? overview.albums.length; discovery.albums = overview.albums ?? []; } discovery.related = related ?? []; let page = await pageReady; if (discovery.artist !== norm) return; if (!page && discovery.pageWanted && ui.nowPlaying) { discovery.pageWanted = false; page = await getCatalogArtistPage(id).catch(() => null); if (discovery.artist !== norm) return; } if (page) { rememberArtistSeed(page.artist); discovery.picture = page.artist.picture ?? null; discovery.fans = page.artist.fans ?? null; discovery.albumCount = page.artist.album_count ?? page.albums.length; discovery.albums = page.albums ?? []; discovery.socials = page.socials ?? []; discovery.typeHints = page.typeHints ?? []; discovery.pageLoaded = true; discovery.pageWanted = false; // The same MusicBrainz merge the artist page runs. Without it this // panel showed the raw provider list — My Bloody Valentine with // four releases, because Loveless and Isn't Anything are supplements // Deezer's regional catalog does not carry — and filed everything // without `type_hints`, so the same discography sorted two ways // depending on which surface you were looking at. Skipped when the // page arrived already merged (a warm revisit through the cache). if (!page.identity_merged) { void applyDiscoveryIdentity(id, norm, page.artist?.name ?? artistName); } } } catch { // leave whatever loaded; sections just stay empty } finally { if (discovery.artist === norm) discovery.loading = false; } } // ---- auto-sourcing resolver ---- // The catalog is the app's front door: picking a song or album quietly runs // a Soulseek search, ranks the sources, and streams/downloads the winner. // The manual Search view stays available as a fallback. export const resolver = $state({ active: false, label: "", error: "", }); 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 } } // ---- remembered stream sources ------------------------------------------ // The last source each catalog track streamed from, persisted so a track can // play again WITHOUT a Soulseek search: its bytes are already in the stream // cache, and the resolve would only rediscover the peer that put them there. // The memory is a HINT, not a promise — before any search is skipped the // backend confirms the exact file is byte-complete on disk, so eviction, a // half-finished transfer, or a source that never delivered all fall back to // the normal resolver on their own. const STREAM_SOURCE_STORE = "soulstream.streamSources.v1"; const STREAM_SOURCE_CAP = 400; let streamSources = null; // Map key → {username, path, size, duration, quality} function streamSourceKeys(trackId, artist, title) { const keys = []; if (trackId != null) keys.push(`t:${trackId}`); if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase()); return keys; } function loadStreamSources() { if (streamSources) return streamSources; streamSources = new Map(); try { for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) { streamSources.set(k, v); } } catch { // corrupt store — start over; losing it only costs one search per track } return streamSources; } function saveStreamSources() { try { const entries = [...streamSources.entries()]; localStorage.setItem( STREAM_SOURCE_STORE, JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)), ); } catch { // quota — same story, the memory is only a shortcut } } /** Called every time a stream item is queued to PLAY (not when it proves * itself) — eager on purpose: a source that never delivers leaves no complete * cache file, so the probe rejects it later and nothing was lost. Insertion * order doubles as the LRU for the cap in saveStreamSources. */ function rememberStreamSource(item) { if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return; const map = loadStreamSources(); const record = { username: item.stream.username, path: item.path, size: item.stream.size, duration: item.duration ?? null, quality: item.quality ?? null, }; for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) { map.delete(key); map.set(key, record); } saveStreamSources(); } /** The remembered source for this track, but ONLY if its cache file is * byte-complete right now. Anything else is null: resolve normally. */ async function cachedStreamSourceFor(trackId, artist, title) { const map = loadStreamSources(); for (const key of streamSourceKeys(trackId, artist, title)) { const src = map.get(key); if (!src) continue; try { if (await api.streamCacheReady(src.username, src.path, src.size)) return src; } catch { // backend unavailable — resolve normally } } return null; } 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", ); } /** The tier gridCover asks for, chosen by device pixel ratio. * * The biggest consumer is the album expander's rail art at a FIXED 209px — the * rail column is 250px regardless of window or monitor size, so a 4K display * does not make the art box bigger; only DPR does. 209px needs 418 device * pixels at DPR 2, which 500 covers, and 4K on Windows normally runs 150% or * 200% scaling. Measured side by side at DPR 2 the 500 and 1000 sources are * indistinguishable at actual size; at DPR 3 the 500 is visibly soft. * So: 500 below that line, 1000 above it, rather than paying 3.6x the bytes * (148KB -> 501KB per cover) on every display to fix the top one. * Coverart Archive has no 1000 rendition — 1200 is its next step up. */ function coverTier() { const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1; return dpr > 2 ? { square: 1000, caa: 1200 } : { square: 500, caa: 500 }; } /** Responsive card-sized variant of a canonical cover URL. The resolver keeps * its original/high-resolution winner for the player; neither the 150px grid * nor the expander's 209px rail should decode 9-million-pixel Apple masters * into GPU memory to do it. */ export function gridCover(url) { if (!url || url.startsWith("data:")) return url; const { square, caa } = coverTier(); try { const parsed = new URL(url); if (parsed.hostname.endsWith("dzcdn.net")) { return url.replace( /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, `/${square}x${square}$1.$2`, ); } if (parsed.hostname.endsWith("mzstatic.com")) { parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, `/${square}x${square}bb.jpg`); return parsed.toString(); } if (parsed.hostname.endsWith("coverartarchive.org")) { parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, `/front-${caa}`); return parsed.toString(); } } catch { return url; } return url; } /** A tiny CDN-rendered variant used for blurred placeholders and palette * sampling. Deezer and Apple both resize the same source artwork by URL. */ export function tinyCover(url) { if (!url) return null; try { const parsed = new URL(url); if (parsed.hostname.endsWith("mzstatic.com")) { parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/96x96bb.jpg"); return parsed.toString(); } if (parsed.hostname.endsWith("coverartarchive.org")) { const originalPath = parsed.pathname; parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-250"); return parsed.pathname === originalPath && !originalPath.endsWith("/front-250") ? null : parsed.toString(); } if (!parsed.hostname.endsWith("dzcdn.net")) return null; } catch { return null; } const tiny = url.replace( /\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i, "/56x56$1.$2", ); return tiny === url ? null : tiny; } /** Find the best source for a song and stream it, failing over to the next * candidate when a peer never starts sending. Songs already in the library * play locally (with their album as the queue). * opts: { cover, trackId, albumId, album, expectSecs } — catalog context for * art, badges, album-folder source preference, and length verification. */ export async function streamSong(artist, title, opts = {}) { if (resolver.active) return; const local = await libraryTrackFor(artist, title); if (local) { playAlbum( local.tracks, `${local.album.artist} — ${local.album.title}`, local.index, local.album.art_path ?? null, ); player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; return; } // Streamed before and still byte-complete in the cache? Play those bytes // now — no search, no resolver popup, no network requirement. This is the // second-long "finding best source…" beat that used to sit between songs // of an already-cached album. Checked before `requireOnline` on purpose: // a cached track is playable offline. const cached = await cachedStreamSourceFor(opts.trackId ?? null, artist, title); if (cached) { autoAlbum = null; player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; player.queue = [ { path: cached.path, title, sub: opts.album ? `${artist} — ${opts.album}` : artist, artist, album: opts.album ?? null, duration: cached.duration ?? opts.expectSecs ?? null, art: null, artUrl: hqCover(opts.cover) ?? null, trackId: opts.trackId ?? null, stream: { username: cached.username, size: cached.size }, quality: cached.quality ?? null, }, ]; playQueueIndex(0); // Keep the album going exactly like the resolved path does. if (opts.albumId) { autoAlbum = { albumId: opts.albumId, albumTitle: opts.album ?? null, cover: hqCover(opts.cover) ?? null, tracks: null, pending: false, }; maybeExtendAlbumQueue(); } return; } if (!requireOnline()) return; autoAlbum = null; player.track = { title, sub: "finding best source…", source: "stream" }; player.status = "buffering"; // Before the Soulseek resolve, not after it. Every caller already hands us // the record this track belongs to, and the panel loads its tracklist off // `catalogRef.albumId` — so setting it here lets the record load in // PARALLEL with finding a source, instead of starting only once a peer has // been picked. That wait was the whole "why does it take time to show the // rest of the album" delay: the id was in hand the moment you clicked. player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null }; const { responses, artistIncluded } = await resolve( `${artist} — ${title}`, [`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)], (rs) => songCandidates(rs, title, true, opts.album, opts.expectSecs, artist).length, artist, ); const candidates = songCandidates( responses, title, true, opts.album, opts.expectSecs, artist, !artistIncluded, ); if (candidates.length === 0) { player.status = "idle"; player.track = null; // Hand back the record claimed above: nothing is playing from it. player.catalogRef = { albumId: null, trackId: null }; resolver.error = `No streamable sources found for “${title}”`; return; } for (const c of candidates.slice(0, 4)) { player.queue = [ { path: c.file.path, title, // "Artist — Album" whenever the record is known, which is the // same shape `playAlbum` writes. The panel splits `sub` on the // dash to get its clickable artist/album lines, so a bare // artist here meant a single track NEVER named the record it // came from — even though `opts.album` was passed by every // caller and sat unused one line below. sub: opts.album ? `${artist} — ${opts.album}` : artist, artist, album: opts.album ?? null, duration: c.file.duration ?? opts.expectSecs ?? null, art: null, artUrl: hqCover(opts.cover) ?? null, trackId: opts.trackId ?? null, stream: { username: c.username, size: c.file.size }, quality: qualityOf(c.file), }, ]; const queuedItem = player.queue[0]; playQueueIndex(0); if (await waitForStreamStart(15000)) { // Keep the album going: resolve + pre-buffer the next album // track in the background, Spotify-style. // // Only if this song still owns the queue. `waitForStreamStart` // polls the GLOBAL player status, so a play command issued while // this one was buffering can satisfy it — and that command already // cleared autoAlbum on its way past. Installing ours over the top // would point the album fill at a queue belonging to something // else entirely. if (opts.albumId && player.queue[0] === queuedItem) { autoAlbum = { albumId: opts.albumId, albumTitle: opts.album ?? null, cover: hqCover(opts.cover) ?? null, tracks: null, pending: false, }; 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 } } // Every track of this record remembered and still byte-complete in the // cache? Queue the whole album from memory — no search, no popup, and // playable offline. A single unremembered or evicted track falls through // to the resolver exactly as before, which then re-teaches the memory via // the queue it builds. if (album.id) { const known = await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null); if (known?.length) { const sources = []; for (const t of known) { const src = await cachedStreamSourceFor(t.id ?? null, album.artist, t.title); if (!src) { sources.length = 0; break; } sources.push({ t, src }); } if (sources.length) { autoAlbum = null; player.queue = sources.map(({ t, src }) => ({ path: src.path, title: t.title, sub: `${album.artist} — ${album.title}`, artist: album.artist, album: album.title ?? null, duration: src.duration ?? t.duration_secs ?? null, art: null, artUrl: album.cover ?? null, trackId: t.id ?? null, stream: { username: src.username, size: src.size }, quality: src.quality ?? null, })); player.catalogRef = { albumId: album.id ?? null, trackId: null }; playQueueIndex(0); return; } } } if (!requireOnline()) return; autoAlbum = null; player.track = { title: album.title, sub: "finding best source…", source: "stream" }; player.status = "buffering"; // Resolve the authoritative tracklist FIRST. Its length is the true track // count — Deezer's artist-album LIST omits nb_tracks, so album.track_count // is routinely null when Play is pressed on the artist page — and its // titles are what the queue should show instead of peer filenames. Feeding // the real count into albumCandidates both stops the ≥3-files filter from // rejecting small releases and lets the folder-count preference pick the // edition that matches the release (e.g. the 3-track single, not a 7-track // CDR that would leak bonus tracks into the queue as raw filenames). const catalogPromise = album.id ? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null) : Promise.resolve(null); // Overlap the peer search with the tracklist fetch when the release is // provably multi-track — the search is the long pole, and the tracklist // round-trip used to serialize in front of it. `liveTrackCount` feeds the // early-exit counter and tightens itself the moment the tracklist lands. // A possible single (unknown count, or single/EP typed) keeps the // sequential order, because a confirmed single must take the SONG path // and must not race an already-active album resolve. const provablyMultiTrack = (album.track_count ?? 0) > 1 || album.record_type === "album"; let liveTrackCount = album.track_count || null; const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length; let resolvePromise = null; if (provablyMultiTrack) { resolvePromise = resolve( `${album.artist} — ${album.title}`, [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], goodCount, album.artist, ); } const catalogTracks = await catalogPromise; const trackCount = catalogTracks?.length || album.track_count || null; liveTrackCount = trackCount; // A confirmed single is one track — stream it through the song path, which // matches peer files by TITLE (album folder-matching can't identify a lone // file among a folder's contents) and layers on the live/remix + duration // hardening. Without this, a single fell into the album path and, with an // unknown count, was rejected for having fewer than 3 files per folder. if (trackCount === 1) { // Rare mistyped release: a search may already be in flight — let it // finish so the song path's own resolve isn't refused as re-entrant. if (resolvePromise) await resolvePromise.catch(() => {}); const t = catalogTracks?.[0]; await streamSong(album.artist, t?.title ?? album.title, { cover: t?.cover ?? album.cover, trackId: t?.id ?? null, albumId: album.id ?? null, album: album.title || null, expectSecs: t?.duration_secs ?? null, }); return; } const { responses, artistIncluded } = await (resolvePromise ?? resolve( `${album.artist} — ${album.title}`, [`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)], goodCount, album.artist, )); const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded); if (candidates.length === 0) { player.status = "idle"; player.track = null; resolver.error = `No streamable album sources found for “${album.title}”`; return; } 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), }; }); // One album resolve teaches the memory the WHOLE record, so the next // press of Play needs no search at all once these files finish // caching. A folder that never delivers is harmless — its cache stays // incomplete and the probe rejects it. for (const it of player.queue) rememberStreamSource(it); player.catalogRef = { albumId: album.id ?? null, trackId: null }; playQueueIndex(0); if (await waitForStreamStart(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]; } // ---- streaming rate ------------------------------------------------------ // `player.streamSpeed` is measured here because nothing upstream reports it. // Samples are [timestamp, bytesReceived] over a rolling window, so a transfer // that stalls decays toward zero instead of reporting its last burst forever, // and a bursty peer reads as one speed rather than a flickering number. const RATE_WINDOW_MS = 1500; let rateSamples = []; function resetStreamRate() { rateSamples = []; player.streamSpeed = 0; } function noteStreamRate(received) { const now = performance.now(); // A new file restarts the measurement: without this, the first sample of // the next track is compared against the last of the previous one. if (rateSamples.length && received < rateSamples[rateSamples.length - 1][1]) { rateSamples = []; } rateSamples.push([now, received]); while (rateSamples.length > 2 && now - rateSamples[0][0] > RATE_WINDOW_MS) { rateSamples.shift(); } } function streamRate() { if (rateSamples.length < 2) return 0; const [t0, b0] = rateSamples[0]; const [t1, b1] = rateSamples[rateSamples.length - 1]; const secs = (t1 - t0) / 1000; if (secs <= 0) return 0; return Math.max(0, (b1 - b0) / secs); } export const player = $state({ status: "idle", // idle | buffering | playing | paused track: null, // { title, sub, source: 'stream'|'local' } position: 0, duration: null, // The player bar temporarily owns position while the user is dragging the // playhead. Incoming backend ticks resume on release. scrubbing: false, volume: 0.7, muted: false, // streaming buffer info streamReceived: 0, streamTotal: 0, // Bytes/sec, DERIVED here rather than reported: the `soulseek:stream` event // carries only {received, total, done}, and unlike the download path there // is no `speed_bps` to read. 0 means "not measured yet" — the panel shows // no rate rather than a zero, because a fresh stream is not a stalled one. streamSpeed: 0, error: "", // album play queue (local tracks) queue: [], queueIndex: -1, shuffle: false, repeat: "off", // off | all | one // waveform peaks (0..1) for the current local track, [] while loading waveform: [], // How much of the TRACK `waveform` actually describes, 0..1 — reported by // the backend with the peaks themselves, so the two can never disagree. // 1 for local files and complete streams; a mid-transfer decode covers // exactly this prefix and the lanes fold it across exactly this fraction. // Where the standing bars end IS how much of the track has loaded. waveformCoverage: 1, // True while a waveform request for the CURRENT track is still working — // what separates "computing, show the loading swell" from "there is no // waveform, rest as dots". Cleared on success and on final failure. wavePending: false, // Which catalog entity is playing (drives now-playing badges). catalogRef: { albumId: null, trackId: null }, // True for a track restored from the last session whose AUDIO did not // survive — an app restart rather than a page reload. The transport reads // "paused", but there is nothing loaded to resume, so play has to re-source // it from the queue. See `resumePlayback`. needsResource: false, }); // Adaptive accent theme, derived from the playing track's album art. // Idle default matches the SoulStream brand blue (see icons/). const DEFAULT_ACCENT = "#1f9ad6"; const DEFAULT_ACCENT_HOVER = "#41b7ee"; export const theme = $state({ accent: DEFAULT_ACCENT, accentHover: DEFAULT_ACCENT_HOVER, }); 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) { // The outgoing track's final seconds go in here — which is the moment // its album can cross the floor. Re-read once that write has landed; // waiting only on the fixed timer below meant a track that crossed 45s // late never showed up until something else happened to refresh. listenFlush(); setTimeout(refreshRecents, 1500); listenSession = { key, artist, title, pending: 0, playIdP: null }; listenLastPos = null; } const delta = listenLastPos == null ? 0 : positionSecs - listenLastPos; listenLastPos = positionSecs; if (delta <= 0 || delta > 3) return; listenSession.pending += delta; if (!listenSession.playIdP && listenSession.pending >= 1) { // Two possible anchors, both exact ids, never a name. A local track is // tied to its library album through tracks.path; a STREAMED one — which // is most listening here — through the catalog album id, carrying its // own title/artist/cover because there is no local row to read them // back from. const q = player.queue?.[player.queueIndex] ?? null; listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, { albumId: player.catalogRef?.albumId ?? null, albumTitle: q?.album ?? null, albumArtist: q?.artist ?? null, coverUrl: q?.artUrl ?? null, }); listenSession.playIdP.catch(() => {}); // The row is opened at ~1s but only earns its place in recents once it // passes the floor, so re-read after the track has had time to count. listenSession.playIdP .then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000)) .catch(() => {}); } if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush(); } let started = false; let windowActivityStarted = false; let latestPlayerPosition = 0; let latestPlayerDuration = null; let latestStreamReceived = 0; let latestStreamTotal = 0; function initWindowActivity() { if (windowActivityStarted) return; windowActivityStarted = true; let active = true; let syncGeneration = 0; let syncTimer = null; const nativeUnlisteners = []; const sync = async () => { const generation = ++syncGeneration; let minimized = false; try { minimized = await api.win.isMinimized(); } catch { // Plain-browser previews have no native window API. } if (!active || generation !== syncGeneration) return; const wasActive = ui.windowActive; // A visible, unfocused window can still be watched side-by-side. Only // actual native minimization / DOM hiding should freeze visual state. ui.windowActive = !document.hidden && !minimized; if (!wasActive && ui.windowActive) { if (!player.scrubbing) player.position = latestPlayerPosition; if (latestPlayerDuration != null) player.duration = latestPlayerDuration; player.streamReceived = latestStreamReceived; player.streamTotal = latestStreamTotal; const current = player.queue[player.queueIndex]; if ( current?.stream && latestStreamReceived > 0 && latestStreamReceived < latestStreamTotal ) { waveRefreshAt = performance.now(); void loadStreamWaveform(current); } } }; const scheduleSync = () => { clearTimeout(syncTimer); syncTimer = setTimeout(sync, 50); }; void sync(); const addNativeListener = (promise) => { void promise .then((unlisten) => { if (active) nativeUnlisteners.push(unlisten); else unlisten(); }) .catch(() => {}); }; addNativeListener(api.win.onResized(scheduleSync)); addNativeListener(api.win.onFocusChanged(scheduleSync)); document.addEventListener("visibilitychange", scheduleSync); if (import.meta.hot) { import.meta.hot.dispose(() => { active = false; clearTimeout(syncTimer); document.removeEventListener("visibilitychange", scheduleSync); for (const unlisten of nativeUnlisteners) unlisten(); }); } } /** Wire backend events once, at app start. */ export function initEvents() { if (started) return; started = true; initWindowActivity(); // Preview audio-focus bridge (scrollable-track-previews). Injected here // rather than imported there: this module owns the player store, and the // preview module must stay import-cycle-free. resumeMain goes straight to // api — NOT through any wrapper that bumps the revision gateway, because // a focus-internal resume must not invalidate its own token. initPreviewBridge({ readPlayer: () => player, resumeMain: () => { // Close crossfade: come back from the preview at volume 0 and // ramp to the user's level while the preview rings down. api.playerFadeResume(PREVIEW_CROSSFADE_MS); }, }); // The `connected` event fires once, at login. A WebView reload (dev // iteration, crash recovery) lands AFTER it, so the fresh frontend // believed it was offline against a logged-in backend and pushed the // login modal at the first play attempt. Recover the real state at boot — // retried, because the first invoke can race IPC readiness at mount. void (async () => { for (let attempt = 0; attempt < 3; attempt++) { try { const status = await api.appStatus(); if (status?.connected_as && app.connection !== "online") { app.connection = "online"; app.username = status.connected_as; app.showLogin = false; } return; } catch { await new Promise((resolve) => setTimeout(resolve, 2500)); } } })(); api.onEvent("soulseek:state", (ev) => { if (ev.kind === "connected") { app.connection = "online"; app.username = ev.username; app.loginError = ""; } 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; // Mirror the store rather than zeroing it, the same way // latestPlayerDuration does directly above. These counters are // what a window restore copies back, and a fully cached track // is reported COMPLETE before the audio thread ever says // "buffering" — zeroing here threw that away, so minimising and // restoring turned a finished transfer back into the // indeterminate "Streaming from " sweep with no further // event coming to correct it. latestStreamReceived = player.streamReceived; latestStreamTotal = player.streamTotal; player.scrubbing = false; player.error = ""; break; case "playing": { player.status = "playing"; // A track re-sourced by a session restore starts at zero; put // the reader back where they were. Taken exactly once, so an // ordinary resume later in the same track is unaffected. const seekTo = takeResumeSeek(); if (seekTo != null) api.playerControl("seek", seekTo); break; } case "paused": player.status = "paused"; listenFlush(); break; case "stopped": player.scrubbing = false; listenFlush(); stopPlayback(); break; case "track-ended": listenFlush(); if (ev.gapless) advanceGaplessly(); else nextTrack(true); break; case "position": latestPlayerPosition = ev.secs; if (ev.duration != null) latestPlayerDuration = ev.duration; if (!player.scrubbing && ui.windowActive) player.position = ev.secs; if (ev.duration != null && player.duration !== ev.duration) { player.duration = ev.duration; } listenTick(ev.secs); break; case "error": player.status = "idle"; player.error = ev.message; break; } }); api.onEvent("soulseek:stream", (s) => { // Rate is measured here because nothing upstream reports it. A rolling // average over ~1.5s of samples, not the instantaneous delta: Soulseek // transfers arrive in bursts, and a per-event figure reads as a number // flickering between 0 and several MB/s rather than a speed. noteStreamRate(s.received); latestStreamReceived = s.received; latestStreamTotal = s.total; if (ui.windowActive) { player.streamReceived = s.received; player.streamTotal = s.total; player.streamSpeed = streamRate(); } const cur = player.queue[player.queueIndex]; if (s.done) { // Fully cached → final waveform, and the peer's slot is // definitely free, so make sure the next track is pre-buffering. if (cur?.stream) loadStreamWaveform(cur, true); preloadNextStream(); tryAppendNext(); } else if ( ui.windowActive && cur?.stream // Until the FIRST peaks land, retry fast — the earliest decodes // race the transfer's opening seconds and often see too little // file to succeed, and audio audibly playing over a still-empty // lane was the gap being closed here. Once bars exist, the // geometric decode thresholds make 3s the right cadence. && performance.now() - waveRefreshAt > (player.waveform.length ? 3000 : 700) ) { // Still downloading: refresh the partial waveform so it grows // with the transfer. waveRefreshAt = performance.now(); loadStreamWaveform(cur); } }); // A next-track pre-buffer finished downloading: append it to the audio // sink so the upcoming transition is gapless. api.onEvent("soulseek:preload", (p) => { if (p.done) tryAppendNext(); }); let libraryRefreshTimer = null; api.onEvent("soulseek:library-changed", () => { clearTimeout(libraryRefreshTimer); libraryRefreshTimer = setTimeout(refreshLibrary, 400); }); api.onEvent("soulseek:playlists-changed", refreshPlaylists); // The 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; resetStreamRate(); loadWaveform(path); setAccentFromArt(artPath); await api.playLocal(path, null); } /** Play an album/playlist (list of library tracks) starting at `start`. * `artPath` is the album cover; playlist tracks carry their own art_path. */ 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. rememberStreamSource(item); player.streamReceived = 0; player.streamTotal = item.stream.size; // A fresh track must not inherit the previous one's refresh clock — // that throttled the FIRST partial waveform by up to three seconds of // audible-but-laneless playback. waveRefreshAt = 0; resetStreamRate(); if ("trackId" in item) player.catalogRef.trackId = item.trackId; appendedNextPath = null; setAccentFromArtUrl(item.artUrl ?? null); 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; // Claim the slot BEFORE awaiting, and give it back only if the append // fails. Setting it after the await is a check-then-act race: a fully // cached track has two callers firing in the same tick — the stream // event's `done` branch and play_stream's own `.then()` — and both would // pass the guard above, append the same source to the sink twice, and // leave the audio a whole track behind the panel from the next boundary on. const claimed = appendedNextPath; appendedNextPath = item.path; try { const ok = await api.preloadCachedStream( item.stream.username, item.path, item.stream.size, item.duration ?? null, ); if (!ok && appendedNextPath === item.path) appendedNextPath = claimed; } catch { // cache incomplete or command unavailable — the normal (non-gapless) // advance still works. if (appendedNextPath === item.path) appendedNextPath = claimed; } } function advanceGaplessly() { const next = gaplessNextIndex(); // The backend only labels a boundary gapless when it has this source // ready in the same sink. If state changed meanwhile, fall back safely. if (next < 0) return nextTrack(true); const item = player.queue[next]; player.queueIndex = next; player.position = 0; player.duration = item.duration; latestPlayerPosition = 0; latestPlayerDuration = item.duration ?? null; latestStreamReceived = 0; latestStreamTotal = 0; player.status = "playing"; appendedNextPath = null; if (item.stream) { // A pre-buffered stream track, playing from its completed cache file. // Report it COMPLETE rather than zero-of-zero: `streamTotal === 0` is // the UI's "no byte counts yet" state, so zeroing a finished track left // the header showing the indeterminate "Streaming from " sweep // with no further event ever coming to correct it. Gapless is only // offered for a fully cached file — that is what made it appendable — // so its size IS its received count. const size = item.stream.size; player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path }; player.streamReceived = size; player.streamTotal = size; latestStreamReceived = size; latestStreamTotal = size; resetStreamRate(); if ("trackId" in item) player.catalogRef.trackId = item.trackId; setAccentFromArtUrl(item.artUrl ?? null); loadStreamWaveform(item); 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 rest // of the album is resolved (a background Soulseek search per track) and // appended to the queue, so the album keeps going. // // It used to append exactly ONE track and wait for the next track start to // append the one after, which meant a song started from search or an artist // page showed a queue of one or two entries however long the record was. It // still resolves one at a time — a track cannot be queued before a peer with // it is found — but it no longer stops after the first, so the queue fills in // over the following minute instead of never. let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending } async function maybeExtendAlbumQueue() { const ctx = autoAlbum; if (!ctx || ctx.pending) return; // Resume from the LAST thing queued, not from what is playing: the fill // outlives the track it started on, and a run cut short (offline, a // catalog hiccup) has to be able to pick up where it stopped on the next // track start. When the album is already complete this finds nothing left // to do and simply retires the context. const anchor = player.queue[player.queue.length - 1]; if (!anchor?.stream) return; ctx.pending = true; try { if (!ctx.tracks) { const page = await api.catalogAlbum(ctx.albumId); if (autoAlbum !== ctx) return; ctx.tracks = page.tracks; ctx.albumTitle ??= page.album.title ?? null; ctx.cover ??= page.album.cover ?? null; } let idx = ctx.tracks.findIndex((t) => t.id === anchor.trackId); if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(anchor.title)); if (idx < 0) { autoAlbum = null; return; } for (let i = idx + 1; i < ctx.tracks.length; i++) { const t = ctx.tracks[i]; if (app.connection !== "online") return; // A remembered, still-byte-complete source skips this track's // background search entirely — the common case for a record that // has streamed before. Same bail guards as the searched path. const known = await cachedStreamSourceFor(t.id ?? null, t.artist, t.title); if (known) { if (autoAlbum !== ctx || !player.queue.includes(anchor)) return; const item = { path: known.path, title: t.title, sub: t.artist, artist: t.artist ?? null, album: ctx.albumTitle ?? null, duration: known.duration ?? t.duration_secs ?? null, art: null, artUrl: ctx.cover, trackId: t.id, stream: { username: known.username, size: known.size }, quality: known.quality ?? null, }; player.queue.push(item); preloadNextStream(); tryAppendNext(); continue; } // Same sanitized ladder as resolve(), inlined because this is a // background append that must not toggle resolver.active (that // would block user-triggered resolutions while it searches). const goodCount = (rs) => songCandidates(rs, t.title, true, ctx.albumTitle, t.duration_secs, t.artist).length; const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`); const fallback = sanitizeSearchQuery(simplifyTitle(t.title)); let artistIncluded = true; let responses = primary ? await collectSources(primary, 7000, goodCount) : []; if (!responses.length && fallback && fallback !== primary) { responses = await collectSources(fallback, 7000, goodCount); artistIncluded = normText(fallback).includes(normText(t.artist ?? "")); } else if (primary) { artistIncluded = normText(primary).includes(normText(t.artist ?? "")); } // Bail if the user moved on while we were searching. BOTH tests // are load-bearing: // // - context identity, because every path that replaces the queue // clears autoAlbum; // - the anchor still being IN the queue, because context identity // alone is not enough. streamSong installs autoAlbum only after // `await waitForStreamStart`, which polls the GLOBAL player // status; a second play command issued during that window nulls // autoAlbum first and streamSong then re-installs its own // context over the top. The fill would go on appending one // album's tracks to the other album's live queue. // // Not the currently-playing item: this loop deliberately outlives // the track it started on, so advancing within the same album must // not stop it. The anchor is a queue MEMBER, so it survives our own // appends and disappears exactly when the queue is replaced. if (autoAlbum !== ctx || !player.queue.includes(anchor)) return; const best = songCandidates( responses, t.title, true, ctx.albumTitle, t.duration_secs, t.artist, !artistIncluded, )[0]; if (!best) continue; // no streamable source — skip this track const item = { path: best.file.path, title: t.title, sub: t.artist, artist: t.artist ?? null, album: ctx.albumTitle ?? null, duration: best.file.duration ?? t.duration_secs ?? null, art: null, artUrl: ctx.cover, trackId: t.id, stream: { username: best.username, size: best.file.size }, quality: qualityOf(best.file), }; player.queue.push(item); // Teach the memory as the fill goes, so the album's LATER tracks // are known even if playback never reaches them this session. rememberStreamSource(item); // Both are guarded against repeat work (lastPreloadedPath, // appendedNextPath), so calling them per append costs nothing once // the immediate next track is already prepared. preloadNextStream(); tryAppendNext(); } autoAlbum = null; // every remaining track queued or skipped } catch { // transient (offline, catalog hiccup) — retried on the next track start } finally { ctx.pending = false; } } // ---- waveform ---- // 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so // by the time the lanes folded buckets into display bars every bar was ~2s // averaged twice, and the transients the lane exists to show were gone before // shaping ever ran (measured in waveshape.js's header). 2000 is ~128ms per // bucket on a typical track — finer than any lane's bar pitch — and the // command's ceiling is 4096, so this is only a constant. const WAVE_BUCKETS = 2000; let waveReqPath = null; // Throttle for progressive stream-waveform recomputes (see soulseek:stream). let waveRefreshAt = 0; async function loadWaveform(path) { player.waveform = []; player.wavePending = !!path; 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; player.waveformCoverage = 1; } } catch { if (waveReqPath === path) player.waveform = []; } finally { // Either way the request is over; a failed local decode rests as dots // rather than swelling forever at a waveform that will never come. if (waveReqPath === path) player.wavePending = false; } } /** Waveform for a streamed queue item, computed from its cache file. Empty * (plain progress bar) until the download completes; the stream-done event * calls this again to swap the full waveform in mid-track. */ let waveSeq = 0; let streamWaveInFlight = false; let queuedStreamWave = null; async function loadStreamWaveform(item, final = false) { const key = `stream:${item.stream.username}|${item.path}`; if (waveReqPath !== key) { player.waveform = []; // Pending until the FIRST partial decode lands; after that the lane // has bars and the streaming rise owns the story. A mid-stream refresh // that fails just keeps the bars it already has. player.wavePending = true; } waveReqPath = key; // A late partial FLAC decode can take close to the three-second refresh // interval. Coalesce all overlap into the newest request, retaining a // final completion request so the full-quality waveform always wins. if (streamWaveInFlight) { const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final; queuedStreamWave = { item, key, final: final || preserveFinal }; return; } streamWaveInFlight = true; let next = { item, key, final }; try { while (next) { const request = next; next = null; const seq = ++waveSeq; try { // { coverage, peaks }: the backend reports how much of the // track the peaks describe, measured from the SAME file read // that produced them — the lanes' rise frontier is anchored to // this, never to the live byte counter, so the drawn shape // cannot stretch between refreshes. const slice = await api.streamWaveform( request.item.stream.username, request.item.path, request.item.stream.size, WAVE_BUCKETS, ); if ( waveReqPath === request.key && seq === waveSeq && slice.peaks.length > 0 ) { player.waveform = slice.peaks; player.waveformCoverage = Math.max(0, Math.min(1, slice.coverage)); player.wavePending = false; } } catch { // Peaks stay as they were. A failed FINAL decode is the end of // the line though — stop the loading swell so the lane rests // as dots instead of promising a waveform that will not come. if (request.final && waveReqPath === request.key) { player.wavePending = false; } } const queued = queuedStreamWave; queuedStreamWave = null; // Only the newest still-visible track matters. A queued final call // is never dropped; the backend independently confirms completion. if (queued && waveReqPath === queued.key) next = queued; } } finally { streamWaveInFlight = false; } } // ---- queue navigation, shuffle, repeat ---- // 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.waveformCoverage = 1; player.wavePending = false; 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, catalogGo, goToView, openEntry, navBack, navForward, navToCrumb, nav, }; } 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. /* ------------------------------------------------------------------ session * Where you were, and what you were listening to, across a reload. * * The audio thread outlives the webview. A dev reload — or a crash, or a * WebView2 restart — leaves it playing while the page comes back with an empty * store, so the player bar drew a MOVING scrubber under the words "Nothing * playing" and the sidebar showed nothing playing either: position events kept * arriving, but `status`, `track` and `queue` had gone with the page. * * Two halves, and they have different owners: * * TRANSPORT — playing/paused and the position — belongs to the audio thread, * and is asked for at boot (`api.playerState`). Never guessed * from the snapshot: only the audio thread knows whether the * sound survived. * EVERYTHING ELSE — which song, the queue around it, where you had navigated * to and how far down the page — is the frontend's own and is * the part worth writing down. * * `app_status` already recovers the Soulseek connection this way at boot; this * is the same repair for playback and navigation. */ const SESSION_KEY = "soulstream.session.v1"; /** Crumbs kept. The trail only ever renders a handful; the rest is history for * the back arrow, and 60 entries of it is not worth the quota. */ const SESSION_NAV_MAX = 20; const SESSION_TICK_MS = 3000; /** Position moves constantly and is not part of the change signature, so a * long-playing track still gets its progress written down periodically. */ const SESSION_MAX_STALE_MS = 12_000; let sessionRestoring = false; let sessionSignature = ""; let sessionWrittenAt = 0; /** A crumb, minus anything that would be silly to store. `art` is frequently a * data: URL for library records — megabytes each, and they re-resolve from the * art cache on their own. */ function packCrumb(entry) { if (!entry) return null; const art = typeof entry.art === "string" && entry.art.startsWith("data:") ? null : entry.art; return { ...entry, art }; } function writeSession() { if (sessionRestoring) return; try { // Puts the reader's scroll position on the entry they are standing on, // which is the same thing the back arrow relies on. captureScroll(); const snapshot = { v: 1, view: ui.view, albumId: ui.albumId, playlistId: ui.playlistId, nav: { stack: nav.stack.slice(-SESSION_NAV_MAX).map(packCrumb), origin: packCrumb(nav.origin), }, player: { queue: player.queue, queueIndex: player.queueIndex, track: player.track, position: player.position, duration: player.duration, catalogRef: player.catalogRef, shuffle: player.shuffle, repeat: player.repeat, volume: player.volume, muted: player.muted, }, }; localStorage.setItem(SESSION_KEY, JSON.stringify(snapshot)); sessionWrittenAt = Date.now(); } catch { // Quota, or a profile with storage disabled. The session is a // convenience; nothing downstream may depend on it having been written. } } /** Cheap "has anything worth saving changed" probe, run on a timer rather than * from a reactive effect: the alternative is subscribing to a store that ticks * five times a second, and the only field that does is deliberately excluded. */ function sessionTick() { if (sessionRestoring) return; const signature = [ ui.view, ui.albumId, ui.playlistId, nav.stack.length, player.status, player.queueIndex, player.queue.length, player.shuffle, player.repeat, player.volume, player.muted, ].join("|"); const stale = player.status !== "idle" && Date.now() - sessionWrittenAt > SESSION_MAX_STALE_MS; if (signature === sessionSignature && !stale) return; sessionSignature = signature; writeSession(); } /** Re-source a track that was restored without its audio — an app restart, * where the queue survived in the snapshot but the sound did not. Resuming a * live track is the ordinary path and goes straight through. */ let resumeSeekSecs = null; export function resumePlayback() { if (player.status === "paused" && !player.needsResource) { api.playerControl("resume"); return; } const index = player.queueIndex; if (index < 0 || index >= player.queue.length) return; // Pick the track up where it was left rather than at zero; applied once the // audio thread reports it is actually playing (see the `playing` case). resumeSeekSecs = player.position > 1 ? player.position : null; player.needsResource = false; playQueueIndex(index); } /** Consumed by the player-event handler the first time a restored track * reaches `playing`. */ export function takeResumeSeek() { const secs = resumeSeekSecs; resumeSeekSecs = null; return secs; } /** * Put back what the last page left behind. Called once at boot, after the * stores exist. */ export async function restoreSession() { let saved = null; try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) ?? "null"); } catch { saved = null; } if (!saved || saved.v !== 1) { setInterval(sessionTick, SESSION_TICK_MS); return; } sessionRestoring = true; try { // THE AUDIO THREAD IS THE AUTHORITY. A reload finds it still playing; a // restart finds it idle, and the snapshot's own status would be a lie. let transport = null; try { transport = await api.playerState(); } catch { // Not running under Tauri, or IPC not up yet: treat as idle. } const saveState = saved.player ?? {}; if (typeof saveState.volume === "number") player.volume = saveState.volume; player.muted = !!saveState.muted; player.shuffle = !!saveState.shuffle; player.repeat = saveState.repeat ?? "off"; const item = saveState.queue?.[saveState.queueIndex] ?? null; if (item && saveState.track) { player.queue = saveState.queue; player.queueIndex = saveState.queueIndex; player.track = saveState.track; player.duration = saveState.duration ?? null; player.catalogRef = saveState.catalogRef ?? { albumId: null, trackId: null }; const live = transport && transport.status !== "idle"; player.status = live ? transport.status : "paused"; player.position = live ? (transport.position ?? 0) : (saveState.position ?? 0); if (live && transport.duration != null) player.duration = transport.duration; // Nothing is loaded in the audio thread, so the play button has to // re-source rather than resume — see `resumePlayback`. player.needsResource = !live; // The bar's artwork and the app accent follow the track exactly as // they would had it just been started. if (item.stream) setAccentFromArtUrl(item.artUrl ?? null); else setAccentFromArt(item.art ?? null); } // Where you were. The trail and the history arrows are restored first so // that re-opening the page finds its own crumb already on top — // `recordVisit` then updates it in place instead of pushing a duplicate. const stack = (saved.nav?.stack ?? []).filter((e) => e && e.key); if (stack.length) { nav.stack = stack; nav.future = []; nav.origin = saved.nav?.origin ?? null; ui.albumId = saved.albumId ?? null; ui.playlistId = saved.playlistId ?? null; const here = stack[stack.length - 1]; try { await openEntry(here); } catch { ui.view = saved.view ?? "home"; } // Back where the reader was on the page, once it has height to // scroll — the same staged restore the back arrow uses. const target = here.scroll ?? 0; if (target > 0) { requestAnimationFrame(() => restoreScroll(document.querySelector(".content"), target), ); } } else if (saved.view) { ui.view = saved.view; } } finally { sessionRestoring = false; sessionSignature = ""; setInterval(sessionTick, SESSION_TICK_MS); // A reload is the case this whole thing exists for, and it is the one // moment the position is exactly known. if (typeof window !== "undefined") { window.addEventListener("beforeunload", writeSession); } } } if (import.meta.env.DEV) { globalThis.__soulstream = { app, ui, player, catalog, resolver, nav, libraryStore, recentsStore, streamCatalogAlbum, streamSong, openCatalogArtist, catalogGo, refreshLibrary, refreshRecents, autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs, }; }