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