forked from Keelrleek/SoulStream
The page is the signed-off prototype built out: floating rounded hero, inline top-tracks panel, filter chips, two-column Songs|Albums beside Artists, and all seven states — results, searching, no results, one-kind-only, library-empty, provider-unreachable, first visit. MusicBrainz records are merged in silently behind the Deezer rows. The top result is ranked by POPULARITY, not by kind: each kind puts up its most popular exact match and the winner is whichever holds the largest share of its own kind's leader. A raw fans-vs-rank comparison was tried first and handed "panda bear" to an Owl City song. Two provenance traps are paid for here. Deezer's /search/album returns no release_date at all, so sorting newest-first ordered by PROVENANCE rather than by date; dates are backfilled from the artist overview, resolved PER ALBUM ARTIST because Deezer's artist search answers nothing for "weezer blue album". And the typeahead holds its keyboard selection as the row's KEY rather than its index, because the MusicBrainz supplement lands ~600ms later and re-orders the album group under the cursor. The hero's middle third is hidden on the width of the PANE, not the window. The sidebar and the now-playing rail take ~490px between them, so a 1504px window can leave the hero 1012px — a window-width media query cannot see that, and the "latest release" card ended up sheared down to 26px of itself. The budget is written out at the @container rule. Session restore rounds it off: reload or restart returns to the same place with the same track, everything but the transport coming from localStorage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5894 lines
247 KiB
JavaScript
5894 lines
247 KiB
JavaScript
// 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 <img> elements retain their own src,
|
||
* so dropping a cold cache reference cannot degrade an already-painted image. */
|
||
function trimResolvedCache(cache, maxEntries, maxChars) {
|
||
let entries = 0;
|
||
let chars = 0;
|
||
for (const value of cache.values()) {
|
||
if (isPending(value)) continue;
|
||
entries += 1;
|
||
chars += cacheValueChars(value);
|
||
}
|
||
if (entries <= maxEntries && chars <= maxChars) return;
|
||
for (const [key, value] of cache) {
|
||
if (isPending(value)) continue;
|
||
cache.delete(key);
|
||
entries -= 1;
|
||
chars -= cacheValueChars(value);
|
||
if (entries <= maxEntries && chars <= maxChars) break;
|
||
}
|
||
}
|
||
|
||
function setResolvedCache(cache, key, value, maxEntries, maxChars) {
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
trimResolvedCache(cache, maxEntries, maxChars);
|
||
}
|
||
|
||
function setBoundedMap(cache, key, value, maxEntries) {
|
||
cache.delete(key);
|
||
cache.set(key, value);
|
||
while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
|
||
}
|
||
|
||
// path -> data URL, bounded so browsing a large library cannot retain every
|
||
// base64 cover for the entire WebView lifetime.
|
||
const artCache = new Map();
|
||
const ART_CACHE_MAX = 128;
|
||
const ART_CACHE_MAX_CHARS = 32 * MIB;
|
||
|
||
export function evictArt(path) {
|
||
artCache.delete(path);
|
||
}
|
||
|
||
export async function getArt(path) {
|
||
if (!path) return null;
|
||
if (artCache.has(path)) return touchCache(artCache, path);
|
||
// PlayerBar, NowPlaying, and accent extraction can all request the same
|
||
// cover during one track change. Cache the in-flight promise so one native
|
||
// read/base64 conversion satisfies every consumer.
|
||
const pending = api.albumArt(path).then(
|
||
(url) => {
|
||
if (artCache.get(path) === pending) {
|
||
setResolvedCache(
|
||
artCache,
|
||
path,
|
||
url,
|
||
ART_CACHE_MAX,
|
||
ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return url;
|
||
},
|
||
() => {
|
||
if (artCache.get(path) === pending) {
|
||
setResolvedCache(
|
||
artCache,
|
||
path,
|
||
null,
|
||
ART_CACHE_MAX,
|
||
ART_CACHE_MAX_CHARS,
|
||
);
|
||
}
|
||
return null;
|
||
},
|
||
);
|
||
artCache.set(path, pending);
|
||
return pending;
|
||
}
|
||
|
||
// Catalog cover URLs → data URLs, proxied through Rust (no CORS on CDNs).
|
||
const catalogArtCache = new Map();
|
||
const CATALOG_ART_CACHE_MAX = 160;
|
||
const CATALOG_ART_CACHE_MAX_CHARS = 32 * MIB;
|
||
|
||
/** 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 <img>. A short miss window keeps the working original
|
||
// in place while still allowing transient CDN failures to retry later.
|
||
const requestKey = producer?.key ?? coverUpgradeRequestKey(artistId, originalCover);
|
||
coverUpgradeResults.delete(requestKey);
|
||
if (coverUpgradeContexts.get(originalCover)?.key === requestKey) {
|
||
coverUpgradeContexts.delete(originalCover);
|
||
}
|
||
rememberCoverUpgradeMiss(requestKey);
|
||
const cacheArtistId = producer?.artistId ?? artistId;
|
||
const cacheArtist = producer?.artist ?? artist;
|
||
const cacheTitle = producer?.title ?? title;
|
||
if (cacheArtist && cacheTitle) {
|
||
void api
|
||
.invalidateCatalogCover(
|
||
cacheArtistId,
|
||
cacheArtist,
|
||
cacheTitle,
|
||
originalCover,
|
||
)
|
||
.catch(() => {});
|
||
}
|
||
return originalCover;
|
||
} else {
|
||
// A card intentionally requests a 500px CDN derivative. If only that
|
||
// resized route failed, retry the known-good canonical source before
|
||
// declaring the album itself broken.
|
||
if (failedUrl) {
|
||
try {
|
||
if (new URL(failedUrl).href !== new URL(originalCover).href) {
|
||
return originalCover;
|
||
}
|
||
} catch {
|
||
if (failedUrl !== originalCover) return originalCover;
|
||
}
|
||
}
|
||
markArtBroken(originalCover);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cover URL to display for a currently-playing catalog item — 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<Set<normTitle>>
|
||
|
||
/** 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<seed|null>
|
||
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
|
||
* "<name> musician band" with no verification, which is how the panel came
|
||
* to describe Mr. Whitekeys under Camping in Alaska and Crazy Horse under
|
||
* Crazy P. It also bypassed the app's Wikimedia UA/pacer/cache. Needs the
|
||
* catalog id to anchor identity, so it runs after the id resolves. */
|
||
async function fetchArtistBio(artistId, name) {
|
||
try {
|
||
return (await api.catalogArtistBio(artistId, name)) ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
}
|
||
void refreshSourcePrefs();
|
||
|
||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
|
||
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||
* benefit of the doubt when the user asked for a minimum. Thresholds sit
|
||
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
|
||
function passesSourcePrefs(f, prefs) {
|
||
const lossless = LOSSLESS_EXTS.has(f.extension);
|
||
if (prefs.max === "lossy_only" && lossless) return false;
|
||
switch (prefs.min) {
|
||
case "lossless":
|
||
if (!lossless) return false;
|
||
break;
|
||
case "kbps320":
|
||
if (!lossless && (f.bitrate ?? 0) < 315) return false;
|
||
break;
|
||
case "kbps256":
|
||
if (!lossless && (f.bitrate ?? 0) < 250) return false;
|
||
break;
|
||
case "kbps192":
|
||
if (!lossless && (f.bitrate ?? 0) < 186) return false;
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
|
||
* overwhelmingly name files unaccented; without folding, normText simply
|
||
* DELETED accented letters ("Jóga" → "jga") and the request could never
|
||
* match the file. */
|
||
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
|
||
|
||
const normText = (s) =>
|
||
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
|
||
|
||
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
|
||
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
|
||
* with just the primary segment, capped to a few words. */
|
||
function simplifyTitle(s) {
|
||
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
|
||
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
|
||
return words || s || "";
|
||
}
|
||
|
||
// Words that mark an *edition* of a release, not a distinct release. Used to
|
||
// tell an edition suffix (noise, safe to drop) from a variant name that names
|
||
// a different album — critical for self-titled records where the base title
|
||
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
|
||
const ALBUM_EDITION_RE =
|
||
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
|
||
|
||
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
|
||
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
|
||
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
|
||
* only hold edition noise (or there are none). */
|
||
export function albumDisambiguator(title) {
|
||
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
|
||
for (const inner of groups) {
|
||
for (const part of inner.split(/\s*[/|;]\s*/)) {
|
||
const p = part.trim();
|
||
if (p && !ALBUM_EDITION_RE.test(p)) return p;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/** Search-query title-part for a release: the simplified title, plus the
|
||
* disambiguating variant name when there is one, so distinct self-titled
|
||
* albums search for their own colour instead of colliding on the band name. */
|
||
function albumQueryTitle(album) {
|
||
const base = simplifyTitle(album.title);
|
||
// Fall back to a caller-supplied variant (the family's colour) when the
|
||
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
|
||
// but names no colour, so without this it searches bare "Weezer".
|
||
const d = albumDisambiguator(album.title) || album.variant || "";
|
||
return d ? `${base} ${d}` : base;
|
||
}
|
||
|
||
// Server-distributed excluded search phrases (Soulseek message 160). A query
|
||
// containing one is NEVER routed by the server — it returns zero responses
|
||
// network-wide with no error. Verified live: every query containing "mgmt"
|
||
// was silent while "little dark age" alone returned 738 responses. The list
|
||
// arrives by event after login and is also fetchable on demand (covers a
|
||
// WebView reload); the resolver strips these terms before they reach the wire.
|
||
let excludedSearchPhrases = [];
|
||
export function setExcludedSearchPhrases(list) {
|
||
excludedSearchPhrases = (Array.isArray(list) ? list : [])
|
||
.map((p) => String(p ?? "").toLowerCase().trim())
|
||
.filter(Boolean);
|
||
}
|
||
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
|
||
|
||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||
function sanitizeSearchQuery(query) {
|
||
let out = ` ${query ?? ""} `;
|
||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||
}
|
||
return out.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
/** Run a background source search (own token — the Search view is untouched)
|
||
* and collect responses. Ends early once `goodCount()` finds enough.
|
||
*
|
||
* The early exit is evaluated on a 250ms TICK, not only when a batch
|
||
* arrives: peers deliver most results within the first second and then go
|
||
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
|
||
* still sat out the full window waiting for one more batch that never came
|
||
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
|
||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||
* a workable set at 1800ms, and the full window only runs when results
|
||
* are genuinely sparse. */
|
||
async function collectSources(query, maxMs, goodCount) {
|
||
const token = await api.startSearch(query);
|
||
const responses = [];
|
||
let unlisten = null;
|
||
await new Promise((resolve) => {
|
||
const started = performance.now();
|
||
const timer = setTimeout(() => {
|
||
clearInterval(tick);
|
||
resolve();
|
||
}, maxMs);
|
||
const tick = setInterval(() => {
|
||
if (!goodCount) return;
|
||
const elapsed = performance.now() - started;
|
||
const good = goodCount(responses);
|
||
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
|
||
clearTimeout(timer);
|
||
clearInterval(tick);
|
||
resolve();
|
||
}
|
||
}, 250);
|
||
unlisten = api.onEvent("soulseek:search-results", (batch) => {
|
||
for (const r of batch) if (r.token === token) responses.push(r);
|
||
});
|
||
});
|
||
unlisten?.();
|
||
api.stopSearch(token).catch(() => {});
|
||
return responses;
|
||
}
|
||
|
||
/** Queries measured silent (zero responses) recently — skipped for a while
|
||
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
|
||
* every album/track of the same artist. */
|
||
const silentQueries = new Map();
|
||
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
|
||
function querySilent(q) {
|
||
const at = silentQueries.get(q);
|
||
if (at == null) return false;
|
||
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
|
||
silentQueries.delete(q);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Terms PROVEN poisoned this session: a rung containing the term was silent
|
||
// while a term-less rung answered — the network is fine, the term is dead
|
||
// (the "mgmt" case; the server's own excluded list does not admit it).
|
||
// Applied exactly like server-excluded phrases, so the second album by the
|
||
// same artist never pays the discovery timeout again.
|
||
const learnedPoisonedTerms = new Set();
|
||
|
||
/** Estimated seconds until this peer's file has fully arrived: transfer time
|
||
* from the file's size and the peer's advertised upload speed (bytes/sec),
|
||
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
|
||
* peer hides its numbers — an unknown speed must not look like a fast one. */
|
||
function estimatedTransferSecs(r, file) {
|
||
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
|
||
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
|
||
let secs = size / speed;
|
||
if (!r.has_free_upload_slot) secs += 90;
|
||
secs += Math.min(r.queue_length ?? 0, 50) * 45;
|
||
return secs;
|
||
}
|
||
|
||
function scoreSource(r, file, prefs = null) {
|
||
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
|
||
if (!q) return -1;
|
||
const bitrate = file.bitrate;
|
||
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
|
||
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
|
||
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
|
||
// only reorders sources that already passed the user's min/max quality
|
||
// window (passesSourcePrefs runs before scoring), so "fastest" can never
|
||
// dip below the configured floor. Format/bitrate remain as tie-breakers.
|
||
if (prefs?.priority === "speed") {
|
||
// Positive baseline: callers treat score <= 0 as "reject this
|
||
// format", and a slow, deep-queued peer must rank last, not vanish.
|
||
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
|
||
let s = 100_000 - eta * 25;
|
||
s += q * 50;
|
||
if (bitrate) s += Math.min(bitrate, 1500) / 50;
|
||
return s;
|
||
}
|
||
let s = q * 1000;
|
||
if (bitrate) s += Math.min(bitrate, 1500) / 10;
|
||
if (r.has_free_upload_slot) s += 600;
|
||
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
|
||
s -= Math.min(r.queue_length ?? 0, 50) * 12;
|
||
return s;
|
||
}
|
||
|
||
function requireOnline() {
|
||
if (app.connection !== "online") {
|
||
app.showLogin = true;
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
|
||
// remixes, demos, covers, radio edits… `normText` strips brackets, so
|
||
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
|
||
// these are tested against the RAW filename + folder to catch that. Matching
|
||
// is symmetric: a candidate must carry exactly the markers the requested
|
||
// title carries — no live file for a studio request, no studio file for an
|
||
// explicitly "(Live)" catalog track.
|
||
const TRACK_VARIANT_MARKERS = [
|
||
/\blive\b/i,
|
||
/\bunplugged\b/i,
|
||
/\bacoustic\b/i,
|
||
/\bremix(?:es|ed)?\b/i,
|
||
/\brmx\b/i,
|
||
/\brework(?:ed)?\b/i,
|
||
/\bdemo(?:s)?\b/i,
|
||
/\binstrumental\b/i,
|
||
/\bkaraoke\b/i,
|
||
/\bcover(?:s|ed)?\b/i,
|
||
/\btribute\b/i,
|
||
/\b(?:radio|single)\s+edit\b/i,
|
||
/\b(?:club|extended|dub|dance)\s+mix\b/i,
|
||
/\bmedley\b/i,
|
||
/\bmashup\b/i,
|
||
/\bsession(?:s)?\b/i,
|
||
/\brehearsal\b/i,
|
||
/\bouttake(?:s)?\b/i,
|
||
/\balternate\b/i,
|
||
/\btake\s+\d+\b/i,
|
||
/\bre-?recorded\b/i,
|
||
// "<Name>'s Version" re-records (Taylor's Version and kin): a different
|
||
// recording of the song, matched symmetrically like every marker here —
|
||
// found live by the source audit when a request for "22" top-ranked
|
||
// "22 (Taylor's Version)".
|
||
/\b\w+['’]s\s+version\b/i,
|
||
/\bsped\s+up\b/i,
|
||
/\bslowed\b/i,
|
||
/\b8[\s-]?bit\b/i,
|
||
];
|
||
// Checked against the filename only: "Reprise" is also a record label
|
||
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
|
||
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
|
||
|
||
/** True when the candidate names a different version of the song than the
|
||
* request asked for (either direction). */
|
||
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
|
||
const cand = `${candidateFolder}\\${candidateName}`;
|
||
for (const re of TRACK_VARIANT_MARKERS) {
|
||
if (re.test(cand) !== re.test(wantedTitle)) return true;
|
||
}
|
||
for (const re of TRACK_VARIANT_NAME_ONLY) {
|
||
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** A peer file's playtime is the wrong recording when it's far from the
|
||
* catalog's known length — most often a ripped 30-second streaming *preview*
|
||
* whose filename matches the real track (e.g. a 30s "Long Season" for a
|
||
* 41-minute live track). Only judges when BOTH durations are known; the band
|
||
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
|
||
* silence never trip it. */
|
||
function durationMismatch(expectSecs, candSecs) {
|
||
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
|
||
const tol = Math.max(45, expectSecs * 0.2);
|
||
return Math.abs(candSecs - expectSecs) > tol;
|
||
}
|
||
|
||
/** Ranked candidate files for a song across all responses (best first).
|
||
* `album` (title string, optional) biases toward folders of the release the
|
||
* user is actually looking at. `expectSecs` (optional) is the catalog track's
|
||
* known length, used to drop preview clips / wrong-length rips. */
|
||
function songCandidates(
|
||
responses,
|
||
title,
|
||
streamOnly = false,
|
||
album = null,
|
||
expectSecs = null,
|
||
artist = null,
|
||
requireIdentityEvidence = false,
|
||
) {
|
||
const want = normText(title);
|
||
const wantedRaw = title ?? "";
|
||
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
|
||
const artistKey = artist ? normText(artist) : "";
|
||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||
const out = [];
|
||
for (const r of responses) {
|
||
for (const f of r.files) {
|
||
if (!normText(f.name).includes(want)) continue;
|
||
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
|
||
if (!passesSourcePrefs(f, prefs)) continue;
|
||
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
|
||
if (durationMismatch(expectSecs, f.duration)) continue;
|
||
let s = scoreSource(r, f, prefs);
|
||
if (s <= 0) continue;
|
||
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
|
||
const artistEvidence =
|
||
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
|
||
// A rung that dropped the artist term (server-excluded phrase)
|
||
// matches by title alone, which happily returns other artists'
|
||
// covers of the song. The peer's PATH must then prove identity:
|
||
// the artist somewhere in the folder/filename, or the exact
|
||
// release folder.
|
||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||
continue;
|
||
}
|
||
// The requested release's own folder beats compilation/misc rips
|
||
// (below a format-tier jump, so quality still wins).
|
||
if (albumEvidence) s += 800;
|
||
if (artistEvidence) s += 400;
|
||
// Filename ends with the title → it IS this song, not a longer
|
||
// title containing it ("Holiday" vs "Holiday in Cambodia").
|
||
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
|
||
out.push({ username: r.username, file: f, score: s });
|
||
}
|
||
}
|
||
out.sort((a, b) => b.score - a.score);
|
||
return out;
|
||
}
|
||
|
||
/** Exposed for the source-resolution audit harness (session-scratchpad CDP
|
||
* probes drive the REAL matching/scoring code against live search responses).
|
||
* Not part of the app surface. */
|
||
export const _sourceAudit = {
|
||
songCandidates,
|
||
albumCandidates,
|
||
sanitizeSearchQuery,
|
||
collectSources,
|
||
variantMismatch,
|
||
durationMismatch,
|
||
asciiFold,
|
||
normText,
|
||
simplifyTitle,
|
||
stopPlayback,
|
||
};
|
||
|
||
/** Resolves true when the stream is audibly playing; false on decode/peer
|
||
* failure (status drops to idle) or when nothing plays within `ms`. */
|
||
function waitForStreamStart(ms) {
|
||
return new Promise((resolve) => {
|
||
const t0 = performance.now();
|
||
const iv = setInterval(() => {
|
||
if (player.status === "playing") {
|
||
clearInterval(iv);
|
||
resolve(true);
|
||
} else if (performance.now() - t0 > ms || player.status === "idle") {
|
||
clearInterval(iv);
|
||
resolve(false);
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
/** Ranked candidate folders for an album (best first). `trackCountOverride`
|
||
* is the authoritative track count from the catalog tracklist fetch; prefer it
|
||
* over `album.track_count`, which is routinely null when Play is pressed on the
|
||
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
|
||
function albumCandidates(
|
||
responses,
|
||
album,
|
||
streamOnly = false,
|
||
trackCountOverride = null,
|
||
requireIdentityEvidence = false,
|
||
) {
|
||
const artistKey = normText(album.artist ?? "");
|
||
const wantAlbum = normText(simplifyTitle(album.title));
|
||
const wanted = trackCountOverride ?? album.track_count ?? null;
|
||
const year = album.release_date?.slice(0, 4) || null;
|
||
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
|
||
// colour word, so we can prefer the right folder and reject a wrong-colour
|
||
// one that a fuzzy peer search returns anyway.
|
||
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
|
||
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
|
||
const folders = new Map();
|
||
for (const r of responses) {
|
||
for (const f of r.files) {
|
||
const key = `${r.username}|${f.folder}`;
|
||
let g = folders.get(key);
|
||
if (!g) {
|
||
g = { username: r.username, folder: f.folder, files: [], r };
|
||
folders.set(key, g);
|
||
}
|
||
g.files.push(f);
|
||
}
|
||
}
|
||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||
const out = [];
|
||
for (const g of folders.values()) {
|
||
const audio = g.files.filter(
|
||
(f) =>
|
||
(streamOnly
|
||
? RESOLVER_STREAMABLE.has(f.extension)
|
||
: RESOLVER_AUDIO_QUALITY[f.extension])
|
||
&& passesSourcePrefs(f, prefs),
|
||
);
|
||
// Require enough streamable files to plausibly be this release, but
|
||
// NEVER default an unknown count to 3 — that silently rejected every
|
||
// single/2-track release (whose peer folders hold 1–2 matching files)
|
||
// and is exactly why "No streamable album sources found" fired for
|
||
// real, obviously-streamable singles like ICARUS. Known count → at most
|
||
// 3, floor 1; unknown → just 1.
|
||
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
|
||
if (audio.length < minAudio) continue;
|
||
const top = audio
|
||
.map((f) => scoreSource(g.r, f, prefs))
|
||
.reduce((a, b) => a + b, 0) / audio.length;
|
||
let s = top;
|
||
const folderNorm = normText(g.folder);
|
||
const albumEvidence = folderNorm.includes(wantAlbum);
|
||
const artistEvidence = artistKey && folderNorm.includes(artistKey);
|
||
// Artist-less query rung (server-excluded phrase): the folder path
|
||
// must prove the release's identity — see songCandidates.
|
||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||
continue;
|
||
}
|
||
if (albumEvidence) s += 400;
|
||
if (artistEvidence) s += 300;
|
||
// Prefer folders whose track count matches the release.
|
||
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
|
||
// The release year in the folder path is a strong per-release signal
|
||
// (distinguishes reissues and same-named albums).
|
||
if (year && g.folder.includes(year)) s += 300;
|
||
// Self-titled colour disambiguation: reward the matching "(X Album)"
|
||
// folder, and strongly reject a folder that clearly names a different
|
||
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
|
||
if (wantColour) {
|
||
const folderColours = [
|
||
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
|
||
].map((m) => m[1]);
|
||
if (folderColours.includes(wantColour)) s += 500;
|
||
else if (folderColours.length) s -= 900;
|
||
}
|
||
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||
out.push({ username: g.username, folder: g.folder, files: g.files, audio, score: s });
|
||
}
|
||
out.sort((a, b) => b.score - a.score);
|
||
return out;
|
||
}
|
||
|
||
/** Walk a LADDER of query shapes until one returns responses. Each rung is
|
||
* sanitized against the server's excluded-phrase list first (a poisoned term
|
||
* silences the whole query), deduplicated, and dropped when sanitizing left
|
||
* nothing. Returns `{ responses, artistIncluded }` — `artistIncluded` is
|
||
* whether the rung that produced the responses still contained the artist
|
||
* term, so candidate filters know to demand artist/album evidence in the
|
||
* file path instead (a title-only query happily returns covers and karaoke).
|
||
*
|
||
* `queries` may be a string (single rung) or an array, best-first. */
|
||
async function resolve(label, queries, goodCount, artistTerm = null) {
|
||
resolver.active = true;
|
||
resolver.label = label;
|
||
resolver.error = "";
|
||
player.error = "";
|
||
try {
|
||
const artistKey = artistTerm ? normText(artistTerm) : "";
|
||
const seen = new Set();
|
||
const ladder = [];
|
||
for (const raw of [].concat(queries).filter(Boolean)) {
|
||
// Each shape also gets an ASCII-folded rung: peers name files
|
||
// unaccented, and the server AND-matches query tokens, so
|
||
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
|
||
for (const variant of [raw, asciiFold(raw)]) {
|
||
const q = sanitizeSearchQuery(variant);
|
||
if (!q || seen.has(q)) continue;
|
||
seen.add(q);
|
||
ladder.push(q);
|
||
}
|
||
}
|
||
// Non-final rungs get a short window: a rung the network answers at
|
||
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
|
||
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
|
||
// outright. The post-login warm-up retry moved BEHIND the ladder: if
|
||
// ANY rung answered, connectivity is proven and no retry is owed —
|
||
// the old first-rung retry burned 7 extra seconds on every poisoned
|
||
// artist query before the fallback rung even ran.
|
||
let sawSilentArtistRung = false;
|
||
for (let index = 0; index < ladder.length; index++) {
|
||
const q = ladder[index];
|
||
if (querySilent(q)) continue;
|
||
const final = index === ladder.length - 1;
|
||
const hasArtist = artistKey && normText(q).includes(artistKey);
|
||
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
|
||
if (responses.length) {
|
||
// An answered term-less rung after a silent artist rung PROVES
|
||
// the artist term is what kills the query — learn it, so the
|
||
// artist's other releases skip the dead rung entirely.
|
||
if (sawSilentArtistRung && !hasArtist && artistTerm) {
|
||
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
|
||
}
|
||
return {
|
||
responses,
|
||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||
};
|
||
}
|
||
if (hasArtist) sawSilentArtistRung = true;
|
||
silentQueries.set(q, performance.now());
|
||
}
|
||
// Whole ladder silent: either the terms are all poisoned or we are in
|
||
// the post-login window where the server has not attached us to a
|
||
// search parent yet. One quiet retry of the primary rung covers the
|
||
// latter (and clears its silent mark on success).
|
||
if (ladder.length) {
|
||
const q = ladder[0];
|
||
const responses = await collectSources(q, 7000, goodCount);
|
||
if (responses.length) {
|
||
silentQueries.delete(q);
|
||
return {
|
||
responses,
|
||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||
};
|
||
}
|
||
}
|
||
return { responses: [], artistIncluded: true };
|
||
} finally {
|
||
resolver.active = false;
|
||
resolver.label = "";
|
||
}
|
||
}
|
||
|
||
// ---- library-first playback ----
|
||
// Clicking play on a catalog album/song that's already saved plays the
|
||
// local library copy instead of sourcing a stream from a peer.
|
||
|
||
/** Library album matching a catalog release (same normalization as the
|
||
* artist page's ✓ badges, plus the simplified-title variant so verbose
|
||
* catalog editions still match). */
|
||
function libraryAlbumFor(artist, title, releaseDate = null) {
|
||
const a = normText(artist);
|
||
const t1 = normText(title);
|
||
if (!a || !t1) return null;
|
||
// normText strips ALL bracket content, so base-title comparison alone
|
||
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
|
||
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
|
||
// a library copy saved under the bare provider title "Weezer". After the
|
||
// base titles agree, identity therefore needs the variant name
|
||
// (albumDisambiguator) to agree — or, when either side lacks one, the
|
||
// release YEAR to corroborate. A self-titled base carries no identity at
|
||
// all (the title is just the artist), so even a bare↔bare title match
|
||
// requires the year there. No usable evidence → no local match; streaming
|
||
// the right album beats instantly playing the wrong one.
|
||
const wantD = normText(albumDisambiguator(title));
|
||
// The simplified-title fallback lets a library copy saved under a terse
|
||
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
|
||
// (Deluxe Edition)"); disambiguated variants never take it.
|
||
const t2 = wantD ? null : normText(simplifyTitle(title));
|
||
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
|
||
const selfTitled = t1 === a;
|
||
return (
|
||
libraryStore.albums.find((al) => {
|
||
if (normText(al.artist) !== a) return false;
|
||
const lt = normText(al.title);
|
||
if (lt !== t1 && !(t2 && lt === t2)) return false;
|
||
const libD = normText(albumDisambiguator(al.title));
|
||
if (wantD && wantD === libD) return true;
|
||
if (wantD !== libD || selfTitled) {
|
||
const libYear = Number(al.year) || null;
|
||
return wantYear != null && libYear != null && libYear === wantYear;
|
||
}
|
||
return true;
|
||
}) ?? null
|
||
);
|
||
}
|
||
|
||
/** The library album matching a catalog release, or null — the same evidence
|
||
* rules as library-first playback (variant name + release-year for self-titled
|
||
* records). Exposed for the artist page's saved-track ticks. */
|
||
export function libraryLocalAlbum(artist, album) {
|
||
return libraryAlbumFor(artist, album?.title ?? "", album?.release_date ?? null);
|
||
}
|
||
|
||
/** Whether a catalog release already has a matching copy in the library —
|
||
* the same evidence rules as library-first playback, so the artist page's
|
||
* ✓ badges and the play routing can never disagree. */
|
||
export function libraryHasAlbum(artist, album) {
|
||
return Boolean(libraryLocalAlbum(artist, album));
|
||
}
|
||
|
||
/** Locate a track in the library by artist + title, searching only that
|
||
* artist's albums. Returns { album, tracks, index } or null. */
|
||
async function libraryTrackFor(artist, title) {
|
||
const a = normText(artist);
|
||
const t = normText(title);
|
||
if (!a || !t) return null;
|
||
for (const al of libraryStore.albums) {
|
||
if (normText(al.artist) !== a) continue;
|
||
try {
|
||
const tracks = await api.albumTracks(al.id);
|
||
const index = tracks.findIndex((tr) => normText(tr.title) === t);
|
||
if (index >= 0) return { album: al, tracks, index };
|
||
} catch {
|
||
// library unavailable — fall through to streaming
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Distill a peer file's tags into the quality info the Now Playing panel
|
||
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
|
||
* many clients only report some, so the panel hides what's missing. */
|
||
function qualityOf(f) {
|
||
if (!f) return null;
|
||
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
|
||
return {
|
||
format: ext.toUpperCase(),
|
||
bitrate: f.bitrate ?? null,
|
||
sampleRate: f.sample_rate ?? null,
|
||
bitDepth: f.bit_depth ?? null,
|
||
vbr: !!f.vbr,
|
||
size: f.size ?? null,
|
||
};
|
||
}
|
||
|
||
/** Quality info for a local library track: format from the path, the rest
|
||
* from the DB (sample-rate/bit-depth/size backfilled for older files at
|
||
* startup). The panel drops whatever's still missing. Null if there's no
|
||
* path. `t` is a library Track row. */
|
||
function localQuality(t) {
|
||
const path = t?.path || "";
|
||
const ext = path.split(".").pop() || "";
|
||
if (!ext || ext === path) return null;
|
||
return {
|
||
format: ext.toUpperCase(),
|
||
bitrate: t.bitrate_kbps ?? null,
|
||
sampleRate: t.sample_rate ?? null,
|
||
bitDepth: t.bit_depth ?? null,
|
||
vbr: false,
|
||
size: t.size ?? null,
|
||
};
|
||
}
|
||
|
||
/** Deezer's CDN renders the same cover at whatever size the URL's size
|
||
* segment asks for. Track objects carry the 250px list thumb
|
||
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
|
||
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
|
||
* untouched. */
|
||
function hqCover(url) {
|
||
if (!url) return url;
|
||
try {
|
||
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
|
||
} catch {
|
||
return url;
|
||
}
|
||
return url.replace(
|
||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||
"/1000x1000$1.$2",
|
||
);
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
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
|
||
}
|
||
}
|
||
if (!requireOnline()) return;
|
||
autoAlbum = null;
|
||
player.track = { title: album.title, sub: "finding best source…", source: "stream" };
|
||
player.status = "buffering";
|
||
// Resolve the authoritative tracklist FIRST. Its length is the true track
|
||
// count — Deezer's artist-album LIST omits nb_tracks, so album.track_count
|
||
// is routinely null when Play is pressed on the artist page — and its
|
||
// titles are what the queue should show instead of peer filenames. Feeding
|
||
// the real count into albumCandidates both stops the ≥3-files filter from
|
||
// rejecting small releases and lets the folder-count preference pick the
|
||
// edition that matches the release (e.g. the 3-track single, not a 7-track
|
||
// CDR that would leak bonus tracks into the queue as raw filenames).
|
||
const catalogPromise = album.id
|
||
? api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
|
||
: Promise.resolve(null);
|
||
// Overlap the peer search with the tracklist fetch when the release is
|
||
// provably multi-track — the search is the long pole, and the tracklist
|
||
// round-trip used to serialize in front of it. `liveTrackCount` feeds the
|
||
// early-exit counter and tightens itself the moment the tracklist lands.
|
||
// A possible single (unknown count, or single/EP typed) keeps the
|
||
// sequential order, because a confirmed single must take the SONG path
|
||
// and must not race an already-active album resolve.
|
||
const provablyMultiTrack =
|
||
(album.track_count ?? 0) > 1 || album.record_type === "album";
|
||
let liveTrackCount = album.track_count || null;
|
||
const goodCount = (rs) => albumCandidates(rs, album, true, liveTrackCount).length;
|
||
let resolvePromise = null;
|
||
if (provablyMultiTrack) {
|
||
resolvePromise = resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
goodCount,
|
||
album.artist,
|
||
);
|
||
}
|
||
const catalogTracks = await catalogPromise;
|
||
const trackCount = catalogTracks?.length || album.track_count || null;
|
||
liveTrackCount = trackCount;
|
||
|
||
// A confirmed single is one track — stream it through the song path, which
|
||
// matches peer files by TITLE (album folder-matching can't identify a lone
|
||
// file among a folder's contents) and layers on the live/remix + duration
|
||
// hardening. Without this, a single fell into the album path and, with an
|
||
// unknown count, was rejected for having fewer than 3 files per folder.
|
||
if (trackCount === 1) {
|
||
// Rare mistyped release: a search may already be in flight — let it
|
||
// finish so the song path's own resolve isn't refused as re-entrant.
|
||
if (resolvePromise) await resolvePromise.catch(() => {});
|
||
const t = catalogTracks?.[0];
|
||
await streamSong(album.artist, t?.title ?? album.title, {
|
||
cover: t?.cover ?? album.cover,
|
||
trackId: t?.id ?? null,
|
||
albumId: album.id ?? null,
|
||
album: album.title || null,
|
||
expectSecs: t?.duration_secs ?? null,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const { responses, artistIncluded } = await (resolvePromise
|
||
?? resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
goodCount,
|
||
album.artist,
|
||
));
|
||
const candidates = albumCandidates(responses, album, true, trackCount, !artistIncluded);
|
||
if (candidates.length === 0) {
|
||
player.status = "idle";
|
||
player.track = null;
|
||
resolver.error = `No streamable album sources found for “${album.title}”`;
|
||
return;
|
||
}
|
||
for (const folder of candidates.slice(0, 3)) {
|
||
player.queue = folder.audio.map((f, i) => {
|
||
const ct = catalogTrackFor(f, i, folder.audio, catalogTracks);
|
||
return {
|
||
path: f.path,
|
||
title: ct?.title ?? cleanTrackName(f.name),
|
||
sub: `${album.artist} — ${album.title}`,
|
||
artist: album.artist,
|
||
album: album.title ?? null,
|
||
duration: f.duration ?? ct?.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: album.cover ?? null,
|
||
trackId: ct?.id ?? null,
|
||
stream: { username: folder.username, size: f.size },
|
||
quality: qualityOf(f),
|
||
};
|
||
});
|
||
player.catalogRef = { albumId: album.id ?? null, trackId: null };
|
||
playQueueIndex(0);
|
||
if (await waitForStreamStart(15000)) return;
|
||
}
|
||
stopPlayback();
|
||
resolver.error = `Sources for “${album.title}” aren't responding — try again in a moment`;
|
||
}
|
||
|
||
/** Find the best folder for an album and download all of it. */
|
||
export async function saveCatalogAlbum(album) {
|
||
if (!requireOnline() || resolver.active) return false;
|
||
// Same authoritative-count logic as streamCatalogAlbum: a single downloads
|
||
// as one track (folder-count matching can't find a lone-file release), and
|
||
// the real count keeps a multi-track Save from being rejected or grabbing a
|
||
// wrong-length edition.
|
||
const catalogTracks = album.id
|
||
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
|
||
: null;
|
||
const trackCount = catalogTracks?.length || album.track_count || null;
|
||
if (trackCount === 1) {
|
||
const t = catalogTracks?.[0];
|
||
return await saveSong(album.artist, t?.title ?? album.title);
|
||
}
|
||
const { responses, artistIncluded } = await resolve(
|
||
`${album.artist} — ${album.title}`,
|
||
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
|
||
(rs) => albumCandidates(rs, album, false, trackCount).length,
|
||
album.artist,
|
||
);
|
||
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
|
||
if (!best) {
|
||
resolver.error = `No album sources found for “${album.title}”`;
|
||
return false;
|
||
}
|
||
// The whole folder, extras included — the pipeline organizes and the
|
||
// staging cleanup files art/logs next to the finished album.
|
||
for (const f of best.files) {
|
||
await api.downloadFile(best.username, f.path, f.size);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export const search = $state({
|
||
token: null,
|
||
query: "",
|
||
searching: false,
|
||
responses: 0,
|
||
// key: `${username}|${folder}` -> group summary (scalars only — the file
|
||
// lists live outside reactive state, see searchFiles below).
|
||
groups: {},
|
||
});
|
||
|
||
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores
|
||
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
|
||
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
|
||
// the group's reactive fileCount and read the list through filesForGroup().
|
||
const searchFiles = new Map();
|
||
let searchStopTimer = null;
|
||
let searchRequest = 0;
|
||
|
||
function trimHiddenSearchFiles() {
|
||
const visibleKeys = new Set(
|
||
Object.values(search.groups)
|
||
.sort(
|
||
(a, b) =>
|
||
(b.hasFreeSlot - a.hasFreeSlot)
|
||
|| (b.speed - a.speed),
|
||
)
|
||
.slice(0, 120)
|
||
.map((group) => group.key),
|
||
);
|
||
for (const key of searchFiles.keys()) {
|
||
if (!visibleKeys.has(key)) searchFiles.delete(key);
|
||
}
|
||
}
|
||
|
||
export function filesForGroup(key) {
|
||
return searchFiles.get(key)?.files ?? [];
|
||
}
|
||
|
||
export const AUDIO_EXTS = new Set([
|
||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
||
]);
|
||
|
||
export const downloads = $state({
|
||
// id -> download payload (latest update wins)
|
||
items: {},
|
||
});
|
||
|
||
export const network = $state({
|
||
shareFolders: 0,
|
||
shareFiles: 0,
|
||
respondedCount: 0,
|
||
uploads: {}, // id -> upload payload
|
||
});
|
||
|
||
const TERMINAL_TRANSFER_STATES = new Set(["complete", "failed", "cancelled"]);
|
||
const DOWNLOAD_HISTORY_MAX = 300;
|
||
const UPLOAD_HISTORY_MAX = 100;
|
||
|
||
function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) {
|
||
const values = Object.values(store);
|
||
if (
|
||
waitUntilIdle
|
||
&& values.some((transfer) => !TERMINAL_TRANSFER_STATES.has(transfer.state))
|
||
) {
|
||
return;
|
||
}
|
||
const terminal = values
|
||
.filter((transfer) => TERMINAL_TRANSFER_STATES.has(transfer.state))
|
||
.sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
|
||
for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id];
|
||
}
|
||
|
||
// ---- 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: [],
|
||
// 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 <peer>" 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
|
||
&& performance.now() - waveRefreshAt > 3000
|
||
) {
|
||
// Still downloading: refresh the partial waveform so it grows
|
||
// with the transfer.
|
||
waveRefreshAt = performance.now();
|
||
loadStreamWaveform(cur);
|
||
}
|
||
});
|
||
|
||
// A next-track pre-buffer finished downloading: append it to the audio
|
||
// sink so the upcoming transition is gapless.
|
||
api.onEvent("soulseek:preload", (p) => {
|
||
if (p.done) tryAppendNext();
|
||
});
|
||
|
||
let libraryRefreshTimer = null;
|
||
api.onEvent("soulseek:library-changed", () => {
|
||
clearTimeout(libraryRefreshTimer);
|
||
libraryRefreshTimer = setTimeout(refreshLibrary, 400);
|
||
});
|
||
|
||
api.onEvent("soulseek:playlists-changed", refreshPlaylists);
|
||
|
||
// The backend batches search responses (~150ms ticks) so a broad search
|
||
// can't flood the IPC bridge or trigger a reactive flush per response.
|
||
api.onEvent("soulseek:search-results", (batch) => {
|
||
let matched = 0;
|
||
for (const r of batch) {
|
||
if (!search.searching || r.token !== search.token) continue;
|
||
matched += 1;
|
||
for (const f of r.files) {
|
||
const key = `${r.username}|${f.folder}`;
|
||
let g = search.groups[key];
|
||
let raw = searchFiles.get(key);
|
||
if (!g) {
|
||
// Cap group count so a popular search can't flood the DOM.
|
||
if (searchFiles.size >= 300) continue;
|
||
raw = { files: [], exts: new Map() };
|
||
searchFiles.set(key, raw);
|
||
g = search.groups[key] = {
|
||
key,
|
||
username: r.username,
|
||
folder: f.folder,
|
||
folderName: f.folder.split("\\").pop() || r.username,
|
||
hasFreeSlot: r.has_free_upload_slot,
|
||
speed: r.upload_speed,
|
||
queue: r.queue_length,
|
||
// Scalar summaries the UI renders; the file list
|
||
// itself stays out of reactive state.
|
||
fileCount: 0,
|
||
audioCount: 0,
|
||
totalSize: 0,
|
||
topExt: "",
|
||
maxBitrate: 0,
|
||
};
|
||
} else if (!raw) {
|
||
// Defensive recovery if a late batch races raw-list
|
||
// pruning. Keep the existing scalar summary intact.
|
||
raw = { files: [], exts: new Map() };
|
||
searchFiles.set(key, raw);
|
||
}
|
||
if (raw.files.length < 500) {
|
||
raw.files.push(f);
|
||
g.fileCount += 1;
|
||
g.totalSize += f.size;
|
||
if (AUDIO_EXTS.has(f.extension)) g.audioCount += 1;
|
||
if (f.bitrate && f.bitrate > g.maxBitrate) g.maxBitrate = f.bitrate;
|
||
const n = (raw.exts.get(f.extension) ?? 0) + 1;
|
||
raw.exts.set(f.extension, n);
|
||
if (n >= (raw.exts.get(g.topExt) ?? 0)) g.topExt = f.extension;
|
||
}
|
||
}
|
||
}
|
||
if (matched > 0) search.responses += matched;
|
||
});
|
||
}
|
||
|
||
export async function connect(username, password, remember) {
|
||
app.connection = "connecting";
|
||
app.loginError = "";
|
||
try {
|
||
await api.connectAndLogin(username, password, remember);
|
||
app.connection = "online";
|
||
app.username = username;
|
||
app.showLogin = false;
|
||
} catch (e) {
|
||
app.connection = "offline";
|
||
app.loginError = String(e);
|
||
}
|
||
}
|
||
|
||
export async function autoConnect() {
|
||
// A reloaded WebView boots against a backend that may ALREADY be logged
|
||
// in; connect_stored then fails ("already connected") and the catch below
|
||
// used to stamp the page offline — the first play attempt raised the
|
||
// login modal against a healthy session. Adopt the live session instead.
|
||
try {
|
||
const status = await api.appStatus();
|
||
if (status?.connected_as) {
|
||
app.connection = "online";
|
||
app.username = status.connected_as;
|
||
app.showLogin = false;
|
||
return;
|
||
}
|
||
} catch {
|
||
// status unavailable — fall through to the normal connect path
|
||
}
|
||
try {
|
||
const creds = await api.getCredentials();
|
||
if (!creds.has_password) return;
|
||
app.connection = "connecting";
|
||
await api.connectStored();
|
||
app.connection = "online";
|
||
app.username = creds.username;
|
||
} catch {
|
||
// One more status probe before declaring offline: the connect may
|
||
// have lost a race with an existing session rather than failed.
|
||
try {
|
||
const status = await api.appStatus();
|
||
if (status?.connected_as) {
|
||
app.connection = "online";
|
||
app.username = status.connected_as;
|
||
return;
|
||
}
|
||
} catch {}
|
||
app.connection = "offline";
|
||
}
|
||
}
|
||
|
||
export async function downloadFile(username, file) {
|
||
await api.downloadFile(username, file.path, file.size);
|
||
}
|
||
|
||
/** Stream a search-result file: play while it downloads to cache. */
|
||
export async function streamFile(username, file, subLabel) {
|
||
autoAlbum = null;
|
||
player.queue = [
|
||
{
|
||
path: file.path,
|
||
title: file.name.replace(/\.[^.]+$/, ""),
|
||
sub: subLabel ?? username,
|
||
duration: file.duration ?? null,
|
||
art: null,
|
||
artUrl: null,
|
||
stream: { username, size: file.size },
|
||
quality: qualityOf(file),
|
||
},
|
||
];
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
playQueueIndex(0);
|
||
}
|
||
|
||
/** Play a finished download from disk (single track). */
|
||
export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||
bumpMainRevision("userPlay");
|
||
autoAlbum = null;
|
||
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
|
||
player.queueIndex = 0;
|
||
player.track = { title, sub: subLabel ?? "", source: "local", path };
|
||
player.status = "buffering";
|
||
player.duration = null;
|
||
player.streamTotal = 0;
|
||
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.
|
||
player.streamReceived = 0;
|
||
player.streamTotal = item.stream.size;
|
||
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 <peer>" 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;
|
||
// 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
|
||
player.queue.push({
|
||
path: best.file.path,
|
||
title: t.title,
|
||
sub: t.artist,
|
||
artist: t.artist ?? null,
|
||
album: ctx.albumTitle ?? null,
|
||
duration: best.file.duration ?? t.duration_secs ?? null,
|
||
art: null,
|
||
artUrl: ctx.cover,
|
||
trackId: t.id,
|
||
stream: { username: best.username, size: best.file.size },
|
||
quality: qualityOf(best.file),
|
||
});
|
||
// 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 ----
|
||
|
||
const WAVE_BUCKETS = 400;
|
||
let waveReqPath = null;
|
||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||
let waveRefreshAt = 0;
|
||
|
||
async function loadWaveform(path) {
|
||
player.waveform = [];
|
||
waveReqPath = path;
|
||
if (!path) return;
|
||
try {
|
||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||
// Ignore if the track changed while we were computing.
|
||
if (waveReqPath === path) player.waveform = peaks;
|
||
} catch {
|
||
if (waveReqPath === path) player.waveform = [];
|
||
}
|
||
}
|
||
|
||
/** Waveform for a streamed queue item, computed from its cache file. Empty
|
||
* (plain progress bar) until the download completes; the stream-done event
|
||
* calls this again to swap the full waveform in mid-track. */
|
||
let waveSeq = 0;
|
||
let streamWaveInFlight = false;
|
||
let queuedStreamWave = null;
|
||
|
||
async function loadStreamWaveform(item, final = false) {
|
||
const key = `stream:${item.stream.username}|${item.path}`;
|
||
if (waveReqPath !== key) player.waveform = [];
|
||
waveReqPath = key;
|
||
// A late partial FLAC decode can take close to the three-second refresh
|
||
// interval. Coalesce all overlap into the newest request, retaining a
|
||
// final completion request so the full-quality waveform always wins.
|
||
if (streamWaveInFlight) {
|
||
const preserveFinal = queuedStreamWave?.key === key && queuedStreamWave.final;
|
||
queuedStreamWave = { item, key, final: final || preserveFinal };
|
||
return;
|
||
}
|
||
streamWaveInFlight = true;
|
||
let next = { item, key, final };
|
||
try {
|
||
while (next) {
|
||
const request = next;
|
||
next = null;
|
||
const seq = ++waveSeq;
|
||
try {
|
||
const peaks = await api.streamWaveform(
|
||
request.item.stream.username,
|
||
request.item.path,
|
||
request.item.stream.size,
|
||
WAVE_BUCKETS,
|
||
);
|
||
if (
|
||
waveReqPath === request.key
|
||
&& seq === waveSeq
|
||
&& peaks.length > 0
|
||
) {
|
||
player.waveform = peaks;
|
||
}
|
||
} catch {
|
||
// stays empty — the buffer/progress bar renders instead
|
||
}
|
||
const queued = queuedStreamWave;
|
||
queuedStreamWave = null;
|
||
// Only the newest still-visible track matters. A queued final call
|
||
// is never dropped; the backend independently confirms completion.
|
||
if (queued && waveReqPath === queued.key) next = queued;
|
||
}
|
||
} finally {
|
||
streamWaveInFlight = false;
|
||
}
|
||
}
|
||
|
||
// ---- queue navigation, shuffle, repeat ----
|
||
|
||
// Indices already played this shuffle cycle (module-private).
|
||
let shuffleSeen = new Set();
|
||
|
||
function pickShuffleIndex(reset = false) {
|
||
const n = player.queue.length;
|
||
if (reset) shuffleSeen = new Set();
|
||
const remaining = [];
|
||
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
|
||
if (remaining.length === 0) return -1;
|
||
return remaining[Math.floor(Math.random() * remaining.length)];
|
||
}
|
||
|
||
export function nextTrack(auto = false) {
|
||
const n = player.queue.length;
|
||
if (n === 0) {
|
||
stopPlayback();
|
||
return;
|
||
}
|
||
// Auto-advance on a repeat-one track replays it.
|
||
if (auto && player.repeat === "one") {
|
||
playQueueIndex(player.queueIndex);
|
||
return;
|
||
}
|
||
|
||
let next;
|
||
if (player.shuffle) {
|
||
shuffleSeen.add(player.queueIndex);
|
||
next = pickShuffleIndex();
|
||
if (next < 0) {
|
||
if (player.repeat === "all") next = pickShuffleIndex(true);
|
||
else return stopPlayback();
|
||
}
|
||
} else {
|
||
next = player.queueIndex + 1;
|
||
if (next >= n) {
|
||
if (player.repeat === "all") next = 0;
|
||
else return stopPlayback();
|
||
}
|
||
}
|
||
playQueueIndex(next);
|
||
}
|
||
|
||
export function prevTrack() {
|
||
// Restart the current track if we're more than 3s in.
|
||
if (player.position > 3) {
|
||
api.playerControl("seek", 0);
|
||
return;
|
||
}
|
||
const prev = player.queueIndex - 1;
|
||
if (prev >= 0) playQueueIndex(prev);
|
||
else api.playerControl("seek", 0);
|
||
}
|
||
|
||
export function toggleShuffle() {
|
||
player.shuffle = !player.shuffle;
|
||
shuffleSeen = new Set([player.queueIndex]);
|
||
}
|
||
|
||
export function cycleRepeat() {
|
||
player.repeat =
|
||
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
|
||
}
|
||
|
||
function stopPlayback() {
|
||
player.status = "idle";
|
||
player.track = null;
|
||
player.position = 0;
|
||
player.duration = null;
|
||
player.queue = [];
|
||
player.queueIndex = -1;
|
||
player.waveform = [];
|
||
player.catalogRef = { albumId: null, trackId: null };
|
||
latestPlayerPosition = 0;
|
||
latestPlayerDuration = null;
|
||
latestStreamReceived = 0;
|
||
latestStreamTotal = 0;
|
||
autoAlbum = null;
|
||
appendedNextPath = null;
|
||
lastPreloadedPath = null;
|
||
setAccentFromArt(null);
|
||
}
|
||
|
||
export async function downloadFolder(group) {
|
||
for (const f of filesForGroup(group.key)) {
|
||
await api.downloadFile(group.username, f.path, f.size);
|
||
}
|
||
}
|
||
|
||
// Dev-only handle to THIS module instance for CDP-driven verification —
|
||
// a dynamic import from the console can resolve to a second instance when
|
||
// Vite has HMR-stamped the app's own import URL.
|
||
if (import.meta.env.DEV && typeof window !== "undefined") {
|
||
window.__state = {
|
||
app, player, resolver, catalog, ui, discovery, downloads, libraryStore,
|
||
streamSong, streamCatalogAlbum, autoConnect, nextTrack, prevTrack, loadDiscovery,
|
||
saveStream, refreshLibrary, openCatalogArtist, openCatalogAlbum,
|
||
playAlbum, jumpToQueueIndex, toggleNowPlaying,
|
||
artBroken, markArtBroken,
|
||
coverUpgrades, resolveCovers, playerArtSource, libraryHasAlbum,
|
||
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,
|
||
};
|
||
}
|