Files
SoulStream/src/lib/state.svelte.js
T
KeelrLeek 613ae18e87 cover logo: fix the two ways artists silently lost their wordmark
Both regressions were invisible because the pipeline's failure mode is a shrug:
no logo, no error, fall back to plain text.

1. The dominance guard was resolution-dependent. It bins the crop into a coarse
   16-cubed RGB histogram and rejects when the dominant colour holds under half
   the pixels ("text over a photo"). A BIGGER source resolves more grain and
   JPEG texture, which spreads pixels across bins and shrinks that share -- so
   the identical crop of Alvvays' "Blue Rev" measures dominance 0.746 at 1000px
   and 0.226 at 3000px. Once the upgrade path started supplying hi-res artwork,
   covers that had segmented cleanly for months began failing a guard that had
   not changed; its input grew. The histogram and guard now read a copy
   normalised to the 1000px they were calibrated at, while the alpha ramp still
   runs at full resolution -- which is the whole reason for fetching the larger
   cover.

2. Only the top 14 covers were ever offered. The rank puts albums first and
   penalises remix/mix/edit/live titles, but a prolific artist's wordmark is
   often on exactly those: Poolside has 74 releases and SEVEN covers that OCR
   reads as "POOLSIDE", every one of them outside a 14-cover window. The cap
   goes to 40 on both sides (the frontend list must not undercut the backend's,
   since whatever it trims the extractor can never see). This is free for
   artists that hit early -- the locate loop stops at COVER_LOGO_MAX_HITS -- and
   is paid once, then cached, by artists with no wordmark at all.

Recovered: Alvvays (nothing -> 871x173), Poolside (nothing -> 528x225),
Men I Trust, and Motor City Drum Ensemble now extracts too. Verified no
regression across the rest of the research roster: Camping in Alaska, Crazy P,
Folamour, Hot Toddy, Mildlife, Session Victim, Turnover, Knocked Loose,
Snail Mail, Spiritbox and Todd Terje all still extract at their previous sizes.
2026-07-25 18:51:30 -05:00

4388 lines
176 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared reactive app state (Svelte 5 runes module).
import * as api from "./api.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
import {
initPreviewBridge,
bumpMainRevision,
notePlaybackGeneration,
} from "./preview/previewSession.svelte.js";
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
export const app = $state({
connection: "offline", // offline | connecting | online
username: "",
loginError: "",
showLogin: false,
});
export const ui = $state({
view: "home",
albumId: null, // selected album in Library view
playlistId: null, // selected playlist in Playlist view
nowPlaying: true, // right-side Now Playing panel open
sidebarCollapsed: false, // left nav collapsed to an icon rail
fullscreenArt: false, // Spotify-style full-window now-playing view
// WebView2 keeps document.visibilityState="visible" while the native
// window is minimized. This is the authoritative shared activity gate for
// hidden animation, canvas, carousel, and high-frequency position work.
windowActive: true,
});
export function toggleNowPlaying() {
ui.nowPlaying = !ui.nowPlaying;
}
export function toggleSidebar() {
ui.sidebarCollapsed = !ui.sidebarCollapsed;
}
export const libraryStore = $state({
albums: [],
loaded: false,
});
export const playlistStore = $state({
playlists: [],
});
export async function refreshPlaylists() {
try {
playlistStore.playlists = await api.listPlaylists();
} catch {
// backend not available
}
}
const MIB = 1024 * 1024;
function isPending(value) {
return value && typeof value.then === "function";
}
function touchCache(cache, key) {
if (!cache.has(key)) return undefined;
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
function cacheValueChars(value) {
if (typeof value === "string") return value.length;
if (typeof value?.data_url === "string") return value.data_url.length;
if (Number.isFinite(value?.byteLength)) return value.byteLength;
return 0;
}
/** Keep resolved base64 payloads on a byte-like character budget while never
* evicting in-flight promises. Visible <img> elements retain their own src,
* so dropping a cold cache reference cannot degrade an already-painted image. */
function trimResolvedCache(cache, maxEntries, maxChars) {
let entries = 0;
let chars = 0;
for (const value of cache.values()) {
if (isPending(value)) continue;
entries += 1;
chars += cacheValueChars(value);
}
if (entries <= maxEntries && chars <= maxChars) return;
for (const [key, value] of cache) {
if (isPending(value)) continue;
cache.delete(key);
entries -= 1;
chars -= cacheValueChars(value);
if (entries <= maxEntries && chars <= maxChars) break;
}
}
function setResolvedCache(cache, key, value, maxEntries, maxChars) {
cache.delete(key);
cache.set(key, value);
trimResolvedCache(cache, maxEntries, maxChars);
}
function setBoundedMap(cache, key, value, maxEntries) {
cache.delete(key);
cache.set(key, value);
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
}
// path -> data URL, bounded so browsing a large library cannot retain every
// base64 cover for the entire WebView lifetime.
const artCache = new Map();
const ART_CACHE_MAX = 128;
const ART_CACHE_MAX_CHARS = 32 * MIB;
export function evictArt(path) {
artCache.delete(path);
}
export async function getArt(path) {
if (!path) return null;
if (artCache.has(path)) return touchCache(artCache, path);
// PlayerBar, NowPlaying, and accent extraction can all request the same
// cover during one track change. Cache the in-flight promise so one native
// read/base64 conversion satisfies every consumer.
const pending = api.albumArt(path).then(
(url) => {
if (artCache.get(path) === pending) {
setResolvedCache(
artCache,
path,
url,
ART_CACHE_MAX,
ART_CACHE_MAX_CHARS,
);
}
return url;
},
() => {
if (artCache.get(path) === pending) {
setResolvedCache(
artCache,
path,
null,
ART_CACHE_MAX,
ART_CACHE_MAX_CHARS,
);
}
return null;
},
);
artCache.set(path, pending);
return pending;
}
// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs).
const catalogArtCache = new Map();
const CATALOG_ART_CACHE_MAX = 160;
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB;
export async function getCatalogArt(url) {
if (!url) return null;
// Already-inline art (e.g. a cover-derived logo) has nothing to proxy.
if (url.startsWith("data:")) return url;
if (catalogArtCache.has(url)) return touchCache(catalogArtCache, url);
// Cache the promise immediately, not only its result. Multiple components
// often request the same art during one render; one IPC/base64 conversion
// should satisfy every waiter.
const pending = api.catalogArt(url).then(
(dataUrl) => {
if (catalogArtCache.get(url) === pending) {
setResolvedCache(
catalogArtCache,
url,
dataUrl,
CATALOG_ART_CACHE_MAX,
CATALOG_ART_CACHE_MAX_CHARS,
);
}
return dataUrl;
},
() => {
// CDN/provider failures are transient; allow the next visit to retry.
if (catalogArtCache.get(url) === pending) catalogArtCache.delete(url);
return null;
},
);
catalogArtCache.set(url, pending);
return pending;
}
// Remote catalog cover URLs that failed to load — a dead CDN link, a Cover Art
// Archive 404 (MusicBrainz-supplemented cards are given a `front-500` URL
// whether or not CAA actually holds art for that release group), or bytes that
// won't decode. Cards read `artBroken[url]` and fall back to their disc
// placeholder, so a dead provider URL renders the placeholder instead of the
// browser's broken-image glyph. Session-scoped and keyed by URL (a dead URL
// stays dead); bounded so a marathon browse can't retain unbounded strings.
export const artBroken = $state({});
export function markArtBroken(url) {
if (!url || artBroken[url]) return;
const keys = Object.keys(artBroken);
if (keys.length > 2000) for (const k of keys) delete artBroken[k];
artBroken[url] = true;
// Remote art hosts flake transiently (Cover Art Archive redirects into
// archive.org, which routinely times out under load) — one failed load
// must not pin the placeholder for the whole session. Re-probe off-DOM
// and un-mark on success; cards restore reactively.
if (/^https?:/i.test(url) && !artRetriesStarted.has(url)) {
artRetriesStarted.add(url);
retryBrokenArt(url, 0);
}
}
// Cover Art Archive redirects into archive.org, which can be down for minutes
// at a time, so the old two-shot 4s/20s ladder simply exhausted itself inside
// half a minute and pinned the placeholder for the rest of the session. Retry
// further out, and — crucially — RELEASE the url from `artRetriesStarted` when
// the ladder ends, so revisiting the artist later starts a fresh cycle instead
// of being permanently locked out.
const ART_RETRY_DELAYS = [3_000, 10_000, 30_000, 90_000];
const artRetriesStarted = new Set();
function retryBrokenArt(url, attempt) {
if (attempt >= ART_RETRY_DELAYS.length) {
artRetriesStarted.delete(url);
return;
}
setTimeout(() => {
if (!artBroken[url]) {
artRetriesStarted.delete(url);
return;
}
const probe = new Image();
probe.onload = () => {
delete artBroken[url];
artRetriesStarted.delete(url);
};
probe.onerror = () => retryBrokenArt(url, attempt + 1);
probe.src = url;
}, ART_RETRY_DELAYS[attempt]);
}
// Catalog cover upgrades: original cover URL -> a verified higher-resolution
// URL from the no-MusicBrainz backend resolver (Apple / Deezer native max /,
// for MusicBrainz-supplemented cards only, Cover Art Archive). The grid paints
// the instant Deezer cover, then swaps to the upgrade when it resolves. Keyed
// by URL so a shared cover resolves once; bounded for a long browse.
export const coverUpgrades = $state({});
const coverUpgradeInflight = new Map(); // identity + original url -> Promise
const coverUpgradeResults = new Map(); // bounded LRU: request key -> result context
const coverUpgradeContexts = new Map(); // original url -> disk-cache producer
const coverUpgradeMisses = new Map(); // original url -> attempted-at
const coverUpgradeQueue = [];
let coverUpgradeActive = 0;
let coverUpgradeScope = 0;
let coverUpgradeArtistId = null;
const COVER_UPGRADE_CONCURRENCY = 4;
const COVER_UPGRADE_MAX = 400;
const COVER_UPGRADE_MISS_TTL_MS = 2 * 60 * 1000;
function advanceCoverUpgradeScope(artistId) {
coverUpgradeArtistId = artistCacheKey(artistId) || null;
coverUpgradeScope += 1;
// Queued work has not crossed IPC yet, so it is safe to cancel when its
// artist is no longer visible. At most four already-running jobs finish;
// the new artist never sits behind an arbitrarily long stale backlog.
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
const job = coverUpgradeQueue[index];
if (job.sticky || job.scope === coverUpgradeScope) continue;
coverUpgradeQueue.splice(index, 1);
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
job.resolve(null);
}
}
function activateCoverUpgradeScope(artistId) {
if (sameArtistId(coverUpgradeArtistId, artistId)) return;
advanceCoverUpgradeScope(artistId);
}
/** Cancel artist-grid jobs that have not crossed IPC when that view leaves.
* Sticky currently-playing art and the four already-running calls are kept. */
export function leaveCoverUpgradeScope(artistId) {
if (!sameArtistId(coverUpgradeArtistId, artistId)) return;
advanceCoverUpgradeScope(null);
}
function coverUpgradeRequestKey(artistId, url) {
return `${artistCacheKey(artistId)}\u0000${url}`;
}
function rememberCoverUpgrade(key, url, best, artistId, artist, title) {
const result = { key, url, best, artistId, artist, title };
coverUpgradeResults.delete(key);
coverUpgradeResults.set(key, result);
coverUpgradeContexts.set(url, result);
coverUpgrades[url] = best;
while (coverUpgradeResults.size > COVER_UPGRADE_MAX) {
const oldest = coverUpgradeResults.keys().next().value;
const removed = coverUpgradeResults.get(oldest);
coverUpgradeResults.delete(oldest);
if (coverUpgradeContexts.get(removed?.url)?.key === oldest) {
coverUpgradeContexts.delete(removed.url);
if (coverUpgrades[removed.url] === removed.best) delete coverUpgrades[removed.url];
}
}
}
function rememberCoverUpgradeMiss(key) {
coverUpgradeMisses.delete(key);
coverUpgradeMisses.set(key, Date.now());
while (coverUpgradeMisses.size > COVER_UPGRADE_MAX) {
coverUpgradeMisses.delete(coverUpgradeMisses.keys().next().value);
}
}
function pumpCoverUpgradeQueue() {
while (coverUpgradeActive < COVER_UPGRADE_CONCURRENCY && coverUpgradeQueue.length) {
// Current-page viewport work wins over retained player/album jobs.
const currentIndex = coverUpgradeQueue.findIndex(
(candidate) => !candidate.sticky && candidate.scope === coverUpgradeScope,
);
const job = currentIndex > 0
? coverUpgradeQueue.splice(currentIndex, 1)[0]
: coverUpgradeQueue.shift();
coverUpgradeActive += 1;
api.catalogCover(job.artistId, job.artist, job.title, job.url)
.then((best) => {
if (best && best !== job.url) {
coverUpgradeMisses.delete(job.key);
rememberCoverUpgrade(
job.key,
job.url,
best,
job.artistId,
job.artist,
job.title,
);
} else {
rememberCoverUpgradeMiss(job.key);
}
job.resolve(best ?? null);
})
.catch(() => {
rememberCoverUpgradeMiss(job.key);
job.resolve(null);
})
.finally(() => {
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
coverUpgradeActive -= 1;
pumpCoverUpgradeQueue();
});
}
}
function keepOnlyLatestStickyCoverJob(url) {
for (let index = coverUpgradeQueue.length - 1; index >= 0; index -= 1) {
const job = coverUpgradeQueue[index];
if (!job.sticky || job.url === url) continue;
coverUpgradeQueue.splice(index, 1);
if (coverUpgradeInflight.get(job.key) === job.promise) {
coverUpgradeInflight.delete(job.key);
}
job.resolve(null);
}
}
function scheduleCoverUpgrade(artistId, artist, title, url, sticky = false) {
const key = coverUpgradeRequestKey(artistId, url);
// Player changes supersede older queued player-art polish. Running calls
// finish safely, but rapid track skipping cannot accumulate an immortal
// sticky backlog across every artist scope.
if (sticky) keepOnlyLatestStickyCoverJob(url);
if (coverUpgradeResults.has(key)) {
const result = touchCache(coverUpgradeResults, key);
coverUpgradeContexts.set(url, result);
if (coverUpgrades[url] !== result.best) coverUpgrades[url] = result.best;
return Promise.resolve(result.best);
}
const published = coverUpgradeContexts.get(url);
if (published?.key === key && coverUpgrades[url]) {
return Promise.resolve(coverUpgrades[url]);
}
const attemptedAt = coverUpgradeMisses.get(key);
if (attemptedAt && Date.now() - attemptedAt < COVER_UPGRADE_MISS_TTL_MS) {
return Promise.resolve(null);
}
if (coverUpgradeInflight.has(key)) {
const existing = coverUpgradeInflight.get(key);
if (sticky) {
const index = coverUpgradeQueue.findIndex((job) => job.promise === existing);
if (index >= 0) {
const [job] = coverUpgradeQueue.splice(index, 1);
job.sticky = true;
coverUpgradeQueue.unshift(job);
}
}
return existing;
}
let complete;
const promise = new Promise((resolve) => { complete = resolve; });
const job = {
key,
artistId: artistId ?? null,
artist: artist ?? "",
title: title ?? "",
url,
resolve: complete,
promise,
sticky,
scope: coverUpgradeScope,
};
// Register before enqueueing: staged overview/full-page calls can no longer
// create separate worker pools containing the same not-yet-started URL.
coverUpgradeInflight.set(key, promise);
if (sticky) coverUpgradeQueue.unshift(job);
else coverUpgradeQueue.push(job);
pumpCoverUpgradeQueue();
return promise;
}
/** Resolve higher-resolution covers for catalog albums, in parallel with a
* concurrency cap. The backend does NO MusicBrainz lookup, so this fans out
* freely. Idempotent — already-resolved or in-flight covers are skipped. */
export async function resolveCovers(
albums,
artist,
{ sticky = false, artistId = null } = {},
) {
const requests = [];
const seen = new Set();
for (const a of albums ?? []) {
const url = a?.cover;
if (!url || seen.has(url)) continue;
seen.add(url);
requests.push(scheduleCoverUpgrade(artistId, artist, a.title ?? "", url, sticky));
}
await Promise.all(requests);
}
/** A catalog cover img failed to load. If it was an upgraded URL, revert to the
* instant Deezer cover; if the original itself failed, show the placeholder. */
export function coverLoadError(
originalCover,
failedUrl = null,
artist = "",
title = "",
artistId = null,
) {
if (!originalCover) return null;
if (coverUpgrades[originalCover] && coverUpgrades[originalCover] !== originalCover) {
const producer = coverUpgradeContexts.get(originalCover);
delete coverUpgrades[originalCover];
// Do not let the bounded LRU immediately republish the URL that just
// failed in an <img>. A short miss window keeps the working original
// in place while still allowing transient CDN failures to retry later.
const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover);
coverUpgradeResults.delete(requestKey);
if (coverUpgradeContexts.get(originalCover)?.key === requestKey) {
coverUpgradeContexts.delete(originalCover);
}
rememberCoverUpgradeMiss(requestKey);
const cacheArtistId = producer?.artistId ?? artistId;
const cacheArtist = producer?.artist ?? artist;
const cacheTitle = producer?.title ?? title;
if (cacheArtist && cacheTitle) {
void api
.invalidateCatalogCover(
cacheArtistId,
cacheArtist,
cacheTitle,
originalCover,
)
.catch(() => {});
}
return originalCover;
} else {
// A card intentionally requests a 500px CDN derivative. If only that
// resized route failed, retry the known-good canonical source before
// declaring the album itself broken.
if (failedUrl) {
try {
if (new URL(failedUrl).href !== new URL(originalCover).href) {
return originalCover;
}
} catch {
if (failedUrl !== originalCover) return originalCover;
}
}
markArtBroken(originalCover);
return null;
}
}
/** Cover URL to display for a currently-playing catalog item. Kicks off an
* idempotent high-res upgrade for its album and returns the best URL to show
* now — the verified upgrade once resolved, else the instant Deezer cover. The
* player surfaces proxy the returned URL through getCatalogArt; because this
* reads the reactive coverUpgrades map, their effect re-runs and re-proxies at
* full resolution the moment the upgrade lands. */
export function playerArtSource(item) {
const url = item?.artUrl;
if (!url) return null;
if (item.album) {
resolveCovers(
[{ cover: url, title: item.album }],
item.artist ?? "",
{ sticky: true },
);
}
return coverUpgrades[url] ?? url;
}
// Artist heroes have their own proxy because provider fallbacks may be finished
// as baked 4K JPEGs. Never share this cache with covers/logos.
const catalogHeroPreviewCache = new Map();
const catalogHeroArtCache = new Map();
const catalogHeroGpuCache = new Map();
const CATALOG_HERO_PREVIEW_CACHE_MAX = 16;
const CATALOG_HERO_ART_CACHE_MAX = 10;
const CATALOG_HERO_GPU_CACHE_MAX = 2;
const CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS = 12 * MIB;
const CATALOG_HERO_ART_CACHE_MAX_CHARS = 32 * MIB;
const CATALOG_HERO_GPU_CACHE_MAX_BYTES = 32 * MIB;
const CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES = 24 * MIB;
let catalogHeroArtEpoch = 0;
let heroWarmMode = null;
let heroWarmModeRequest = null;
let heroWarmModeEpoch = 0;
function getHeroWarmMode() {
if (heroWarmMode) return Promise.resolve(heroWarmMode);
if (heroWarmModeRequest) return heroWarmModeRequest;
const epoch = heroWarmModeEpoch;
const pending = api.getSettings().then(
(settings) => settings?.artist_hero_upscaler ?? "live_gpu",
() => "live_gpu",
).then((mode) => {
if (epoch === heroWarmModeEpoch) heroWarmMode = mode;
return mode;
}).finally(() => {
if (heroWarmModeRequest === pending) heroWarmModeRequest = null;
});
heroWarmModeRequest = pending;
return pending;
}
function setBoundedHeroCache(cache, key, value, maxEntries, maxChars) {
// Refresh insertion order so frequently revisited heroes survive eviction.
cache.delete(key);
cache.set(key, value);
trimResolvedCache(cache, maxEntries, maxChars);
}
export function clearCatalogHeroArtCache() {
catalogHeroArtEpoch += 1;
// Settings calls this after changing upscalers. Refresh the warm-path hint
// so the next artist prefetch targets the newly selected pipeline.
heroWarmModeEpoch += 1;
heroWarmMode = null;
heroWarmModeRequest = null;
catalogHeroPreviewCache.clear();
catalogHeroArtCache.clear();
catalogHeroGpuCache.clear();
}
/** Return the fast hero raster together with the immutable content-aware crop
* plan that produced it. Cache the whole pair so the deferred 4K finisher can
* apply the exact same source window without running detection a second time. */
export async function getCatalogHeroPreview(url) {
if (!url) return null;
if (catalogHeroPreviewCache.has(url)) {
return touchCache(catalogHeroPreviewCache, url);
}
const epoch = catalogHeroArtEpoch;
const pending = api.catalogHeroPreview(url).then(
(result) => {
const preview = result?.data_url ? result : null;
if (
epoch === catalogHeroArtEpoch
&& catalogHeroPreviewCache.get(url) === pending
) {
if (preview) {
setBoundedHeroCache(
catalogHeroPreviewCache,
url,
preview,
CATALOG_HERO_PREVIEW_CACHE_MAX,
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
);
}
else catalogHeroPreviewCache.delete(url);
}
return epoch === catalogHeroArtEpoch ? preview : null;
},
() => {
if (catalogHeroPreviewCache.get(url) === pending) {
catalogHeroPreviewCache.delete(url);
}
return null;
},
);
setBoundedHeroCache(
catalogHeroPreviewCache,
url,
pending,
CATALOG_HERO_PREVIEW_CACHE_MAX,
CATALOG_HERO_PREVIEW_CACHE_MAX_CHARS,
);
return pending;
}
/** Share the expensive source decode/crop/raw-RGBA IPC across provisional→final
* promotion and rapid carousel/remount races. Large sources remain single-flight
* while pending but are dropped after use so two 4K payloads cannot pin RAM. */
export async function getCatalogHeroGpuSource(url, plan = null) {
if (!url) return null;
if (catalogHeroGpuCache.has(url)) return touchCache(catalogHeroGpuCache, url);
const epoch = catalogHeroArtEpoch;
const pending = api.catalogHeroGpuSource(url, plan).then(
(payload) => {
if (epoch !== catalogHeroArtEpoch) return null;
if (catalogHeroGpuCache.get(url) === pending) {
if (payload?.byteLength <= CATALOG_HERO_GPU_CACHE_MAX_ENTRY_BYTES) {
setBoundedHeroCache(
catalogHeroGpuCache,
url,
payload,
CATALOG_HERO_GPU_CACHE_MAX,
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
);
} else {
catalogHeroGpuCache.delete(url);
}
}
return payload;
},
() => {
if (catalogHeroGpuCache.get(url) === pending) catalogHeroGpuCache.delete(url);
return null;
},
);
setBoundedHeroCache(
catalogHeroGpuCache,
url,
pending,
CATALOG_HERO_GPU_CACHE_MAX,
CATALOG_HERO_GPU_CACHE_MAX_BYTES,
);
return pending;
}
/** Return only an already-finished persistent hero, without starting an
* upscale on a cache miss. This lets repeat visits race disk against the HD
* preview while keeping first-ever processing behind the first painted frame. */
export async function getCachedCatalogHeroArt(url) {
if (!url) return null;
const existing = catalogHeroArtCache.get(url);
// A full upscale already in progress is not a cache hit. Probe disk and
// continue scanning other candidates instead of waiting behind that job.
if (typeof existing === "string") {
touchCache(catalogHeroArtCache, url);
return existing;
}
const epoch = catalogHeroArtEpoch;
try {
const dataUrl = await api.catalogCachedHeroArt(url);
if (epoch !== catalogHeroArtEpoch) return null;
if (dataUrl) {
setBoundedHeroCache(
catalogHeroArtCache,
url,
dataUrl,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
}
return dataUrl;
} catch {
return null;
}
}
export async function getCatalogHeroArt(url, plan = null) {
if (!url) return null;
if (catalogHeroArtCache.has(url)) return touchCache(catalogHeroArtCache, url);
const pending = api.catalogHeroArt(url, plan).then(
(result) => {
const dataUrl = result?.data_url ?? result;
const cacheable = result?.cacheable !== false;
// A manual-art refresh may clear the map while this request is in
// flight. Do not let an obsolete completion repopulate it.
if (catalogHeroArtCache.get(url) === pending) {
if (cacheable) {
setBoundedHeroCache(
catalogHeroArtCache,
url,
dataUrl,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
}
else catalogHeroArtCache.delete(url);
}
return dataUrl;
},
() => {
if (catalogHeroArtCache.get(url) === pending) {
catalogHeroArtCache.delete(url);
}
return null;
},
);
setBoundedHeroCache(
catalogHeroArtCache,
url,
pending,
CATALOG_HERO_ART_CACHE_MAX,
CATALOG_HERO_ART_CACHE_MAX_CHARS,
);
return pending;
}
export async function refreshLibrary() {
try {
// Artist/art refreshes replace files at the same cached path. Drop
// data-URL entries so the webview fetches the newly resized image.
artCache.clear();
libraryStore.albums = await api.libraryAlbums();
libraryStore.loaded = true;
window.__libErr = null;
} catch (e) {
// not connected to backend (browser preview) — leave empty
window.__libErr = String(e);
}
}
// ---- catalog browser (Deezer-backed streaming-style search) ----
export const catalog = $state({
query: "",
loading: false,
results: null, // { artists, albums, tracks }
artistLoading: false,
artistStageError: "",
artistPage: null, // { artist, albums }
// Bumped once per openCatalogArtist call, INCLUDING a re-open of the artist
// already on screen. The view opens itself at the top off this rather than
// off the artist's id: the id cannot tell a re-open from no navigation at
// all, and it cannot tell the cold path's stage-by-stage replacement of
// artistPage (same id, new object, several times a second) from a real one
// — keying on that would drag a reader back to the top mid-read.
artistOpenSeq: 0,
albumLoading: false,
albumPage: null, // { album, tracks }
pendingTrackId: null, // a track to scroll to/flash on the album page
error: "",
});
let artistPageRequest = 0;
let artistWarmGeneration = 0;
let catalogSearchRequest = 0;
let albumPageRequest = 0;
let albumResolveRequest = 0;
const ARTIST_REQUEST_CACHE_MAX = 24;
const ARTIST_IDENTITY_CACHE_MAX = 128;
const ARTIST_LOGO_CACHE_MAX = 64;
const artistIdCache = new Map(); // normalized name → Deezer id (or null)
const artistSeedCache = new Map(); // normalized name → search-result seed
const artistSeedById = new Map(); // Deezer id → latest known artist seed
const catalogArtistOverviewCache = new Map();
const catalogArtistHeroSeedCache = new Map();
const catalogArtistArtDataCache = new Map();
const catalogArtistLogoDataCache = new Map();
const catalogArtistPageCache = new Map();
// One canonical representation for all frontend artist cache keys. Route ids
// can arrive as strings while Tauri payload ids are numbers; treating those as
// separate keys wastes work and makes otherwise-correct late-result guards fail.
const artistCacheKey = (id) => (id == null ? "" : String(id).trim());
const albumCacheKey = (id) => (id == null ? "" : String(id).trim());
const sameArtistId = (left, right) => {
const leftKey = artistCacheKey(left);
return !!leftKey && leftKey === artistCacheKey(right);
};
const sameAlbumId = (left, right) => {
const leftKey = albumCacheKey(left);
return !!leftKey && leftKey === albumCacheKey(right);
};
const artistIdentityKey = (value) => {
const text = typeof value === "string" ? value : value?.name;
return (text ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "");
};
/** Accept an exact artist credit or an explicit collaboration credit containing
* that exact artist. A mere prefix is never enough ("Crazy P" must not match
* "Crazy Penis"). Missing credit metadata is retained because there is no
* identity signal to evaluate. */
function recordBelongsToArtist(record, artistName) {
const credited = typeof record?.artist === "string"
? record.artist
: record?.artist?.name;
if (!credited) return true;
const expected = artistIdentityKey(artistName);
if (!expected) return true;
if (artistIdentityKey(credited) === expected) return true;
return credited
.split(/\s+(?:feat(?:uring)?\.?|ft\.?|with|vs\.?|x)\s+|\s*(?:&|,|×)\s*/i)
.some((part) => artistIdentityKey(part) === expected);
}
function artistScopedRecords(records, artistName) {
return Array.isArray(records)
? records.filter((record) => recordBelongsToArtist(record, artistName))
: [];
}
/** Sanitize every staged/full artist payload before it enters a shared cache.
* Backend endpoints already anchor on id; this cheap name-credit boundary is a
* second line of defense for additive provider records such as Apple/MB data. */
function artistScopedPayload(payload) {
const artistName = payload?.artist?.name ?? "";
return {
...payload,
...(Object.hasOwn(payload ?? {}, "albums")
? { albums: artistScopedRecords(payload.albums, artistName) }
: {}),
...(Object.hasOwn(payload ?? {}, "popular_tracks")
? { popular_tracks: artistScopedRecords(payload.popular_tracks, artistName) }
: {}),
};
}
function checkedArtistPayload(artistId, payload, stage) {
if (!sameArtistId(payload?.artist?.id, artistId)) {
throw new Error(`${stage} returned a mismatched artist identity`);
}
return artistScopedPayload(payload);
}
function rememberArtistSeed(seed) {
if (!seed?.id) return null;
const idKey = artistCacheKey(seed.id);
const prior = artistSeedById.get(idKey);
const remembered = {
id: seed.id,
name: seed.name ?? prior?.name ?? "",
picture: seed.picture ?? prior?.picture ?? null,
fans: seed.fans ?? prior?.fans ?? null,
album_count:
seed.album_count ?? seed.albumCount ?? prior?.album_count ?? null,
};
setBoundedMap(
artistSeedById,
idKey,
remembered,
ARTIST_IDENTITY_CACHE_MAX,
);
const norm = remembered.name.trim().toLowerCase();
if (norm) {
const existing = artistSeedCache.get(norm);
const sameIdentity = sameArtistId(existing?.id, remembered.id);
const moreCanonical = (remembered.fans ?? -1) > (existing?.fans ?? -1);
// Deezer can return several exact-name matches. Preserve the most-
// followed canonical seed instead of letting the last obscure namesake
// in a search response poison all future name-only navigation.
if (!existing || sameIdentity || moreCanonical) {
setBoundedMap(
artistSeedCache,
norm,
remembered,
ARTIST_IDENTITY_CACHE_MAX,
);
setBoundedMap(
artistIdCache,
norm,
remembered.id,
ARTIST_IDENTITY_CACHE_MAX,
);
}
}
return remembered;
}
function trimArtistRequestCache(cache) {
let resolved = 0;
for (const value of cache.values()) {
if (!isPending(value)) resolved += 1;
}
for (const [key, value] of cache) {
if (resolved <= ARTIST_REQUEST_CACHE_MAX) break;
if (isPending(value)) continue;
cache.delete(key);
resolved -= 1;
}
}
function cachedArtistRequest(cache, artistId, load) {
const key = artistCacheKey(artistId);
if (!key) return Promise.reject(new Error("artist id is required"));
if (cache.has(key)) return Promise.resolve(touchCache(cache, key));
const pending = load().then(
(result) => {
if (cache.get(key) === pending) {
cache.delete(key);
cache.set(key, result);
trimArtistRequestCache(cache);
}
return result;
},
(error) => {
if (cache.get(key) === pending) cache.delete(key);
throw error;
},
);
cache.set(key, pending);
trimArtistRequestCache(cache);
return pending;
}
function resolvedArtistCacheValue(cache, artistId) {
const value = cache.get(artistCacheKey(artistId));
return value && typeof value.then !== "function" ? value : null;
}
export const getCatalogArtistOverview = (artistId) =>
cachedArtistRequest(catalogArtistOverviewCache, artistId, () =>
api.catalogArtistOverview(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist overview")),
);
export const getCatalogArtistHeroSeed = (artistId) =>
cachedArtistRequest(catalogArtistHeroSeedCache, artistId, () =>
api.catalogArtistHeroSeed(artistId),
).then((seed) => {
// The backend applies a short negative TTL. Do not turn a transient
// no-seed result into a frontend-session-long miss.
if (!seed) catalogArtistHeroSeedCache.delete(artistCacheKey(artistId));
return seed;
});
export const getCatalogArtistIdentityArt = (artistId) =>
cachedArtistRequest(catalogArtistArtDataCache, artistId, () =>
api.catalogArtistArt(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist art")),
).then((art) => {
// Empty provider/Bing results have a short backend TTL and may recover
// on a later visit. Keep the shared promise for the whole in-flight
// request, but do not turn a valid no-art payload into a session-long
// frontend miss. Positive banners, custom art and logos stay cached.
const hasArt = Boolean(
art?.banner_custom
|| art?.banner
|| art?.banners?.length
|| art?.logos?.length
);
if (!hasArt) catalogArtistArtDataCache.delete(artistCacheKey(artistId));
return art;
});
export const getCatalogArtistProviderLogos = (artistId) =>
cachedArtistRequest(catalogArtistLogoDataCache, artistId, () =>
api.catalogArtistLogos(artistId).then((logos) => {
if (!Array.isArray(logos) || logos.some((logo) => typeof logo !== "string")) {
throw new Error("artist logos returned an invalid response");
}
rememberProviderLogoStatus(artistId, logos.length > 0);
if (!logos.length) {
// Backend provider-art misses intentionally have a short TTL.
// Keep this page's settled negative decision, but do not pin it
// in the frontend request cache for the rest of the session.
catalogArtistLogoDataCache.delete(artistCacheKey(artistId));
}
return logos;
}),
);
export const getCatalogArtistPage = (artistId) =>
cachedArtistRequest(catalogArtistPageCache, artistId, () =>
api.catalogArtist(artistId).then((payload) =>
checkedArtistPayload(artistId, payload, "artist page")),
);
export function invalidateCatalogArtist(artistId) {
const key = artistCacheKey(artistId);
catalogArtistOverviewCache.delete(key);
catalogArtistHeroSeedCache.delete(key);
catalogArtistArtDataCache.delete(key);
catalogArtistLogoDataCache.delete(key);
catalogArtistPageCache.delete(key);
providerLogoStatusById.delete(key);
for (const logoKey of coverLogoByInput.keys()) {
if (logoKey.startsWith(`${key}\u0000`)) coverLogoByInput.delete(logoKey);
}
}
// ---- Stale-while-revalidate page replay -----------------------------------
// A revisit after an app restart used to start from the loading shell even
// though the previous session had the artist's FINAL merged page. The last
// merged page per artist is persisted (localStorage, MRU-capped) and painted
// immediately as a stale stand-in — paint-only: every stage of the normal
// cold flow still runs and replaces it wholesale, so this can never repeat
// the old "cached page skipped the MB pass" bug. Replayed pages keep
// `art_preview: true` (the full page still lands) and set `replayed: true`
// (the overview stage must not overwrite final classified albums with its
// raw first-50).
// v2: pages saved before guest appearances were filtered are stamped
// `identity_merged`, so they would skip the pass that drops them and keep
// rendering another artist's records. Cheap to discard — the replay only buys
// an instant first paint, and a miss is one ordinary load.
const PAGE_REPLAY_RECIPE = "replay-v2";
const PAGE_REPLAY_LIMIT = 16;
// Matches the backend's provider disk-cache TTL on purpose: a replay must
// never outlive the MusicBrainz warmth it was saved with, or the identity
// re-merge that replaces it stops being instant.
const PAGE_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
const PAGE_REPLAY_INDEX = "ss-page-replay-index";
const pageReplayKey = (key) => `ss-page-replay:${key}`;
function loadPageReplay(artistId) {
try {
const key = artistCacheKey(artistId);
if (!key) return null;
const raw = localStorage.getItem(pageReplayKey(key));
if (!raw) return null;
const entry = JSON.parse(raw);
if (entry?.recipe !== PAGE_REPLAY_RECIPE) return null;
if (!entry.at || Date.now() - entry.at > PAGE_REPLAY_TTL_MS) return null;
const page = entry.page;
if (!sameArtistId(page?.artist?.id, artistId)) return null;
if (!Array.isArray(page?.albums) || !page.albums.length) return null;
return page;
} catch {
return null;
}
}
function savePageReplay(artistId, page) {
try {
const key = artistCacheKey(artistId);
if (!key || !page?.albums?.length) return;
// Only durable content fields. Art (banners/logos/cover_logo) is
// deliberately excluded: those pipelines have their own caches and
// settled/override semantics that a replay must not preempt.
const slim = {
artist: page.artist,
popular_tracks: page.popular_tracks ?? [],
albums: page.albums,
top_genre: page.top_genre ?? null,
socials: page.socials ?? [],
typeHints: page.typeHints ?? [],
identity_merged: true,
};
localStorage.setItem(
pageReplayKey(key),
JSON.stringify({ recipe: PAGE_REPLAY_RECIPE, at: Date.now(), page: slim }),
);
const index = JSON.parse(localStorage.getItem(PAGE_REPLAY_INDEX) ?? "[]");
const next = [key, ...index.filter((k) => k !== key)];
for (const evicted of next.slice(PAGE_REPLAY_LIMIT)) {
localStorage.removeItem(pageReplayKey(evicted));
}
localStorage.setItem(PAGE_REPLAY_INDEX, JSON.stringify(next.slice(0, PAGE_REPLAY_LIMIT)));
} catch {
// Storage full or unavailable — the replay is purely optional.
}
}
/** Warm only the two visible stages while a user reads search results. The
* expensive exhaustive page remains click-driven. */
export function cancelCatalogArtistWarmup() {
artistWarmGeneration += 1;
}
export function warmCatalogArtist(seed) {
const remembered = rememberArtistSeed(seed);
if (!remembered) return;
const warmGeneration = ++artistWarmGeneration;
const heroModeReady = getHeroWarmMode();
void getCatalogArtistOverview(remembered.id).catch(() => {});
void getCatalogArtistHeroSeed(remembered.id)
.then(async (seed) => {
if (warmGeneration !== artistWarmGeneration) return;
if (seed?.banner && !seed.banner.startsWith("data:")) {
// Warm exactly the asset the selected renderer consumes. Live
// GPU never paints the legacy HD data URL, so preparing it would
// waste an extra decode and up to 12 MiB of WebView cache.
const mode = await heroModeReady;
if (warmGeneration !== artistWarmGeneration) return;
if (mode === "live_gpu") {
void getCatalogHeroGpuSource(seed.banner, null).catch(() => {});
} else {
void getCatalogHeroPreview(seed.banner).catch(() => {});
}
}
})
.catch(() => {});
void getCatalogArtistProviderLogos(remembered.id)
.then((logos) => {
const primaryLogo = logos?.[0];
if (primaryLogo) void getCatalogArt(primaryLogo);
})
.catch(() => {});
// Also warm the MusicBrainz identity chain — the slowest piece of a cold
// artist page (3-5 requests behind MB's mandatory 1 req/s gate). Gated
// three ways so live-search churn can never flood MusicBrainz: only after
// 600ms of the artist staying the top hit (a newer warm generation
// supersedes the timer), only once per artist per session, and only for
// this single seed — never a result list.
const identityKey = artistCacheKey(remembered.id);
if (identityKey && remembered.name && !identityWarmupDone.has(identityKey)) {
setTimeout(() => {
if (warmGeneration !== artistWarmGeneration) return;
if (identityWarmupDone.has(identityKey)) return;
identityWarmupDone.add(identityKey);
void api.catalogArtistIdentity(remembered.id, remembered.name).catch(() => {});
}, 600);
}
}
const identityWarmupDone = new Set();
/** Run a catalog search and show the results view. */
export async function catalogGo(query) {
cancelCatalogArtistWarmup();
const q = query.trim();
if (!q) return;
const request = ++catalogSearchRequest;
ui.view = "catalog";
ui.albumId = null;
catalog.query = q;
catalog.results = null;
catalog.loading = true;
catalog.error = "";
try {
const results = await api.catalogSearch(q);
if (request !== catalogSearchRequest) return;
catalog.results = results;
for (const artist of results?.artists ?? []) rememberArtistSeed(artist);
// Search results are normally read for a moment before the first artist
// is opened. Spend that otherwise-idle time warming its identity/art
// lookup; the keyed backend single-flight shares this exact request if
// the user clicks immediately.
const likelyArtistId = results?.artists?.[0]?.id;
if (likelyArtistId != null) {
warmCatalogArtist(results.artists[0]);
}
} catch (e) {
if (request === catalogSearchRequest) catalog.error = String(e);
} finally {
if (request === catalogSearchRequest) catalog.loading = false;
}
}
/** Open an artist as a four-stage page: synchronous identity shell, fast
* Deezer overview, independent provider artwork, then exhaustive refinement.
* Every ID gets a shell; known search/discovery seeds add the name + blurred
* portrait without waiting for IPC or the network. */
// Provider presence is an identity-scoped, monotonic decision. Once any exact
// provider pass finds a curated logo, a later empty/error result may not expose
// a cover-derived fallback in front of it.
const providerLogoStatusById = new Map();
// Cover-derived results are keyed by the exact identity-filtered cover input,
// not only by artist id. This prevents a negative or wordmark generated from an
// older/polluted discography from being reused after the candidate set changes.
// Values are either an in-flight Promise, a finalized data URL, or null.
const coverLogoByInput = new Map();
const ARTIST_LOGO_CACHE_MAX_CHARS = 12 * MIB;
function rememberProviderLogoStatus(artistId, hasProviderLogo) {
const key = artistCacheKey(artistId);
if (!key) return;
const prior = providerLogoStatusById.get(key);
setBoundedMap(
providerLogoStatusById,
key,
prior === true || hasProviderLogo === true,
ARTIST_LOGO_CACHE_MAX,
);
}
/** Apply a fast, definitive provider-logo decision. With no provider logo, an
* exact-input cached result or generation starts once the hardened final cover
* list is supplied. */
async function resolveCoverLogoAvailability(artistId, page, providerLogosReady, active) {
const logos = await providerLogosReady;
const statusKey = artistCacheKey(artistId);
if (!Array.isArray(logos)) {
if (
providerLogoStatusById.get(statusKey) === false
&& page
&& active()
) {
await fetchCoverLogo(artistId, page, active);
return { provider: false };
}
return { provider: null };
}
rememberProviderLogoStatus(artistId, logos.length > 0);
if (!active()) return { provider: logos.length > 0 };
const current = catalog.artistPage;
if (logos.length) {
if (sameArtistId(current?.artist?.id, artistId)) {
catalog.artistPage = {
...current,
logos: current.logos?.length ? current.logos : logos,
provider_logos_settled: true,
cover_logo: null,
cover_logo_pending: false,
};
}
return { provider: true };
}
if (sameArtistId(current?.artist?.id, artistId) && !current.provider_logos_settled) {
catalog.artistPage = { ...current, provider_logos_settled: true };
}
// Do not use the old artist-id-only persisted restore here. Its candidate
// fingerprint is unavailable, so it could have been generated from the
// polluted discography we are hardening against. The exact-input request
// below still hits the backend's versioned memory/disk cache on revisits.
if (page) await fetchCoverLogo(artistId, { ...page, logos: [] }, active);
return { provider: false };
}
function mergeCatalogArtistArt(artistId, art, active) {
if (!sameArtistId(art?.artist?.id, artistId) || !active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
const finalBanners = art.banners ?? [];
const artLogos = Array.isArray(art.logos) ? art.logos : [];
const finalLogos = current.logos?.length ? current.logos : artLogos;
const mergedArtist = {
...current.artist,
...art.artist,
// The completed identity-filtered page owns this count. A late art
// profile may still carry Deezer's broader/fused total; use it only
// while the shell has no count at all.
album_count: current.artist?.album_count ?? art.artist?.album_count ?? null,
};
rememberArtistSeed(mergedArtist);
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
catalog.artistPage = {
...current,
artist: mergedArtist,
banner: art.banner ?? null,
banners: finalBanners,
banner_custom: art.banner_custom ?? false,
logos: finalLogos,
provider_logos_settled: true,
cover_logo: finalLogos.length ? null : current.cover_logo ?? null,
cover_logo_pending: finalLogos.length ? false : current.cover_logo_pending ?? false,
hero_seed_banner: finalBanners.length ? null : current.hero_seed_banner,
loading_shell: false,
placeholder_picture:
current.placeholder_picture ?? art.artist?.picture ?? null,
};
}
/** Fire the deferred cover-logo extraction for an artist with no provider logo,
* merging the result into its own page field. Backend-cached, so ~instant on a
* revisit. Called from BOTH the cold-load and warm-cache paths of
* openCatalogArtist — the warm path early-returns and would otherwise skip it. */
/** Lower ranks first. Studio-album covers print the bare band name; singles,
* EPs and anything with a remix/live/mix in the title print tracklists and
* credits — poison for a wordmark. So OCR the clean covers first. */
function albumLogoRank(a) {
const type = a.record_type ?? "";
const typeRank = type === "album" ? 0 : type === "ep" ? 2 : 3;
const noisy = /\b(remix|remixes|mix|live|edit|dub|version|bonus|instrumental)\b/i.test(
a.title ?? "",
)
? 1
: 0;
return typeRank + noisy;
}
// Must not undercut the backend's own cap (COVER_LOGO_MAX_COVERS): whatever is
// trimmed here the extractor can never see. A prolific artist's wordmark is
// frequently on a low-ranked remix single — all seven of Poolside's readable
// covers sat past position 14 — and the backend stops early once it has enough
// hits, so a longer list costs nothing for artists whose top covers work.
function coverLogoCandidates(page) {
return [...(page.albums ?? [])]
.sort((a, b) => albumLogoRank(a) - albumLogoRank(b))
.map((a) => ({ title: a.title ?? "", url: a.cover }))
.filter((c) => c.url)
.slice(0, 40);
}
function coverLogoInputKey(artistId, page, covers) {
const idKey = artistCacheKey(artistId);
const artistKey = artistIdentityKey(page.artist?.name);
const candidates = covers
.map(({ title, url }) => `${title}\u0001${url}`)
.join("\u0002");
return `${idKey}\u0000${artistKey}\u0000${candidates}`;
}
function readCoverLogoForPage(artistId, page) {
if (providerLogoStatusById.get(artistCacheKey(artistId)) !== false) return null;
const covers = coverLogoCandidates(page);
if (!covers.length) return null;
const value = touchCache(coverLogoByInput, coverLogoInputKey(artistId, page, covers));
return typeof value === "string" ? value : null;
}
function coverLogoRequest(artistId, page, covers) {
const inputKey = coverLogoInputKey(artistId, page, covers);
if (coverLogoByInput.has(inputKey)) {
return Promise.resolve(touchCache(coverLogoByInput, inputKey));
}
const pending = api
.catalogCoverLogo(artistId, page.artist?.name ?? "", covers)
.then(
(dataUrl) => {
if (dataUrl != null && typeof dataUrl !== "string") {
throw new Error("cover logo returned an invalid response");
}
const result = dataUrl ?? null;
if (coverLogoByInput.get(inputKey) === pending) {
setResolvedCache(
coverLogoByInput,
inputKey,
result,
ARTIST_LOGO_CACHE_MAX,
ARTIST_LOGO_CACHE_MAX_CHARS,
);
}
return result;
},
(error) => {
if (coverLogoByInput.get(inputKey) === pending) {
coverLogoByInput.delete(inputKey);
}
throw error;
},
);
coverLogoByInput.set(inputKey, pending);
trimResolvedCache(
coverLogoByInput,
ARTIST_LOGO_CACHE_MAX,
ARTIST_LOGO_CACHE_MAX_CHARS,
);
return pending;
}
async function fetchCoverLogo(artistId, page, active) {
const statusKey = artistCacheKey(artistId);
if (
!sameArtistId(page?.artist?.id, artistId)
|| page.logos?.length
|| providerLogoStatusById.get(statusKey) !== false
|| !active()
) {
return null;
}
const covers = coverLogoCandidates(page);
if (!covers.length) return null;
const current = catalog.artistPage;
if (
sameArtistId(current?.artist?.id, artistId)
&& !current.cover_logo_pending
&& !readCoverLogoForPage(artistId, page)
) {
catalog.artistPage = { ...current, cover_logo_pending: true };
}
try {
const dataUrl = await coverLogoRequest(artistId, page, covers);
if (!dataUrl || !active()) return dataUrl;
const latest = catalog.artistPage;
if (
!sameArtistId(latest?.artist?.id, artistId)
|| latest.logos?.length
|| providerLogoStatusById.get(statusKey) !== false
) {
return dataUrl;
}
// Its OWN field, never `logos`: the provider-art pass sets `logos`
// (often to []) and would otherwise clobber it. The view prefers
// provider `logos` and falls back to this.
catalog.artistPage = { ...latest, cover_logo: dataUrl };
return dataUrl;
} catch {
return null;
} finally {
const latest = catalog.artistPage;
if (
active()
&& sameArtistId(latest?.artist?.id, artistId)
&& latest.cover_logo_pending
) {
catalog.artistPage = { ...latest, cover_logo_pending: false };
}
}
}
/** MusicBrainz "last pass" for an open artist page. MB is rate-limited to
* 1 req/s by policy, which used to cost the release grid ~2-3s; Apple's
* unthrottled artist lookup now supplies the release supplement, so MB runs
* AFTER the page is on screen and merges in whenever it lands. Deliberately
* additive-only: it contributes the socials and the few deep cuts (practice
* tapes, BBC sessions) neither Deezer nor Apple carries, and never edits a
* record those two already own.
*
* Two failure modes this function owns:
* - MB can 503 under per-IP contention. A fully empty result gets one
* delayed retry — failures are not cached backend-side, so it is real.
* - The session page cache used to hold the RAW backend page forever, and
* the cached fast path skipped this pass entirely — so a warm revisit
* silently LOST the supplement it had on the cold visit ("A ? of WHEN"
* vanishing from Panda Bear). The merged page is now written back into
* the cache and stamped `identity_merged`, and the fast path re-runs this
* pass for cached pages that lack the stamp. */
async function applyArtistIdentity(artistId, active) {
try {
const name = catalog.artistPage?.artist?.name ?? "";
let identity = await api.catalogArtistIdentity(artistId, name);
// Socials are a separate deferred command now, so the identity
// payload's socials are ALWAYS empty — they can no longer prove "MB
// knows this artist" here, and waiting for the retry below would
// delay the footer links 8 s for artists MB carries without release
// groups. Fire them independently before the retry decision.
void applyArtistSocials(artistId, active);
const empty = !(identity?.albums?.length || identity?.type_hints?.length);
if (empty) {
// Transient-failure retry. For a genuinely album-less MB artist
// both calls answer from the URL caches, so the second pass
// costs no MusicBrainz traffic — only this deliberate delay.
await new Promise((resolve) => setTimeout(resolve, 8000));
if (!active()) return;
identity = await api.catalogArtistIdentity(artistId, name);
}
if (!active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
// MB identity extras are always albums, so only rows that could BE
// that album may block one: a same-title SINGLE is a different
// release (the "Phantom Island" shape — a 2024 single and a 2025
// album), and letting it occupy the title made the real album vanish
// from the page.
const seen = new Set(
(current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => normText(a.title)),
);
const identityAlbums = artistScopedRecords(identity?.albums, current.artist?.name);
// An MB row whose title is a PREFIX of an existing row's full title
// is the same work Deezer carries as a qualified variant —
// "Wonderful Crazy Night" beside the page's "Wonderful Crazy Night
// (Deluxe)". Injecting it adds a second card with no track count.
// Sequel tails ("Greatest Hits" vs "Greatest Hits II") stay
// distinct: a bare numeral/volume marker never counts as a variant
// qualifier. normText already strips brackets, so compare against
// the raw title normalized without stripping.
const rawNorm = (s) => (s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
// Same single-typed exemption as `seen`: only album-like rows may
// claim to be a variant of an MB album.
const existingRaw = (current.albums ?? [])
.filter((a) => a.record_type !== "single")
.map((a) => rawNorm(a.title));
const sequelTail = (tail) =>
/^(?:i{1,3}|iv|v|vi{0,3}|ix|x|\d{1,2}|vol(?:ume)?\s*\d*|pt\s*\d*|part\s*\d*)$/.test(
tail.trim(),
);
const coveredByVariant = (title) => {
const base = rawNorm(title);
if (base.length < 8) return false;
return existingRaw.some(
(t) =>
t.length > base.length
&& t.startsWith(base)
&& !sequelTail(t.slice(base.length)),
);
};
const extra = identityAlbums.filter(
(a) => a?.title && !seen.has(normText(a.title)) && !coveredByVariant(a.title),
);
const socials = identity?.socials ?? [];
// MB's authoritative primary/secondary types. The view matches these
// to displayed releases by title and re-files any the provider
// mislabeled "album" (compilations, soundtracks…).
const typeHints = identity?.type_hints ?? [];
// Guest appearances, dropped only where MusicBrainz can arbitrate.
//
// Deezer's /artist/{id}/albums returns records the artist merely
// contributed to — four Rooby Jeantal lo-fi releases sat in Mid-Air
// Thief's discography that way. The backend flags them (`appears_on`:
// the release's primary artist is someone else), but that flag ALONE
// must never delete a row: Deezer marks every contributor "Main", so a
// guest spot looks exactly like a genuine joint album — Kanye West's
// "Watch the Throne" carries primary artist JAY-Z and would be wrongly
// erased from his page.
//
// MusicBrainz separates them, because it credits a real collaboration
// under BOTH artists: querying Kanye's own MBID returns "Watch the
// Throne", while Mid-Air Thief's returns only his two real albums. So a
// flagged row survives if MB credits it and is dropped if MB does not
// — and ONLY when MB actually answered for this artist. No MB albums
// means no arbiter, and every row is kept rather than guessed at, which
// keeps thin-coverage artists (the obscure end, where this matters
// most) from losing real releases.
const mbTitles = new Set(identityAlbums.map((a) => normText(a.title)));
const ownAlbums = identityAlbums.length
? (current.albums ?? []).filter(
(a) => !a?.appears_on || mbTitles.has(normText(a.title)),
)
: (current.albums ?? []);
const merged = {
...current,
identity_merged: true,
albums: extra.length
? [...ownAlbums, ...extra].sort((a, b) =>
(b.release_date ?? "").localeCompare(a.release_date ?? ""))
: ownAlbums,
socials: socials.length ? socials : current.socials,
typeHints: typeHints.length ? typeHints : current.typeHints,
};
catalog.artistPage = merged;
// Write the supplemented page back into the session cache: a warm
// revisit must not render less than the cold visit did. Overwriting
// a pending entry is safe — cachedArtistRequest only installs its
// own result while its promise is still the stored value.
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Best-effort supplement: the page is complete without it.
}
}
/** Deferred footer links: merged whenever they arrive, never blocking the
* identity pass. Cached back into the session page cache like the identity
* merge, so a warm revisit keeps its links. */
async function applyArtistSocials(artistId, active) {
try {
const socials = await api.catalogArtistSocials(artistId);
if (!socials?.length || !active()) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
if (current.socials?.length) return;
const merged = { ...current, socials };
catalog.artistPage = merged;
const key = artistCacheKey(artistId);
if (key) {
catalogArtistPageCache.delete(key);
catalogArtistPageCache.set(key, merged);
trimArtistRequestCache(catalogArtistPageCache);
}
savePageReplay(artistId, merged);
} catch {
// Footer links are decorative; missing them is fine.
}
}
export async function openCatalogArtist(artistId, seedArtist = null) {
cancelCatalogArtistWarmup();
catalogSearchRequest += 1;
const request = ++artistPageRequest;
const artistKey = artistCacheKey(artistId);
if (!artistKey) return;
// After the bail-out above, so a request we never act on cannot scroll the
// page the reader is still on.
catalog.artistOpenSeq += 1;
activateCoverUpgradeScope(artistId);
ui.view = "catalogArtist";
const active = () =>
request === artistPageRequest
&& ui.view === "catalogArtist"
&& sameArtistId(catalog.artistPage?.artist?.id, artistId);
const providedSeed = sameArtistId(seedArtist?.id, artistId)
? rememberArtistSeed(seedArtist)
: null;
const remembered =
providedSeed
?? touchCache(artistSeedById, artistKey)
?? null;
const providerLogosReady = getCatalogArtistProviderLogos(artistId).catch(() => null);
const loadFullArtistArt = () =>
getCatalogArtistIdentityArt(artistId)
.then((art) => mergeCatalogArtistArt(artistId, art, active))
.catch(() => {});
let completed = resolvedArtistCacheValue(catalogArtistPageCache, artistId);
if (completed && !sameArtistId(completed.artist?.id, artistId)) {
catalogArtistPageCache.delete(artistKey);
completed = null;
}
if (completed) {
rememberArtistSeed(completed.artist);
if (completed.logos?.length) rememberProviderLogoStatus(artistId, true);
catalog.artistPage = {
...completed,
art_preview: false,
loading_shell: false,
partial_error: null,
hero_seed_banner: null,
// A positive curated set is identity-safe to reuse. An earlier
// negative is not: wait for this generation's short-TTL provider
// request before authorizing any cover-derived fallback.
provider_logos_settled: Boolean(completed.logos?.length),
cover_logo: null,
placeholder_picture: remembered?.picture ?? completed.artist?.picture ?? null,
};
catalog.artistLoading = false;
catalog.artistStageError = "";
catalog.error = "";
void resolveCoverLogoAvailability(artistId, completed, providerLogosReady, active);
void loadFullArtistArt();
// A cached page from before the MB pass landed (or from a visit
// where it failed) is missing its supplement — the fast path must
// not render less than the cold path did.
if (!completed.identity_merged) void applyArtistIdentity(artistId, active);
else if (!completed.socials?.length) void applyArtistSocials(artistId, active);
return;
}
// The search portrait is a small square image — it must never enter the
// real hero-source list (banner/banners feed the carousel and 4K finisher).
// It stays a presentation-only placeholder threaded through every state.
const seedPicture = remembered?.picture ?? null;
const replay = loadPageReplay(artistId);
catalog.artistPage = replay
? {
// Stale stand-in from the last visit: the final classified album
// list paints immediately while every cold stage below re-runs
// and replaces it. Art fields start empty on purpose — the art
// pipeline owns them.
...replay,
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_credit: null,
banner_custom: false,
art_preview: true,
loading_shell: false,
replayed: true,
partial_error: null,
hero_seed_banner: null,
cover_logo: null,
placeholder_picture: seedPicture ?? replay.artist?.picture ?? null,
}
: {
artist: {
id: remembered?.id ?? artistId,
name: remembered?.name ?? "",
picture: seedPicture,
fans: remembered?.fans ?? null,
album_count: remembered?.album_count ?? null,
},
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_credit: null,
banner_custom: false,
popular_tracks: [],
albums: [],
top_genre: null,
socials: [],
art_preview: true,
loading_shell: true,
partial_error: null,
hero_seed_banner: null,
placeholder_picture: seedPicture,
};
catalog.artistLoading = true;
catalog.artistStageError = "";
catalog.error = "";
// A persisted positive mark can paint over the identity shell as soon as
// the fast provider-logo check confirms this artist has no provider logo.
// Provider artists start their complete artwork stage immediately; a cold
// no-provider artist reserves the network for cover generation first.
void resolveCoverLogoAvailability(artistId, null, providerLogosReady, active);
// Bing discovery is independent of cover-logo OCR. Run both under their own
// bounded backend limits so neither user-visible asset sits behind the
// other's multi-second pipeline.
void loadFullArtistArt();
// A single identity-safe source can arrive before the complete provider
// tier/list. It feeds only the first preview; the final artwork response
// still owns carousel selection and 4K finishing.
void getCatalogArtistHeroSeed(artistId)
.then((seed) => {
if (!active() || !seed?.banner) return;
const current = catalog.artistPage;
if (
!current
|| !sameArtistId(current.artist?.id, artistId)
|| current.banners?.length
|| current.banner_custom
) {
return;
}
catalog.artistPage = {
...current,
hero_seed_banner: seed.banner,
banner_custom: seed.banner_custom ?? false,
loading_shell: false,
};
})
.catch(() => {});
// Warm the MusicBrainz identity pass NOW instead of after the full page
// lands. Its 4-6 requests serialize behind MusicBrainz's mandatory
// 1 req/s gate (measured 3.7-8.5 s — most of a cold page's
// time-to-fully-sorted), so starting it beside the page load hides
// almost all of that behind work that was happening anyway. Fire and
// forget: applyArtistIdentity later re-asks with the authoritative name
// and rides the warmed URL caches; results here are deliberately unused.
let identityWarmed = false;
const warmArtistIdentity = (name) => {
if (identityWarmed || !name) return;
identityWarmed = true;
void api.catalogArtistIdentity(artistId, name).catch(() => {});
};
warmArtistIdentity(remembered?.name);
// One-round-trip content stage: popular tracks and the first curated
// releases become usable while exhaustive classification continues.
void getCatalogArtistOverview(artistId)
.then((overview) => {
warmArtistIdentity(overview?.artist?.name);
if (!active()) return;
const current = catalog.artistPage;
if (
!current
|| !sameArtistId(current.artist?.id, artistId)
|| current.art_preview === false
|| !sameArtistId(overview.artist?.id, artistId)
) return;
rememberArtistSeed(overview.artist);
catalog.artistPage = {
...current,
artist: { ...current.artist, ...overview.artist },
// A replayed page already shows the last visit's FINAL
// classified albums; the overview's raw first-50 would be a
// strict downgrade. Fresh albums arrive with the full page.
...(current.replayed
? {}
: {
popular_tracks: overview.popular_tracks ?? [],
albums: overview.albums ?? [],
}),
loading_shell: false,
placeholder_picture:
current.placeholder_picture ?? overview.artist?.picture ?? null,
};
})
.catch(() => {});
try {
// Overview, identity art, and the first hero all remain immediate.
// Give rapid back-to-back navigation a brief chance to settle before
// starting the exhaustive release-classification request; stale pages
// should not consume provider/backend work merely to fill a cache.
await new Promise((resolve) => setTimeout(resolve, 180));
if (!active()) return;
const page = await getCatalogArtistPage(artistId);
if (active() && sameArtistId(page?.artist?.id, artistId)) {
rememberArtistSeed(page.artist);
const staged = catalog.artistPage;
const stagedHasArt = sameArtistId(staged?.artist?.id, artistId);
const finalBanners = page.banner_custom
? page.banners ?? []
: stagedHasArt && staged?.banners?.length
? staged.banners
: page.banners ?? [];
const finalLogos = stagedHasArt && staged?.logos?.length
? staged.logos
: page.logos ?? [];
rememberProviderLogoStatus(artistId, finalLogos.length > 0);
catalog.artistPage = {
...page,
// A replayed page carries the last visit's MB type hints AND
// socials; the fresh page always arrives with both empty
// (socials are a deferred command now), so dropping them here
// would let filing/footer visibly regress for the moment
// before the identity/socials passes re-merge them.
...(stagedHasArt && staged?.typeHints?.length
? { typeHints: staged.typeHints }
: {}),
...(stagedHasArt && staged?.socials?.length
? { socials: staged.socials }
: {}),
banner: page.banner_custom
? page.banner
: stagedHasArt
? staged?.banner ?? page.banner ?? null
: page.banner ?? null,
banners: finalBanners,
logos: finalLogos,
provider_logos_settled: true,
art_preview: false,
loading_shell: false,
partial_error: null,
hero_seed_banner:
finalBanners.length ? null : staged?.hero_seed_banner ?? null,
placeholder_picture:
staged?.placeholder_picture ?? page.artist?.picture ?? null,
cover_logo:
finalLogos.length
? null
: staged?.cover_logo ?? null,
cover_logo_pending: staged?.cover_logo_pending ?? false,
};
// MusicBrainz last pass — shared with the cached fast path, see
// applyArtistIdentity for the semantics.
void applyArtistIdentity(artistId, active);
// No provider logo for this artist → try to lift a wordmark from the
// album covers (band name printed as clean text). Deferred, cached,
// additive-only; on failure the plain text name stays.
void resolveCoverLogoAvailability(artistId, page, providerLogosReady, active);
}
} catch (e) {
if (active()) {
void loadFullArtistArt();
catalog.error = String(e);
catalog.artistStageError = "Some release details could not be loaded.";
if (catalog.artistPage) {
catalog.artistPage = {
...catalog.artistPage,
art_preview: false,
loading_shell: false,
partial_error: catalog.artistStageError,
};
}
}
} finally {
if (request === artistPageRequest) catalog.artistLoading = false;
}
}
/** Open an album's catalog page (cover + tracklist). `opts.trackId` flags a
* track to highlight/scroll to (used by the now-playing song link). */
export async function openCatalogAlbum(albumId, opts = {}) {
const albumKey = albumCacheKey(albumId);
if (!albumKey) return;
// A verified-id navigation supersedes any still-running name search.
albumResolveRequest += 1;
const request = ++albumPageRequest;
ui.view = "catalogAlbum";
catalog.albumPage = null;
catalog.albumLoading = true;
catalog.pendingTrackId = opts.trackId ?? null;
catalog.error = "";
const active = () => request === albumPageRequest && ui.view === "catalogAlbum";
try {
const page = await api.catalogAlbum(albumId);
if (!sameAlbumId(page?.album?.id, albumKey)) {
throw new Error("catalog album returned a mismatched album identity");
}
if (!active()) return;
catalog.albumPage = page;
// No catalog trackId (e.g. local playback) — match the song by title.
if (catalog.pendingTrackId == null && opts.trackTitle) {
const t = page.tracks.find(
(tr) => catNorm(tr.title) === catNorm(opts.trackTitle),
);
if (t) catalog.pendingTrackId = t.id;
}
} catch (e) {
if (active()) catalog.error = String(e);
} finally {
if (request === albumPageRequest) catalog.albumLoading = false;
}
}
const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
/** Resolve an artist by name (cached) and open their catalog page — used by
* the now-playing artist link, which only knows the name. */
export async function openArtistByName(name) {
const norm = (name || "").trim().toLowerCase();
if (!norm) return;
let id = artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined;
if (id !== undefined) {
const cachedSeed = artistSeedCache.has(norm)
? touchCache(artistSeedCache, norm)
: null;
if (id != null) openCatalogArtist(id, cachedSeed);
return;
}
// A name-only Now Playing link still navigates immediately. Identity
// resolution replaces this shell in place instead of holding the old view
// behind a catalog search.
const resolveRequest = ++artistPageRequest;
ui.view = "catalogArtist";
catalog.artistLoading = true;
catalog.error = "";
catalog.artistStageError = "";
catalog.artistPage = {
artist: { id: null, name, picture: null, fans: null, album_count: null },
logos: [],
provider_logos_settled: false,
banner: null,
banners: [],
banner_custom: false,
popular_tracks: [],
albums: [],
top_genre: null,
socials: [],
art_preview: true,
loading_shell: true,
partial_error: null,
placeholder_picture: null,
};
try {
const res = await api.catalogSearch(name);
const artists = res.artists ?? [];
// Prefer the most-followed match so a shared name ("Pond", "Panda
// Bear") resolves to the canonical artist, not an obscure namesake
// that Deezer's search happens to list first.
const exact = artists.filter((a) => a.name.trim().toLowerCase() === norm);
const pool = exact.length ? exact : artists;
const best = pool.reduce(
(top, artist) => ((artist.fans ?? 0) > (top?.fans ?? -1) ? artist : top),
null,
);
id = best?.id ?? null;
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
if (best) rememberArtistSeed(best);
if (
id != null
&& resolveRequest === artistPageRequest
&& ui.view === "catalogArtist"
) {
return openCatalogArtist(id, best);
}
if (resolveRequest === artistPageRequest && id == null) {
catalog.error = "Artist not found.";
catalog.artistPage = null;
}
} catch (error) {
// Only a successful empty search is safe to negative-cache. A
// transport/provider failure must remain retryable on the next click.
if (resolveRequest === artistPageRequest) {
catalog.error = String(error);
catalog.artistPage = null;
}
} finally {
if (resolveRequest === artistPageRequest) catalog.artistLoading = false;
}
}
/** Resolve an album by artist + title (via catalog search) and open its page —
* used by the now-playing album/song links when there's no catalog albumId
* (e.g. a local library track). */
export async function openAlbumByName(artist, title, opts = {}) {
const request = ++albumResolveRequest;
if (opts.albumId != null) return openCatalogAlbum(opts.albumId, opts);
if (!artist || !title) return;
try {
const res = await api.catalogSearch(`${artist} ${title}`);
if (request !== albumResolveRequest) return;
const na = catNorm(artist);
const nt = catNorm(title);
// A title-only or first-result fallback can cross into a namesake's
// catalog. Without a verified id, both artist and title must match.
const match = res.albums?.find(
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
);
if (match) openCatalogAlbum(match.id, opts);
} catch {
// no album match — leave the view as-is
}
}
/** Save the file(s) you're already streaming into the library — no re-search,
* no re-source. `scope`: "track" (current) or "album" (all streamed tracks in
* the queue). Downloads the KNOWN peer file(s) directly (skipping the search
* that made the old button fail) → tag/organize pipeline. Returns { ok, msg }
* for a UI toast. */
export async function saveStream(scope) {
const items =
scope === "album"
? player.queue.filter((it) => it.stream)
: player.queue[player.queueIndex]?.stream
? [player.queue[player.queueIndex]]
: [];
if (items.length === 0) return { ok: false, msg: "Nothing streaming" };
try {
// Cached tracks organize instantly; only un-streamed ones download.
const [organized, queued] = await api.saveToLibrary(
items.map((it) => ({
username: it.stream.username,
path: it.path,
size: it.stream.size,
})),
);
let msg;
if (queued > 0) {
msg = organized > 0 ? `Saved ${organized} · downloading ${queued}` : `Downloading ${queued}…`;
} else {
msg = organized > 1 ? `Saved ${organized} to library` : "Saved to library";
}
return { ok: true, msg };
} catch {
return { ok: false, msg: "Couldn’t save" };
}
}
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
function currentArtistAlbum() {
const sub = player.track?.sub ?? "";
return sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""];
}
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
export function openCurrentArtist() {
const [artist] = currentArtistAlbum();
if (artist) openArtistByName(artist);
}
export function openCurrentAlbum() {
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId);
return;
}
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album);
}
export function openCurrentSong() {
const trackTitle = player.track?.title;
if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId, {
trackId: player.catalogRef.trackId,
trackTitle,
});
return;
}
const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album, { trackTitle });
}
// ---- Now Playing discovery (more-from-artist / fans-also-like / about) ----
export const discovery = $state({
artist: null, // normalized name currently loaded (guards against races)
loading: false,
artistId: null,
pageLoaded: false,
pageWanted: false,
picture: null,
fans: null,
albumCount: null,
albums: [], // discography → "More from"
related: [], // "Fans also like"
bio: null, // Wikipedia summary → "About the artist"
});
const bioCache = new Map(); // normalized name → bio text (or null)
const BIO_CACHE_MAX = 64;
/** Deezer's "this artist has no photo" portrait is the md5 of the EMPTY
* STRING. Crucially the API still returns a normal-looking per-artist hash
* that merely REDIRECTS there, and the placeholder has the same dimensions as
* a real portrait — so only the redirect target identifies it. The response's
* final URL is readable (the CDN sends CORS headers), and the fetch warms the
* same image the card is about to display. */
const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e";
const portraitProbeCache = new Map(); // picture url → is-placeholder
async function deezerPortraitMissing(url) {
if (!url) return true;
if (portraitProbeCache.has(url)) return touchCache(portraitProbeCache, url);
let missing = false;
try {
const response = await fetch(url);
missing = response.ok && response.url.includes(DEEZER_EMPTY_PICTURE_HASH);
} catch {
// Offline or blocked: keep the portrait rather than guess it away.
missing = false;
}
setBoundedMap(portraitProbeCache, url, missing, 64);
return missing;
}
/** Short artist bio, resolved by the backend through the artist's OWN
* MusicBrainz→Wikidata identity (see `artist_bio`). This used to be a raw
* browser fetch that displayed Wikipedia's FIRST hit for
* "<name> musician band" with no verification, which is how the panel came
* to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under
* Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the
* catalog id to anchor identity, so it runs after the id resolves. */
async function fetchArtistBio(artistId, name) {
try {
return (await api.catalogArtistBio(artistId, name)) ?? null;
} catch {
return null;
}
}
/** Resolve the playing artist's catalog id, then load their discography +
* related artists for the Now Playing discovery sections. Guarded by the
* normalized name so rapid track changes don't clobber each other. */
export async function loadDiscovery(artistName) {
const norm = (artistName || "").trim().toLowerCase();
if (!norm) {
discovery.artist = null;
return;
}
const resuming = discovery.artist === norm;
if (resuming && discovery.pageLoaded) return;
if (resuming && discovery.loading) {
// If the panel reopened after the delayed page check already skipped,
// let the in-flight overview request perform one exhaustive retry
// before it settles instead of losing refinement permanently.
if (ui.nowPlaying) discovery.pageWanted = true;
return;
}
discovery.artist = norm;
discovery.loading = true;
discovery.pageWanted = ui.nowPlaying;
if (!resuming) {
discovery.pageLoaded = false;
discovery.artistId = null;
discovery.picture = null;
discovery.fans = null;
discovery.albumCount = null;
discovery.albums = [];
discovery.related = [];
discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null;
}
try {
let id = discovery.artistId
?? (artistIdCache.has(norm) ? touchCache(artistIdCache, norm) : undefined);
if (id === undefined) {
const res = await api.catalogSearch(artistName);
const match =
res.artists?.find((a) => a.name.trim().toLowerCase() === norm) ??
res.artists?.[0] ??
null;
id = match?.id ?? null;
setBoundedMap(artistIdCache, norm, id, ARTIST_IDENTITY_CACHE_MAX);
if (match) rememberArtistSeed(match);
}
if (discovery.artist !== norm) return; // track changed while searching
if (id == null) return;
discovery.artistId = id;
// The bio is identity-anchored, so it can only start once the catalog
// id is known.
if (!bioCache.has(norm)) {
fetchArtistBio(id, artistName).then((bio) => {
// Only PIN a real answer. A null is usually a transient
// MusicBrainz throttle (its 1 req/s gate is shared with the
// identity pass and the library warmer), and caching that
// would blank the card for the rest of the session even
// though the very next attempt succeeds. Retries are cheap —
// every tier is disk cached for 24 h.
if (bio) setBoundedMap(bioCache, norm, bio, BIO_CACHE_MAX);
if (discovery.artist === norm) discovery.bio = bio;
});
}
const overviewReady = discovery.albums.length
? Promise.resolve(null)
: getCatalogArtistOverview(id).catch(() => null);
// The Now Playing panel can paint its useful discovery content from
// the overview. Delay exhaustive classification until the artist and
// open panel have remained stable, avoiding invisible work after a
// rapid skip or panel close.
const pageReady = (async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
if (discovery.artist !== norm || !ui.nowPlaying) return null;
discovery.pageWanted = false;
return getCatalogArtistPage(id).catch(() => null);
})();
const [overview, related] = await Promise.all([
overviewReady,
api.catalogRelated(id).catch(() => []),
]);
if (discovery.artist !== norm) return;
if (overview) {
rememberArtistSeed(overview.artist);
discovery.picture = overview.artist.picture ?? null;
// A Deezer "picture" can exist and still be the grey silhouette
// (see deezerPortraitMissing). Those artists usually DO have
// provider/Bing hero art — the artist page already resolves it —
// so borrow that instead of showing a placeholder.
void (async () => {
const portrait = discovery.picture;
if (portrait && !(await deezerPortraitMissing(portrait))) return;
const seed = await getCatalogArtistHeroSeed(id).catch(() => null);
if (
discovery.artist === norm
&& discovery.picture === portrait
&& seed?.banner
) discovery.picture = seed.banner;
})();
discovery.fans = overview.artist.fans ?? null;
discovery.albumCount =
overview.artist.album_count ?? overview.albums.length;
discovery.albums = overview.albums ?? [];
}
discovery.related = related ?? [];
let page = await pageReady;
if (discovery.artist !== norm) return;
if (!page && discovery.pageWanted && ui.nowPlaying) {
discovery.pageWanted = false;
page = await getCatalogArtistPage(id).catch(() => null);
if (discovery.artist !== norm) return;
}
if (page) {
rememberArtistSeed(page.artist);
discovery.picture = page.artist.picture ?? null;
discovery.fans = page.artist.fans ?? null;
discovery.albumCount = page.artist.album_count ?? page.albums.length;
discovery.albums = page.albums ?? [];
discovery.pageLoaded = true;
discovery.pageWanted = false;
}
} catch {
// leave whatever loaded; sections just stay empty
} finally {
if (discovery.artist === norm) discovery.loading = false;
}
}
// ---- auto-sourcing resolver ----
// The catalog is the app's front door: picking a song or album quietly runs
// a Soulseek search, ranks the sources, and streams/downloads the winner.
// The manual Search view stays available as a fallback.
export const resolver = $state({
active: false,
label: "",
error: "",
});
const RESOLVER_AUDIO_QUALITY = {
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
};
// Formats that decode progressively while the file is still downloading.
// MP4-family files (m4a/aac) put their index at the end and only play once
// complete, so they're save-only.
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
// User source-selection preferences, independent per mode. Loaded from
// settings at startup and refreshed by the Settings view after a commit;
// defaults reproduce the pre-feature behavior exactly.
const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" };
export const sourcePrefs = {
stream: { ...DEFAULT_SOURCE_PREFS },
download: { ...DEFAULT_SOURCE_PREFS },
};
export async function refreshSourcePrefs() {
try {
const s = await api.getSettings();
sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) };
sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) };
} catch {
// settings unavailable (fresh install / backend restart) → defaults
}
}
void refreshSourcePrefs();
/** Whether a peer file satisfies the user's quality window for this mode.
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
* bitrate at or above it — a peer that hides its bitrate does not get the
* benefit of the doubt when the user asked for a minimum. Thresholds sit
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
function passesSourcePrefs(f, prefs) {
const lossless = LOSSLESS_EXTS.has(f.extension);
if (prefs.max === "lossy_only" && lossless) return false;
switch (prefs.min) {
case "lossless":
if (!lossless) return false;
break;
case "kbps320":
if (!lossless && (f.bitrate ?? 0) < 315) return false;
break;
case "kbps256":
if (!lossless && (f.bitrate ?? 0) < 250) return false;
break;
case "kbps192":
if (!lossless && (f.bitrate ?? 0) < 186) return false;
break;
}
return true;
}
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
* overwhelmingly name files unaccented; without folding, normText simply
* DELETED accented letters ("Jóga" → "jga") and the request could never
* match the file. */
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
const normText = (s) =>
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
* with just the primary segment, capped to a few words. */
function simplifyTitle(s) {
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
return words || s || "";
}
// Words that mark an *edition* of a release, not a distinct release. Used to
// tell an edition suffix (noise, safe to drop) from a variant name that names
// a different album — critical for self-titled records where the base title
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
const ALBUM_EDITION_RE =
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
* only hold edition noise (or there are none). */
export function albumDisambiguator(title) {
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
for (const inner of groups) {
for (const part of inner.split(/\s*[/|;]\s*/)) {
const p = part.trim();
if (p && !ALBUM_EDITION_RE.test(p)) return p;
}
}
return "";
}
/** Search-query title-part for a release: the simplified title, plus the
* disambiguating variant name when there is one, so distinct self-titled
* albums search for their own colour instead of colliding on the band name. */
function albumQueryTitle(album) {
const base = simplifyTitle(album.title);
// Fall back to a caller-supplied variant (the family's colour) when the
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
// but names no colour, so without this it searches bare "Weezer".
const d = albumDisambiguator(album.title) || album.variant || "";
return d ? `${base} ${d}` : base;
}
// Server-distributed excluded search phrases (Soulseek message 160). A query
// containing one is NEVER routed by the server — it returns zero responses
// network-wide with no error. Verified live: every query containing "mgmt"
// was silent while "little dark age" alone returned 738 responses. The list
// arrives by event after login and is also fetchable on demand (covers a
// WebView reload); the resolver strips these terms before they reach the wire.
let excludedSearchPhrases = [];
export function setExcludedSearchPhrases(list) {
excludedSearchPhrases = (Array.isArray(list) ? list : [])
.map((p) => String(p ?? "").toLowerCase().trim())
.filter(Boolean);
}
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** Query with server-excluded phrases removed (whole-word, any position) and
* whitespace collapsed. Returns "" when nothing usable remains. */
function sanitizeSearchQuery(query) {
let out = ` ${query ?? ""} `;
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
}
return out.replace(/\s+/g, " ").trim();
}
/** Run a background source search (own token — the Search view is untouched)
* and collect responses. Ends early once `goodCount()` finds enough.
*
* The early exit is evaluated on a 250ms TICK, not only when a batch
* arrives: peers deliver most results within the first second and then go
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
* still sat out the full window waiting for one more batch that never came
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
* a workable set at 1800ms, and the full window only runs when results
* are genuinely sparse. */
async function collectSources(query, maxMs, goodCount) {
const token = await api.startSearch(query);
const responses = [];
let unlisten = null;
await new Promise((resolve) => {
const started = performance.now();
const timer = setTimeout(() => {
clearInterval(tick);
resolve();
}, maxMs);
const tick = setInterval(() => {
if (!goodCount) return;
const elapsed = performance.now() - started;
const good = goodCount(responses);
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
clearTimeout(timer);
clearInterval(tick);
resolve();
}
}, 250);
unlisten = api.onEvent("soulseek:search-results", (batch) => {
for (const r of batch) if (r.token === token) responses.push(r);
});
});
unlisten?.();
api.stopSearch(token).catch(() => {});
return responses;
}
/** Queries measured silent (zero responses) recently — skipped for a while
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
* every album/track of the same artist. */
const silentQueries = new Map();
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
function querySilent(q) {
const at = silentQueries.get(q);
if (at == null) return false;
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
silentQueries.delete(q);
return false;
}
return true;
}
// Terms PROVEN poisoned this session: a rung containing the term was silent
// while a term-less rung answered — the network is fine, the term is dead
// (the "mgmt" case; the server's own excluded list does not admit it).
// Applied exactly like server-excluded phrases, so the second album by the
// same artist never pays the discovery timeout again.
const learnedPoisonedTerms = new Set();
/** Estimated seconds until this peer's file has fully arrived: transfer time
* from the file's size and the peer's advertised upload speed (bytes/sec),
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
* peer hides its numbers — an unknown speed must not look like a fast one. */
function estimatedTransferSecs(r, file) {
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
let secs = size / speed;
if (!r.has_free_upload_slot) secs += 90;
secs += Math.min(r.queue_length ?? 0, 50) * 45;
return secs;
}
function scoreSource(r, file, prefs = null) {
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
if (!q) return -1;
const bitrate = file.bitrate;
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
// only reorders sources that already passed the user's min/max quality
// window (passesSourcePrefs runs before scoring), so "fastest" can never
// dip below the configured floor. Format/bitrate remain as tie-breakers.
if (prefs?.priority === "speed") {
// Positive baseline: callers treat score <= 0 as "reject this
// format", and a slow, deep-queued peer must rank last, not vanish.
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
let s = 100_000 - eta * 25;
s += q * 50;
if (bitrate) s += Math.min(bitrate, 1500) / 50;
return s;
}
let s = q * 1000;
if (bitrate) s += Math.min(bitrate, 1500) / 10;
if (r.has_free_upload_slot) s += 600;
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
s -= Math.min(r.queue_length ?? 0, 50) * 12;
return s;
}
function requireOnline() {
if (app.connection !== "online") {
app.showLogin = true;
return false;
}
return true;
}
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
// remixes, demos, covers, radio edits… `normText` strips brackets, so
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
// these are tested against the RAW filename + folder to catch that. Matching
// is symmetric: a candidate must carry exactly the markers the requested
// title carries — no live file for a studio request, no studio file for an
// explicitly "(Live)" catalog track.
const TRACK_VARIANT_MARKERS = [
/\blive\b/i,
/\bunplugged\b/i,
/\bacoustic\b/i,
/\bremix(?:es|ed)?\b/i,
/\brmx\b/i,
/\brework(?:ed)?\b/i,
/\bdemo(?:s)?\b/i,
/\binstrumental\b/i,
/\bkaraoke\b/i,
/\bcover(?:s|ed)?\b/i,
/\btribute\b/i,
/\b(?:radio|single)\s+edit\b/i,
/\b(?:club|extended|dub|dance)\s+mix\b/i,
/\bmedley\b/i,
/\bmashup\b/i,
/\bsession(?:s)?\b/i,
/\brehearsal\b/i,
/\bouttake(?:s)?\b/i,
/\balternate\b/i,
/\btake\s+\d+\b/i,
/\bre-?recorded\b/i,
// "<Name>'s Version" re-records (Taylor's Version and kin): a different
// recording of the song, matched symmetrically like every marker here —
// found live by the source audit when a request for "22" top-ranked
// "22 (Taylor's Version)".
/\b\w+['’]s\s+version\b/i,
/\bsped\s+up\b/i,
/\bslowed\b/i,
/\b8[\s-]?bit\b/i,
];
// Checked against the filename only: "Reprise" is also a record label
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
/** True when the candidate names a different version of the song than the
* request asked for (either direction). */
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
const cand = `${candidateFolder}\\${candidateName}`;
for (const re of TRACK_VARIANT_MARKERS) {
if (re.test(cand) !== re.test(wantedTitle)) return true;
}
for (const re of TRACK_VARIANT_NAME_ONLY) {
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
}
return false;
}
/** A peer file's playtime is the wrong recording when it's far from the
* catalog's known length — most often a ripped 30-second streaming *preview*
* whose filename matches the real track (e.g. a 30s "Long Season" for a
* 41-minute live track). Only judges when BOTH durations are known; the band
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
* silence never trip it. */
function durationMismatch(expectSecs, candSecs) {
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
const tol = Math.max(45, expectSecs * 0.2);
return Math.abs(candSecs - expectSecs) > tol;
}
/** Ranked candidate files for a song across all responses (best first).
* `album` (title string, optional) biases toward folders of the release the
* user is actually looking at. `expectSecs` (optional) is the catalog track's
* known length, used to drop preview clips / wrong-length rips. */
function songCandidates(
responses,
title,
streamOnly = false,
album = null,
expectSecs = null,
artist = null,
requireIdentityEvidence = false,
) {
const want = normText(title);
const wantedRaw = title ?? "";
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
const artistKey = artist ? normText(artist) : "";
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
const out = [];
for (const r of responses) {
for (const f of r.files) {
if (!normText(f.name).includes(want)) continue;
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
if (!passesSourcePrefs(f, prefs)) continue;
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
if (durationMismatch(expectSecs, f.duration)) continue;
let s = scoreSource(r, f, prefs);
if (s <= 0) continue;
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
const artistEvidence =
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
// A rung that dropped the artist term (server-excluded phrase)
// matches by title alone, which happily returns other artists'
// covers of the song. The peer's PATH must then prove identity:
// the artist somewhere in the folder/filename, or the exact
// release folder.
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
continue;
}
// The requested release's own folder beats compilation/misc rips
// (below a format-tier jump, so quality still wins).
if (albumEvidence) s += 800;
if (artistEvidence) s += 400;
// Filename ends with the title → it IS this song, not a longer
// title containing it ("Holiday" vs "Holiday in Cambodia").
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
out.push({ username: r.username, file: f, score: s });
}
}
out.sort((a, b) => b.score - a.score);
return out;
}
/** Exposed for the source-resolution audit harness (session-scratchpad CDP
* probes drive the REAL matching/scoring code against live search responses).
* Not part of the app surface. */
export const _sourceAudit = {
songCandidates,
albumCandidates,
sanitizeSearchQuery,
collectSources,
variantMismatch,
durationMismatch,
asciiFold,
normText,
simplifyTitle,
stopPlayback,
};
/** Resolves true when the stream is audibly playing; false on decode/peer
* failure (status drops to idle) or when nothing plays within `ms`. */
function waitForStreamStart(ms) {
return new Promise((resolve) => {
const t0 = performance.now();
const iv = setInterval(() => {
if (player.status === "playing") {
clearInterval(iv);
resolve(true);
} else if (performance.now() - t0 > ms || player.status === "idle") {
clearInterval(iv);
resolve(false);
}
}, 100);
});
}
/** Ranked candidate folders for an album (best first). `trackCountOverride`
* is the authoritative track count from the catalog tracklist fetch; prefer it
* over `album.track_count`, which is routinely null when Play is pressed on the
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
function albumCandidates(
responses,
album,
streamOnly = false,
trackCountOverride = null,
requireIdentityEvidence = false,
) {
const artistKey = normText(album.artist ?? "");
const wantAlbum = normText(simplifyTitle(album.title));
const wanted = trackCountOverride ?? album.track_count ?? null;
const year = album.release_date?.slice(0, 4) || null;
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
// colour word, so we can prefer the right folder and reject a wrong-colour
// one that a fuzzy peer search returns anyway.
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
const folders = new Map();
for (const r of responses) {
for (const f of r.files) {
const key = `${r.username}|${f.folder}`;
let g = folders.get(key);
if (!g) {
g = { username: r.username, folder: f.folder, files: [], r };
folders.set(key, g);
}
g.files.push(f);
}
}
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
const out = [];
for (const g of folders.values()) {
const audio = g.files.filter(
(f) =>
(streamOnly
? RESOLVER_STREAMABLE.has(f.extension)
: RESOLVER_AUDIO_QUALITY[f.extension])
&& passesSourcePrefs(f, prefs),
);
// Require enough streamable files to plausibly be this release, but
// NEVER default an unknown count to 3 — that silently rejected every
// single/2-track release (whose peer folders hold 1–2 matching files)
// and is exactly why "No streamable album sources found" fired for
// real, obviously-streamable singles like ICARUS. Known count → at most
// 3, floor 1; unknown → just 1.
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
if (audio.length < minAudio) continue;
const top = audio
.map((f) => scoreSource(g.r, f, prefs))
.reduce((a, b) => a + b, 0) / audio.length;
let s = top;
const folderNorm = normText(g.folder);
const albumEvidence = folderNorm.includes(wantAlbum);
const artistEvidence = artistKey && folderNorm.includes(artistKey);
// Artist-less query rung (server-excluded phrase): the folder path
// must prove the release's identity — see songCandidates.
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
continue;
}
if (albumEvidence) s += 400;
if (artistEvidence) s += 300;
// Prefer folders whose track count matches the release.
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
// The release year in the folder path is a strong per-release signal
// (distinguishes reissues and same-named albums).
if (year && g.folder.includes(year)) s += 300;
// Self-titled colour disambiguation: reward the matching "(X Album)"
// folder, and strongly reject a folder that clearly names a different
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
if (wantColour) {
const folderColours = [
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
].map((m) => m[1]);
if (folderColours.includes(wantColour)) s += 500;
else if (folderColours.length) s -= 900;
}
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
out.push({ username: g.username, folder: g.folder, files: g.files, audio, score: s });
}
out.sort((a, b) => b.score - a.score);
return out;
}
/** Walk a LADDER of query shapes until one returns responses. Each rung is
* sanitized against the server's excluded-phrase list first (a poisoned term
* silences the whole query), deduplicated, and dropped when sanitizing left
* nothing. Returns `{ responses, artistIncluded }` — `artistIncluded` is
* whether the rung that produced the responses still contained the artist
* term, so candidate filters know to demand artist/album evidence in the
* file path instead (a title-only query happily returns covers and karaoke).
*
* `queries` may be a string (single rung) or an array, best-first. */
async function resolve(label, queries, goodCount, artistTerm = null) {
resolver.active = true;
resolver.label = label;
resolver.error = "";
player.error = "";
try {
const artistKey = artistTerm ? normText(artistTerm) : "";
const seen = new Set();
const ladder = [];
for (const raw of [].concat(queries).filter(Boolean)) {
// Each shape also gets an ASCII-folded rung: peers name files
// unaccented, and the server AND-matches query tokens, so
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
for (const variant of [raw, asciiFold(raw)]) {
const q = sanitizeSearchQuery(variant);
if (!q || seen.has(q)) continue;
seen.add(q);
ladder.push(q);
}
}
// Non-final rungs get a short window: a rung the network answers at
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
// outright. The post-login warm-up retry moved BEHIND the ladder: if
// ANY rung answered, connectivity is proven and no retry is owed —
// the old first-rung retry burned 7 extra seconds on every poisoned
// artist query before the fallback rung even ran.
let sawSilentArtistRung = false;
for (let index = 0; index < ladder.length; index++) {
const q = ladder[index];
if (querySilent(q)) continue;
const final = index === ladder.length - 1;
const hasArtist = artistKey && normText(q).includes(artistKey);
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
if (responses.length) {
// An answered term-less rung after a silent artist rung PROVES
// the artist term is what kills the query — learn it, so the
// artist's other releases skip the dead rung entirely.
if (sawSilentArtistRung && !hasArtist && artistTerm) {
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
}
return {
responses,
artistIncluded: !artistKey || normText(q).includes(artistKey),
};
}
if (hasArtist) sawSilentArtistRung = true;
silentQueries.set(q, performance.now());
}
// Whole ladder silent: either the terms are all poisoned or we are in
// the post-login window where the server has not attached us to a
// search parent yet. One quiet retry of the primary rung covers the
// latter (and clears its silent mark on success).
if (ladder.length) {
const q = ladder[0];
const responses = await collectSources(q, 7000, goodCount);
if (responses.length) {
silentQueries.delete(q);
return {
responses,
artistIncluded: !artistKey || normText(q).includes(artistKey),
};
}
}
return { responses: [], artistIncluded: true };
} finally {
resolver.active = false;
resolver.label = "";
}
}
// ---- library-first playback ----
// Clicking play on a catalog album/song that's already saved plays the
// local library copy instead of sourcing a stream from a peer.
/** Library album matching a catalog release (same normalization as the
* artist page's ✓ badges, plus the simplified-title variant so verbose
* catalog editions still match). */
function libraryAlbumFor(artist, title, releaseDate = null) {
const a = normText(artist);
const t1 = normText(title);
if (!a || !t1) return null;
// normText strips ALL bracket content, so base-title comparison alone
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
// a library copy saved under the bare provider title "Weezer". After the
// base titles agree, identity therefore needs the variant name
// (albumDisambiguator) to agree — or, when either side lacks one, the
// release YEAR to corroborate. A self-titled base carries no identity at
// all (the title is just the artist), so even a bare↔bare title match
// requires the year there. No usable evidence → no local match; streaming
// the right album beats instantly playing the wrong one.
const wantD = normText(albumDisambiguator(title));
// The simplified-title fallback lets a library copy saved under a terse
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
// (Deluxe Edition)"); disambiguated variants never take it.
const t2 = wantD ? null : normText(simplifyTitle(title));
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
const selfTitled = t1 === a;
return (
libraryStore.albums.find((al) => {
if (normText(al.artist) !== a) return false;
const lt = normText(al.title);
if (lt !== t1 && !(t2 && lt === t2)) return false;
const libD = normText(albumDisambiguator(al.title));
if (wantD && wantD === libD) return true;
if (wantD !== libD || selfTitled) {
const libYear = Number(al.year) || null;
return wantYear != null && libYear != null && libYear === wantYear;
}
return true;
}) ?? null
);
}
/** The library album matching a catalog release, or null — the same evidence
* rules as library-first playback (variant name + release-year for self-titled
* records). Exposed for the artist page's saved-track ticks. */
export function libraryLocalAlbum(artist, album) {
return libraryAlbumFor(artist, album?.title ?? "", album?.release_date ?? null);
}
/** Whether a catalog release already has a matching copy in the library —
* the same evidence rules as library-first playback, so the artist page's
* ✓ badges and the play routing can never disagree. */
export function libraryHasAlbum(artist, album) {
return Boolean(libraryLocalAlbum(artist, album));
}
/** Locate a track in the library by artist + title, searching only that
* artist's albums. Returns { album, tracks, index } or null. */
async function libraryTrackFor(artist, title) {
const a = normText(artist);
const t = normText(title);
if (!a || !t) return null;
for (const al of libraryStore.albums) {
if (normText(al.artist) !== a) continue;
try {
const tracks = await api.albumTracks(al.id);
const index = tracks.findIndex((tr) => normText(tr.title) === t);
if (index >= 0) return { album: al, tracks, index };
} catch {
// library unavailable — fall through to streaming
}
}
return null;
}
/** Distill a peer file's tags into the quality info the Now Playing panel
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
* many clients only report some, so the panel hides what's missing. */
function qualityOf(f) {
if (!f) return null;
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
return {
format: ext.toUpperCase(),
bitrate: f.bitrate ?? null,
sampleRate: f.sample_rate ?? null,
bitDepth: f.bit_depth ?? null,
vbr: !!f.vbr,
size: f.size ?? null,
};
}
/** Quality info for a local library track: format from the path, the rest
* from the DB (sample-rate/bit-depth/size backfilled for older files at
* startup). The panel drops whatever's still missing. Null if there's no
* path. `t` is a library Track row. */
function localQuality(t) {
const path = t?.path || "";
const ext = path.split(".").pop() || "";
if (!ext || ext === path) return null;
return {
format: ext.toUpperCase(),
bitrate: t.bitrate_kbps ?? null,
sampleRate: t.sample_rate ?? null,
bitDepth: t.bit_depth ?? null,
vbr: false,
size: t.size ?? null,
};
}
/** Deezer's CDN renders the same cover at whatever size the URL's size
* segment asks for. Track objects carry the 250px list thumb
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
* untouched. */
function hqCover(url) {
if (!url) return url;
try {
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
} catch {
return url;
}
return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/1000x1000$1.$2",
);
}
/** Responsive card-sized variant of a canonical cover URL. The resolver keeps
* its original/high-resolution winner for the player and album expander; the
* 150px grid should not decode 9-million-pixel Apple masters into GPU memory. */
export function gridCover(url) {
if (!url || url.startsWith("data:")) return url;
try {
const parsed = new URL(url);
if (parsed.hostname.endsWith("dzcdn.net")) {
return url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/500x500$1.$2",
);
}
if (parsed.hostname.endsWith("mzstatic.com")) {
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/500x500bb.jpg");
return parsed.toString();
}
if (parsed.hostname.endsWith("coverartarchive.org")) {
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-500");
return parsed.toString();
}
} catch {
return url;
}
return url;
}
/** A tiny CDN-rendered variant used for blurred placeholders and palette
* sampling. Deezer and Apple both resize the same source artwork by URL. */
export function tinyCover(url) {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname.endsWith("mzstatic.com")) {
parsed.pathname = parsed.pathname.replace(/\/[^/]+$/, "/96x96bb.jpg");
return parsed.toString();
}
if (parsed.hostname.endsWith("coverartarchive.org")) {
const originalPath = parsed.pathname;
parsed.pathname = parsed.pathname.replace(/\/front(?:-\d+)?$/, "/front-250");
return parsed.pathname === originalPath && !originalPath.endsWith("/front-250")
? null
: parsed.toString();
}
if (!parsed.hostname.endsWith("dzcdn.net")) return null;
} catch {
return null;
}
const tiny = url.replace(
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
"/56x56$1.$2",
);
return tiny === url ? null : tiny;
}
/** Find the best source for a song and stream it, failing over to the next
* candidate when a peer never starts sending. Songs already in the library
* play locally (with their album as the queue).
* opts: { cover, trackId, albumId, album, expectSecs } — catalog context for
* art, badges, album-folder source preference, and length verification. */
export async function streamSong(artist, title, opts = {}) {
if (resolver.active) return;
const local = await libraryTrackFor(artist, title);
if (local) {
playAlbum(
local.tracks,
`${local.album.artist} — ${local.album.title}`,
local.index,
local.album.art_path ?? null,
);
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
return;
}
if (!requireOnline()) return;
autoAlbum = null;
player.track = { title, sub: "finding best source…", source: "stream" };
player.status = "buffering";
const { responses, artistIncluded } = await resolve(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, true, opts.album, opts.expectSecs, artist).length,
artist,
);
const candidates = songCandidates(
responses, title, true, opts.album, opts.expectSecs, artist, !artistIncluded,
);
if (candidates.length === 0) {
player.status = "idle";
player.track = null;
resolver.error = `No streamable sources found for “${title}”`;
return;
}
for (const c of candidates.slice(0, 4)) {
player.queue = [
{
path: c.file.path,
title,
sub: artist,
artist,
album: opts.album ?? null,
duration: c.file.duration ?? opts.expectSecs ?? null,
art: null,
artUrl: hqCover(opts.cover) ?? null,
trackId: opts.trackId ?? null,
stream: { username: c.username, size: c.file.size },
quality: qualityOf(c.file),
},
];
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
playQueueIndex(0);
if (await waitForStreamStart(15000)) {
// Keep the album going: resolve + pre-buffer the next album
// track in the background, Spotify-style.
if (opts.albumId) {
autoAlbum = {
albumId: opts.albumId,
albumTitle: opts.album ?? null,
cover: hqCover(opts.cover) ?? null,
tracks: null,
pending: false,
};
maybeExtendAlbumQueue();
}
return;
}
}
stopPlayback();
resolver.error = `Sources for “${title}” aren't responding — try again in a moment`;
}
/** Find the best source for a song and download it into the library. */
export async function saveSong(artist, title) {
if (!requireOnline() || resolver.active) return false;
const { responses, artistIncluded } = await resolve(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, false, null, null, artist).length,
artist,
);
const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0];
if (!best) {
resolver.error = `No sources found for “${title}”`;
return false;
}
await api.downloadFile(best.username, best.file.path, best.file.size);
return true;
}
/** Match a peer file to a catalog track, so queue items carry the tagged
* title instead of the filename.
*
* A track title contained in the filename normally identifies the track — but
* only if it's *specific* to this file. When peers embed the album name in
* every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album
* name is also a track title, that album name is a substring of every filename
* and — being long — used to out-rank each track's real, shorter title, so the
* whole album collapsed to one repeated title. So: ignore any title that
* appears in (roughly) every filename, take the longest *specific* match, and
* otherwise fall back to album order when the folder is a complete album. */
function catalogTrackFor(file, i, files, tracks) {
if (!tracks?.length) return null;
const fname = normText(file.name);
const base = (t) => normText(t.title);
const named = (t) => base(t).length >= 3 && fname.includes(base(t));
// PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds
// exactly the album's track count it's almost always the whole album in
// order, so file `i` is track `i`. That's the only reliable way to tell
// same-named variants apart — "My Name Is Jonas (2024 Remaster)",
// "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base
// title, so filename matching alone can't say which belongs here (it used
// to pick the longest title, i.e. the verbose live/demo one). Only distrust
// the position if the filename clearly names a DIFFERENT song than the one
// sitting there (a reordered/mismatched folder — handled by the backup).
if (files.length === tracks.length) {
const at = tracks[i];
if (!fname || named(at) || !tracks.some(named)) return at;
}
// BACKUP: fuzzy filename→title matching for a partial/reordered folder.
// Drop any title that appears in ~every filename (that's the album name),
// then among the rest prefer the closest duration (disambiguates same-named
// variants), then the longest title.
const contained = tracks.filter(named);
const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0);
const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5));
let pool = contained.filter((t) => ubiquity(t) < ubiquitous);
if (!pool.length) pool = contained;
if (pool.length) {
const dur = file.duration;
if (dur) {
const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur);
// Closest duration wins; on a tie prefer the shorter title, i.e. the
// plain studio version over a longer "(Live…)" / "(Demo…)" variant.
return pool.reduce((a, b) =>
off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a,
);
}
return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a));
}
return files.length === tracks.length ? tracks[i] : null;
}
/** Turn a peer filename into a readable title when no catalog track matches
* it (a genuine bonus/extra file on an expanded edition): drop the extension
* and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a
* title rather than "04. Dragon Ball Z…". Conservative — only strips a number
* that's clearly a track index (has a separator, or a leading zero), so a title
* like "99 Luftballons" is left intact. */
function cleanTrackName(name) {
const stem = (name ?? "").replace(/\.[^.]+$/, "");
const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim();
return stripped || stem;
}
/** Find the best folder for an album; stream it as a queue, failing over to
* the next-best folder when a peer never starts sending. Albums already in
* the library play locally instead. */
export async function streamCatalogAlbum(album) {
if (resolver.active) return;
const local = libraryAlbumFor(album.artist, album.title, album.release_date ?? null);
if (local) {
try {
const tracks = await api.albumTracks(local.id);
if (tracks.length > 0) {
playAlbum(
tracks,
`${local.artist} — ${local.title}`,
0,
local.art_path ?? null,
);
player.catalogRef = { albumId: album.id ?? null, trackId: null };
return;
}
} catch {
// library unavailable — fall through to streaming
}
}
if (!requireOnline()) return;
autoAlbum = null;
player.track = { title: album.title, sub: "finding best source…", source: "stream" };
player.status = "buffering";
// Resolve the authoritative tracklist FIRST. Its length is the true track
// count — Deezer's artist-album LIST omits nb_tracks, so album.track_count
// is routinely null when Play is pressed on the artist page — and its
// titles are what the queue should show instead of peer filenames. Feeding
// the real count into albumCandidates both stops the ≥3-files filter from
// rejecting small releases and lets the folder-count preference pick the
// edition that matches the release (e.g. the 3-track single, not a 7-track
// CDR that would leak bonus tracks into the queue as raw filenames).
const catalogPromise = album.id
? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: Promise.resolve(null);
// Overlap the peer search with the tracklist fetch when the release is
// provably multi-track — the search is the long pole, and the tracklist
// round-trip used to serialize in front of it. `liveTrackCount` feeds the
// early-exit counter and tightens itself the moment the tracklist lands.
// A possible single (unknown count, or single/EP typed) keeps the
// sequential order, because a confirmed single must take the SONG path
// and must not race an already-active album resolve.
const provablyMultiTrack =
(album.track_count ?? 0) > 1 || album.record_type === "album";
let liveTrackCount = album.track_count || null;
const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length;
let resolvePromise = null;
if (provablyMultiTrack) {
resolvePromise = resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
goodCount,
album.artist,
);
}
const catalogTracks = await catalogPromise;
const trackCount = catalogTracks?.length || album.track_count || null;
liveTrackCount = trackCount;
// A confirmed single is one track — stream it through the song path, which
// matches peer files by TITLE (album folder-matching can't identify a lone
// file among a folder's contents) and layers on the live/remix + duration
// hardening. Without this, a single fell into the album path and, with an
// unknown count, was rejected for having fewer than 3 files per folder.
if (trackCount === 1) {
// Rare mistyped release: a search may already be in flight — let it
// finish so the song path's own resolve isn't refused as re-entrant.
if (resolvePromise) await resolvePromise.catch(() => {});
const t = catalogTracks?.[0];
await streamSong(album.artist, t?.title ?? album.title, {
cover: t?.cover ?? album.cover,
trackId: t?.id ?? null,
albumId: album.id ?? null,
album: album.title || null,
expectSecs: t?.duration_secs ?? null,
});
return;
}
const { responses, artistIncluded } = await (resolvePromise
?? resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
goodCount,
album.artist,
));
const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded);
if (candidates.length === 0) {
player.status = "idle";
player.track = null;
resolver.error = `No streamable album sources found for “${album.title}”`;
return;
}
for (const folder of candidates.slice(0, 3)) {
player.queue = folder.audio.map((f, i) => {
const ct = catalogTrackFor(f, i, folder.audio, catalogTracks);
return {
path: f.path,
title: ct?.title ?? cleanTrackName(f.name),
sub: `${album.artist} — ${album.title}`,
artist: album.artist,
album: album.title ?? null,
duration: f.duration ?? ct?.duration_secs ?? null,
art: null,
artUrl: album.cover ?? null,
trackId: ct?.id ?? null,
stream: { username: folder.username, size: f.size },
quality: qualityOf(f),
};
});
player.catalogRef = { albumId: album.id ?? null, trackId: null };
playQueueIndex(0);
if (await waitForStreamStart(15000)) return;
}
stopPlayback();
resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;
}
/** Find the best folder for an album and download all of it. */
export async function saveCatalogAlbum(album) {
if (!requireOnline() || resolver.active) return false;
// Same authoritative-count logic as streamCatalogAlbum: a single downloads
// as one track (folder-count matching can't find a lone-file release), and
// the real count keeps a multi-track Save from being rejected or grabbing a
// wrong-length edition.
const catalogTracks = album.id
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: null;
const trackCount = catalogTracks?.length || album.track_count || null;
if (trackCount === 1) {
const t = catalogTracks?.[0];
return await saveSong(album.artist, t?.title ?? album.title);
}
const { responses, artistIncluded } = await resolve(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
(rs) => albumCandidates(rs, album, false, trackCount).length,
album.artist,
);
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
if (!best) {
resolver.error = `No album sources found for “${album.title}”`;
return false;
}
// The whole folder, extras included — the pipeline organizes and the
// staging cleanup files art/logs next to the finished album.
for (const f of best.files) {
await api.downloadFile(best.username, f.path, f.size);
}
return true;
}
export const search = $state({
token: null,
query: "",
searching: false,
responses: 0,
// key: `${username}|${folder}` -> group summary (scalars only — the file
// lists live outside reactive state, see searchFiles below).
groups: {},
});
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
// the group's reactive fileCount and read the list through filesForGroup().
const searchFiles = new Map();
let searchStopTimer = null;
let searchRequest = 0;
function trimHiddenSearchFiles() {
const visibleKeys = new Set(
Object.values(search.groups)
.sort(
(a, b) =>
(b.hasFreeSlot - a.hasFreeSlot)
|| (b.speed - a.speed),
)
.slice(0, 120)
.map((group) => group.key),
);
for (const key of searchFiles.keys()) {
if (!visibleKeys.has(key)) searchFiles.delete(key);
}
}
export function filesForGroup(key) {
return searchFiles.get(key)?.files ?? [];
}
export const AUDIO_EXTS = new Set([
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
]);
export const downloads = $state({
// id -> download payload (latest update wins)
items: {},
});
export const network = $state({
shareFolders: 0,
shareFiles: 0,
respondedCount: 0,
uploads: {}, // id -> upload payload
});
const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]);
const DOWNLOAD_HISTORY_MAX = 300;
const UPLOAD_HISTORY_MAX = 100;
function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) {
const values = Object.values(store);
if (
waitUntilIdle
&& values.some((transfer) => !TERMINAL_TRANSFER_STATES.has(transfer.state))
) {
return;
}
const terminal = values
.filter((transfer) => TERMINAL_TRANSFER_STATES.has(transfer.state))
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id];
}
export const player = $state({
status: "idle", // idle | buffering | playing | paused
track: null, // { title, sub, source: 'stream'|'local' }
position: 0,
duration: null,
// The player bar temporarily owns position while the user is dragging the
// playhead. Incoming backend ticks resume on release.
scrubbing: false,
volume: 0.7,
muted: false,
// streaming buffer info
streamReceived: 0,
streamTotal: 0,
error: "",
// album play queue (local tracks)
queue: [],
queueIndex: -1,
shuffle: false,
repeat: "off", // off | all | one
// waveform peaks (0..1) for the current local track, [] while loading
waveform: [],
// Which catalog entity is playing (drives now-playing badges).
catalogRef: { albumId: null, trackId: null },
});
// Adaptive accent theme, derived from the playing track's album art.
// Idle default matches the SoulStream brand blue (see icons/).
const DEFAULT_ACCENT = "#1f9ad6";
const DEFAULT_ACCENT_HOVER = "#41b7ee";
export const theme = $state({
accent: DEFAULT_ACCENT,
accentHover: DEFAULT_ACCENT_HOVER,
});
let accentRaf = null;
/** Animate --accent/--accent-hover to the target colors (ease-out). */
function crossfadeAccent(accent, hover, ms = 1200) {
if (accent === theme.accent && hover === theme.accentHover) return;
if (accentRaf) cancelAnimationFrame(accentRaf);
const fromA = hexToRgb(theme.accent);
const fromH = hexToRgb(theme.accentHover);
const toA = hexToRgb(accent);
const toH = hexToRgb(hover);
const t0 = performance.now();
const root = document.documentElement.style;
const step = (now) => {
if (!ui.windowActive) {
theme.accent = accent;
theme.accentHover = hover;
root.setProperty("--accent", accent);
root.setProperty("--accent-hover", hover);
accentRaf = null;
return;
}
const t = Math.min(1, (now - t0) / ms);
const e = t * (2 - t);
theme.accent = rgbToHex(lerpRgb(fromA, toA, e));
theme.accentHover = rgbToHex(lerpRgb(fromH, toH, e));
root.setProperty("--accent", theme.accent);
root.setProperty("--accent-hover", theme.accentHover);
accentRaf = t < 1 ? requestAnimationFrame(step) : null;
};
accentRaf = requestAnimationFrame(step);
}
let accentReq = 0;
/** Set the app accent from an album-art path (crossfades; null → default). */
async function setAccentFromArt(artPath) {
const req = ++accentReq;
if (!artPath) {
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
return;
}
try {
const dataUrl = await getArt(artPath);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
if (req !== accentReq) return; // track changed while extracting
if (colors) crossfadeAccent(colors.accent, colors.hover);
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
} catch {
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
}
}
/** Same, from a remote catalog cover URL (proxied to a data URL). */
async function setAccentFromArtUrl(url) {
const req = ++accentReq;
if (!url) {
crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
return;
}
try {
const dataUrl = await getCatalogArt(url);
const colors = dataUrl ? await accentFromImage(dataUrl) : null;
if (req !== accentReq) return;
if (colors) crossfadeAccent(colors.accent, colors.hover);
else crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
} catch {
if (req === accentReq) crossfadeAccent(DEFAULT_ACCENT, DEFAULT_ACCENT_HOVER);
}
}
// ---- play history ----
// Listened time is accumulated from the player's position ticks — wall-clock
// timers would count paused/buffering time, and ticks stop exactly when audio
// does. A session row is only created once a track has actually accumulated
// listened time, so instant skips never write history. Seeks are ignored via
// the delta guard (a jump or rewind isn't listening). The playId promise
// serializes the insert and its top-ups, so rapid track changes can't lose
// the remainder or write it to the wrong row.
const LISTEN_FLUSH_SECS = 15;
let listenSession = null; // { key, pending, playIdP }
let listenLastPos = null;
function listenFlush(session = listenSession) {
if (!session?.playIdP) return;
const secs = Math.floor(session.pending);
if (secs < 1) return;
session.pending -= secs;
session.playIdP
.then((id) => api.addPlaySeconds(id, secs))
.catch(() => {});
}
function listenTick(positionSecs) {
const t = player.track;
const title = t?.title ?? "";
const artist = (t?.artist ?? "").trim()
|| String(t?.sub ?? "").split(" — ")[0].trim();
if (!title || !artist || artist.includes("…")) return;
const key = `${artist}\u0000${title}`;
if (listenSession?.key !== key) {
listenFlush();
listenSession = { key, artist, title, pending: 0, playIdP: null };
listenLastPos = null;
}
const delta = listenLastPos == null ? 0 : positionSecs - listenLastPos;
listenLastPos = positionSecs;
if (delta <= 0 || delta > 3) return;
listenSession.pending += delta;
if (!listenSession.playIdP && listenSession.pending >= 1) {
listenSession.playIdP = api.recordPlay(artist, title);
listenSession.playIdP.catch(() => {});
}
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
}
let started = false;
let windowActivityStarted = false;
let latestPlayerPosition = 0;
let latestPlayerDuration = null;
let latestStreamReceived = 0;
let latestStreamTotal = 0;
function initWindowActivity() {
if (windowActivityStarted) return;
windowActivityStarted = true;
let active = true;
let syncGeneration = 0;
let syncTimer = null;
const nativeUnlisteners = [];
const sync = async () => {
const generation = ++syncGeneration;
let minimized = false;
try {
minimized = await api.win.isMinimized();
} catch {
// Plain-browser previews have no native window API.
}
if (!active || generation !== syncGeneration) return;
const wasActive = ui.windowActive;
// A visible, unfocused window can still be watched side-by-side. Only
// actual native minimization / DOM hiding should freeze visual state.
ui.windowActive = !document.hidden && !minimized;
if (!wasActive && ui.windowActive) {
if (!player.scrubbing) player.position = latestPlayerPosition;
if (latestPlayerDuration != null) player.duration = latestPlayerDuration;
player.streamReceived = latestStreamReceived;
player.streamTotal = latestStreamTotal;
const current = player.queue[player.queueIndex];
if (
current?.stream
&& latestStreamReceived > 0
&& latestStreamReceived < latestStreamTotal
) {
waveRefreshAt = performance.now();
void loadStreamWaveform(current);
}
}
};
const scheduleSync = () => {
clearTimeout(syncTimer);
syncTimer = setTimeout(sync, 50);
};
void sync();
const addNativeListener = (promise) => {
void promise
.then((unlisten) => {
if (active) nativeUnlisteners.push(unlisten);
else unlisten();
})
.catch(() => {});
};
addNativeListener(api.win.onResized(scheduleSync));
addNativeListener(api.win.onFocusChanged(scheduleSync));
document.addEventListener("visibilitychange", scheduleSync);
if (import.meta.hot) {
import.meta.hot.dispose(() => {
active = false;
clearTimeout(syncTimer);
document.removeEventListener("visibilitychange", scheduleSync);
for (const unlisten of nativeUnlisteners) unlisten();
});
}
}
/** Wire backend events once, at app start. */
export function initEvents() {
if (started) return;
started = true;
initWindowActivity();
// Preview audio-focus bridge (scrollable-track-previews). Injected here
// rather than imported there: this module owns the player store, and the
// preview module must stay import-cycle-free. resumeMain goes straight to
// api — NOT through any wrapper that bumps the revision gateway, because
// a focus-internal resume must not invalidate its own token.
initPreviewBridge({
readPlayer: () => player,
resumeMain: () => {
// Close crossfade: come back from the preview at volume 0 and
// ramp to the user's level while the preview rings down.
api.playerFadeResume(PREVIEW_CROSSFADE_MS);
},
});
// The `connected` event fires once, at login. A WebView reload (dev
// iteration, crash recovery) lands AFTER it, so the fresh frontend
// believed it was offline against a logged-in backend and pushed the
// login modal at the first play attempt. Recover the real state at boot —
// retried, because the first invoke can race IPC readiness at mount.
void (async () => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const status = await api.appStatus();
if (status?.connected_as && app.connection !== "online") {
app.connection = "online";
app.username = status.connected_as;
app.showLogin = false;
}
return;
} catch {
await new Promise((resolve) => setTimeout(resolve, 2500));
}
}
})();
api.onEvent("soulseek:state", (ev) => {
if (ev.kind === "connected") {
app.connection = "online";
app.username = ev.username;
app.loginError = "";
} else if (ev.kind === "disconnected") {
app.connection = "offline";
}
});
api.onEvent("soulseek:excluded-phrases", setExcludedSearchPhrases);
api.onEvent("soulseek:shares", (s) => {
network.shareFolders = s.folders;
network.shareFiles = s.files;
});
api.onEvent("soulseek:upload", (u) => {
network.uploads[u.id] = u;
if (TERMINAL_TRANSFER_STATES.has(u.state)) {
trimTransferHistory(network.uploads, UPLOAD_HISTORY_MAX);
}
});
api.onEvent("soulseek:responded", () => {
network.respondedCount += 1;
});
api.onEvent("soulseek:download", (d) => {
// Preserve last known progress on terminal states that carry none.
const prev = downloads.items[d.id];
if (prev && d.total === 0) {
d.received = prev.received;
d.total = prev.total;
}
downloads.items[d.id] = d;
if (TERMINAL_TRANSFER_STATES.has(d.state)) {
// Preserve every member of an active multi-file burst so the
// title-bar aggregate remains monotonic. Prune once it drains.
trimTransferHistory(downloads.items, DOWNLOAD_HISTORY_MAX, true);
}
});
api.onEvent("soulseek:player", (ev) => {
// Revision-gateway bumps at the ONE choke point every native source
// change flows through. Each new decode ("buffering") is a new
// playback generation for the focus bridge; track-ended is the
// auto-advance revision. A pause caused by the focus coordinator
// emits "paused", which deliberately bumps nothing.
if (ev.kind === "buffering") {
notePlaybackGeneration();
bumpMainRevision("nativeSourceChange");
} else if (ev.kind === "track-ended") {
bumpMainRevision("autoAdvance");
} else if (ev.kind === "stopped") {
bumpMainRevision("userStop");
}
switch (ev.kind) {
case "buffering":
player.status = "buffering";
player.position = 0;
latestPlayerPosition = 0;
latestPlayerDuration = player.duration;
latestStreamReceived = 0;
latestStreamTotal = 0;
player.scrubbing = false;
player.error = "";
break;
case "playing":
player.status = "playing";
break;
case "paused":
player.status = "paused";
listenFlush();
break;
case "stopped":
player.scrubbing = false;
listenFlush();
stopPlayback();
break;
case "track-ended":
listenFlush();
if (ev.gapless) advanceGaplessly();
else nextTrack(true);
break;
case "position":
latestPlayerPosition = ev.secs;
if (ev.duration != null) latestPlayerDuration = ev.duration;
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
if (ev.duration != null && player.duration !== ev.duration) {
player.duration = ev.duration;
}
listenTick(ev.secs);
break;
case "error":
player.status = "idle";
player.error = ev.message;
break;
}
});
api.onEvent("soulseek:stream", (s) => {
latestStreamReceived = s.received;
latestStreamTotal = s.total;
if (ui.windowActive) {
player.streamReceived = s.received;
player.streamTotal = s.total;
}
const cur = player.queue[player.queueIndex];
if (s.done) {
// Fully cached → final waveform, and the peer's slot is
// definitely free, so make sure the next track is pre-buffering.
if (cur?.stream) loadStreamWaveform(cur, true);
preloadNextStream();
tryAppendNext();
} else if (
ui.windowActive
&& cur?.stream
&& performance.now() - waveRefreshAt > 3000
) {
// Still downloading: refresh the partial waveform so it grows
// with the transfer.
waveRefreshAt = performance.now();
loadStreamWaveform(cur);
}
});
// A next-track pre-buffer finished downloading: append it to the audio
// sink so the upcoming transition is gapless.
api.onEvent("soulseek:preload", (p) => {
if (p.done) tryAppendNext();
});
let libraryRefreshTimer = null;
api.onEvent("soulseek:library-changed", () => {
clearTimeout(libraryRefreshTimer);
libraryRefreshTimer = setTimeout(refreshLibrary, 400);
});
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
// The backend batches search responses (~150ms ticks) so a broad search
// can't flood the IPC bridge or trigger a reactive flush per response.
api.onEvent("soulseek:search-results", (batch) => {
let matched = 0;
for (const r of batch) {
if (!search.searching || r.token !== search.token) continue;
matched += 1;
for (const f of r.files) {
const key = `${r.username}|${f.folder}`;
let g = search.groups[key];
let raw = searchFiles.get(key);
if (!g) {
// Cap group count so a popular search can't flood the DOM.
if (searchFiles.size >= 300) continue;
raw = { files: [], exts: new Map() };
searchFiles.set(key, raw);
g = search.groups[key] = {
key,
username: r.username,
folder: f.folder,
folderName: f.folder.split("\\").pop() || r.username,
hasFreeSlot: r.has_free_upload_slot,
speed: r.upload_speed,
queue: r.queue_length,
// Scalar summaries the UI renders; the file list
// itself stays out of reactive state.
fileCount: 0,
audioCount: 0,
totalSize: 0,
topExt: "",
maxBitrate: 0,
};
} else if (!raw) {
// Defensive recovery if a late batch races raw-list
// pruning. Keep the existing scalar summary intact.
raw = { files: [], exts: new Map() };
searchFiles.set(key, raw);
}
if (raw.files.length < 500) {
raw.files.push(f);
g.fileCount += 1;
g.totalSize += f.size;
if (AUDIO_EXTS.has(f.extension)) g.audioCount += 1;
if (f.bitrate && f.bitrate > g.maxBitrate) g.maxBitrate = f.bitrate;
const n = (raw.exts.get(f.extension) ?? 0) + 1;
raw.exts.set(f.extension, n);
if (n >= (raw.exts.get(g.topExt) ?? 0)) g.topExt = f.extension;
}
}
}
if (matched > 0) search.responses += matched;
});
}
export async function connect(username, password, remember) {
app.connection = "connecting";
app.loginError = "";
try {
await api.connectAndLogin(username, password, remember);
app.connection = "online";
app.username = username;
app.showLogin = false;
} catch (e) {
app.connection = "offline";
app.loginError = String(e);
}
}
export async function autoConnect() {
// A reloaded WebView boots against a backend that may ALREADY be logged
// in; connect_stored then fails ("already connected") and the catch below
// used to stamp the page offline — the first play attempt raised the
// login modal against a healthy session. Adopt the live session instead.
try {
const status = await api.appStatus();
if (status?.connected_as) {
app.connection = "online";
app.username = status.connected_as;
app.showLogin = false;
return;
}
} catch {
// status unavailable — fall through to the normal connect path
}
try {
const creds = await api.getCredentials();
if (!creds.has_password) return;
app.connection = "connecting";
await api.connectStored();
app.connection = "online";
app.username = creds.username;
} catch {
// One more status probe before declaring offline: the connect may
// have lost a race with an existing session rather than failed.
try {
const status = await api.appStatus();
if (status?.connected_as) {
app.connection = "online";
app.username = status.connected_as;
return;
}
} catch {}
app.connection = "offline";
}
}
export async function downloadFile(username, file) {
await api.downloadFile(username, file.path, file.size);
}
/** Stream a search-result file: play while it downloads to cache. */
export async function streamFile(username, file, subLabel) {
autoAlbum = null;
player.queue = [
{
path: file.path,
title: file.name.replace(/\.[^.]+$/, ""),
sub: subLabel ?? username,
duration: file.duration ?? null,
art: null,
artUrl: null,
stream: { username, size: file.size },
quality: qualityOf(file),
},
];
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(0);
}
/** Play a finished download from disk (single track). */
export async function playLocalFile(path, title, subLabel, artPath = null) {
bumpMainRevision("userPlay");
autoAlbum = null;
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
player.queueIndex = 0;
player.track = { title, sub: subLabel ?? "", source: "local", path };
player.status = "buffering";
player.duration = null;
player.streamTotal = 0;
loadWaveform(path);
setAccentFromArt(artPath);
await api.playLocal(path, null);
}
/** Play an album/playlist (list of library tracks) starting at `start`.
* `artPath` is the album cover; playlist tracks carry their own art_path. */
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
autoAlbum = null;
player.queue = tracks.map((t) => ({
path: t.path,
title: t.title,
sub: subLabel,
artist: t.artist ?? null,
duration: t.duration_secs ?? null,
art: t.art_path ?? artPath,
quality: localQuality(t),
}));
player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start);
}
function playQueueIndex(i) {
const item = player.queue[i];
if (!item) return;
// Every queue start is a user-command + queue + playback revision for the
// preview focus gateway (auto-advance re-enters here too — that bump is
// correct: a restore must never resurrect a track the queue moved past).
bumpMainRevision("queueJumpOrNextPrev");
player.queueIndex = i;
player.track = {
title: item.title,
sub: item.sub,
// Explicit artist when the queue builder knew it; the play-history
// tracker falls back to parsing "artist — album" out of sub.
artist: item.artist ?? null,
source: item.stream ? "stream" : "local",
path: item.path,
};
player.status = "buffering";
player.duration = item.duration;
if (item.stream) {
// Remote track streamed from a peer while it downloads to cache.
player.streamReceived = 0;
player.streamTotal = item.stream.size;
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
appendedNextPath = null;
setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item); // instant when already fully cached
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
.then(() => {
// Ordered behind play_stream so the gapless append can't
// race the generation bump on the audio thread.
preloadNextStream();
tryAppendNext();
})
.catch((e) => {
player.error = String(e);
});
maybeExtendAlbumQueue();
return;
}
player.streamTotal = 0;
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration).catch((e) => {
player.error = String(e);
});
preloadFollowingTrack();
}
/** Play a specific queue position (Now Playing panel track clicks). */
export function jumpToQueueIndex(i) {
if (i < 0 || i >= player.queue.length || i === player.queueIndex) return;
player.position = 0;
playQueueIndex(i);
}
function gaplessNextIndex() {
if (player.shuffle) return -1;
if (player.repeat === "one") return player.queueIndex;
const next = player.queueIndex + 1;
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
}
function preloadFollowingTrack() {
const next = gaplessNextIndex();
if (next < 0) return;
const item = player.queue[next];
if (item.stream) return; // gapless preload is local-only
api.preloadLocal(item.path, item.duration).catch(() => {});
}
// Quietly pre-buffer the next queued stream into the cache. The backend
// no-ops when it's already cached or being fetched; single-slot peers just
// queue the request until the current transfer finishes.
let lastPreloadedPath = null;
function preloadNextStream() {
const next = gaplessNextIndex();
const item = next >= 0 ? player.queue[next] : null;
if (!item?.stream || item.path === lastPreloadedPath) return;
lastPreloadedPath = item.path;
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {});
}
// Once the next queued stream is fully cached, append it to the active
// audio sink so the boundary is gapless (same mechanism as local albums).
// Keyed per target path so it fires once per upcoming transition.
let appendedNextPath = null;
async function tryAppendNext() {
const next = gaplessNextIndex();
const item = next >= 0 ? player.queue[next] : null;
if (!item?.stream || item.path === appendedNextPath) return;
if (player.status === "idle") return;
try {
const ok = await api.preloadCachedStream(
item.stream.username,
item.path,
item.stream.size,
item.duration ?? null,
);
if (ok) appendedNextPath = item.path;
} catch {
// cache incomplete or command unavailable — the normal (non-gapless)
// advance still works.
}
}
function advanceGaplessly() {
const next = gaplessNextIndex();
// The backend only labels a boundary gapless when it has this source
// ready in the same sink. If state changed meanwhile, fall back safely.
if (next < 0) return nextTrack(true);
const item = player.queue[next];
player.queueIndex = next;
player.position = 0;
player.duration = item.duration;
latestPlayerPosition = 0;
latestPlayerDuration = item.duration ?? null;
latestStreamReceived = 0;
latestStreamTotal = 0;
player.status = "playing";
appendedNextPath = null;
if (item.stream) {
// A pre-buffered stream track, playing from its completed cache
// file — no transfer to report.
player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path };
player.streamReceived = 0;
player.streamTotal = 0;
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item);
preloadNextStream();
tryAppendNext();
maybeExtendAlbumQueue();
return;
}
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
loadWaveform(item.path);
setAccentFromArt(item.art ?? null);
preloadFollowingTrack();
}
// ---- album continuation ----
// A single catalog song remembers its album: when it starts playing, the
// album's next track is resolved (background Soulseek search) and appended
// to the queue, one track per track start, so the album keeps going.
let autoAlbum = null; // { albumId, albumTitle, cover, tracks, pending }
async function maybeExtendAlbumQueue() {
const ctx = autoAlbum;
if (!ctx || ctx.pending) return;
if (player.queueIndex !== player.queue.length - 1) return; // next already queued
const cur = player.queue[player.queueIndex];
if (!cur?.stream) return;
ctx.pending = true;
try {
if (!ctx.tracks) {
const page = await api.catalogAlbum(ctx.albumId);
if (autoAlbum !== ctx) return;
ctx.tracks = page.tracks;
ctx.albumTitle ??= page.album.title ?? null;
ctx.cover ??= page.album.cover ?? null;
}
let idx = ctx.tracks.findIndex((t) => t.id === cur.trackId);
if (idx < 0) idx = ctx.tracks.findIndex((t) => normText(t.title) === normText(cur.title));
if (idx < 0) {
autoAlbum = null;
return;
}
for (let i = idx + 1; i < ctx.tracks.length; i++) {
const t = ctx.tracks[i];
if (app.connection !== "online") return;
// Same sanitized ladder as resolve(), inlined because this is a
// background append that must not toggle resolver.active (that
// would block user-triggered resolutions while it searches).
const goodCount = (rs) =>
songCandidates(rs, t.title, true, ctx.albumTitle, t.duration_secs, t.artist).length;
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
let artistIncluded = true;
let responses = primary ? await collectSources(primary, 7000, goodCount) : [];
if (!responses.length && fallback && fallback !== primary) {
responses = await collectSources(fallback, 7000, goodCount);
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
} else if (primary) {
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
}
// Bail if the user moved on while we were searching.
if (autoAlbum !== ctx || player.queue[player.queueIndex] !== cur) return;
const best = songCandidates(
responses, t.title, true, ctx.albumTitle, t.duration_secs, t.artist, !artistIncluded,
)[0];
if (!best) continue; // no streamable source — skip this track
player.queue.push({
path: best.file.path,
title: t.title,
sub: t.artist,
artist: t.artist ?? null,
album: ctx.albumTitle ?? null,
duration: best.file.duration ?? t.duration_secs ?? null,
art: null,
artUrl: ctx.cover,
trackId: t.id,
stream: { username: best.username, size: best.file.size },
quality: qualityOf(best.file),
});
preloadNextStream();
tryAppendNext();
return;
}
autoAlbum = null; // album finished
} catch {
// transient (offline, catalog hiccup) — retried on the next track start
} finally {
ctx.pending = false;
}
}
// ---- waveform ----
const WAVE_BUCKETS = 400;
let waveReqPath = null;
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
let waveRefreshAt = 0;
async function loadWaveform(path) {
player.waveform = [];
waveReqPath = path;
if (!path) return;
try {
const peaks = await api.waveform(path, WAVE_BUCKETS);
// Ignore if the track changed while we were computing.
if (waveReqPath === path) player.waveform = peaks;
} catch {
if (waveReqPath === path) player.waveform = [];
}
}
/** Waveform for a streamed queue item, computed from its cache file. Empty
* (plain progress bar) until the download completes; the stream-done event
* calls this again to swap the full waveform in mid-track. */
let waveSeq = 0;
let streamWaveInFlight = false;
let queuedStreamWave = null;
async function loadStreamWaveform(item, final = false) {
const key = `stream:${item.stream.username}|${item.path}`;
if (waveReqPath !== key) player.waveform = [];
waveReqPath = key;
// A late partial FLAC decode can take close to the three-second refresh
// interval. Coalesce all overlap into the newest request, retaining a
// final completion request so the full-quality waveform always wins.
if (streamWaveInFlight) {
const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final;
queuedStreamWave = { item, key, final: final || preserveFinal };
return;
}
streamWaveInFlight = true;
let next = { item, key, final };
try {
while (next) {
const request = next;
next = null;
const seq = ++waveSeq;
try {
const peaks = await api.streamWaveform(
request.item.stream.username,
request.item.path,
request.item.stream.size,
WAVE_BUCKETS,
);
if (
waveReqPath === request.key
&& seq === waveSeq
&& peaks.length > 0
) {
player.waveform = peaks;
}
} catch {
// stays empty — the buffer/progress bar renders instead
}
const queued = queuedStreamWave;
queuedStreamWave = null;
// Only the newest still-visible track matters. A queued final call
// is never dropped; the backend independently confirms completion.
if (queued && waveReqPath === queued.key) next = queued;
}
} finally {
streamWaveInFlight = false;
}
}
// ---- queue navigation, shuffle, repeat ----
// Indices already played this shuffle cycle (module-private).
let shuffleSeen = new Set();
function pickShuffleIndex(reset = false) {
const n = player.queue.length;
if (reset) shuffleSeen = new Set();
const remaining = [];
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
if (remaining.length === 0) return -1;
return remaining[Math.floor(Math.random() * remaining.length)];
}
export function nextTrack(auto = false) {
const n = player.queue.length;
if (n === 0) {
stopPlayback();
return;
}
// Auto-advance on a repeat-one track replays it.
if (auto && player.repeat === "one") {
playQueueIndex(player.queueIndex);
return;
}
let next;
if (player.shuffle) {
shuffleSeen.add(player.queueIndex);
next = pickShuffleIndex();
if (next < 0) {
if (player.repeat === "all") next = pickShuffleIndex(true);
else return stopPlayback();
}
} else {
next = player.queueIndex + 1;
if (next >= n) {
if (player.repeat === "all") next = 0;
else return stopPlayback();
}
}
playQueueIndex(next);
}
export function prevTrack() {
// Restart the current track if we're more than 3s in.
if (player.position > 3) {
api.playerControl("seek", 0);
return;
}
const prev = player.queueIndex - 1;
if (prev >= 0) playQueueIndex(prev);
else api.playerControl("seek", 0);
}
export function toggleShuffle() {
player.shuffle = !player.shuffle;
shuffleSeen = new Set([player.queueIndex]);
}
export function cycleRepeat() {
player.repeat =
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
}
function stopPlayback() {
player.status = "idle";
player.track = null;
player.position = 0;
player.duration = null;
player.queue = [];
player.queueIndex = -1;
player.waveform = [];
player.catalogRef = { albumId: null, trackId: null };
latestPlayerPosition = 0;
latestPlayerDuration = null;
latestStreamReceived = 0;
latestStreamTotal = 0;
autoAlbum = null;
appendedNextPath = null;
lastPreloadedPath = null;
setAccentFromArt(null);
}
export async function downloadFolder(group) {
for (const f of filesForGroup(group.key)) {
await api.downloadFile(group.username, f.path, f.size);
}
}
// Dev-only handle to THIS module instance for CDP-driven verification —
// a dynamic import from the console can resolve to a second instance when
// Vite has HMR-stamped the app's own import URL.
if (import.meta.env.DEV && typeof window !== "undefined") {
window.__state = {
app, player, resolver, catalog, ui, discovery, downloads, libraryStore,
streamSong, streamCatalogAlbum, autoConnect, nextTrack, prevTrack, loadDiscovery,
saveStream, refreshLibrary, openCatalogArtist, openCatalogAlbum,
playAlbum, jumpToQueueIndex, toggleNowPlaying,
artBroken, markArtBroken,
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
};
}
export async function runSearch(query) {
const request = ++searchRequest;
clearTimeout(searchStopTimer);
const previousToken = search.token;
// Invalidate routing before awaiting the new backend token, otherwise
// late batches from the previous search can populate the freshly-cleared
// result set during this gap.
search.token = null;
if (previousToken != null) {
api.stopSearch(previousToken).catch(() => {});
}
search.query = query;
search.groups = {};
searchFiles.clear();
search.responses = 0;
search.searching = true;
const token = await api.startSearch(query);
if (request !== searchRequest) {
api.stopSearch(token).catch(() => {});
return;
}
search.token = token;
// Soulseek searches trickle in; stop routing after 45s.
searchStopTimer = setTimeout(() => {
if (search.token === token) {
search.searching = false;
// The UI exposes only the top 120 groups. Keep their full file
// lists and discard invisible raw payloads after routing closes;
// scalar summaries remain available for the result count.
trimHiddenSearchFiles();
api.stopSearch(token).catch(() => {});
}
}, 45_000);
}
// Dev-only: expose the LIVE module instance for the CDP verification loop.
// Vite's HMR graph can serve a probe's bare-URL dynamic import as a SECOND
// instance of this module (?t=-stamped vs bare), so a probe that imports by
// URL may read/mutate a phantom copy while the app runs another. Probes must
// use this global instead of importing.
if (import.meta.env.DEV) {
globalThis.__soulstream = {
app, ui, player, catalog, resolver,
streamCatalogAlbum, streamSong, openCatalogArtist,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
};
}