/* The catalog on the phone: the desktop's lookups (Deezer through the metadata crate, the art proxy for pictures), cached for the session. An artist page opens cold — the library's own shell, instantly — and the lookup fills the rest in: the picture, Popular, the discography. The identity rule is the desktop's (`openArtistByName`): among the search's exact-name matches, the most-followed wins, so a shared name resolves to the canonical artist and never to a namesake the search listed first. */ import * as api from "../lib/api.js"; export const catalog = $state({ /** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */ artists: {}, /** catalog album id → { status, page, error } */ albums: {}, /** artist id → { status, related, profile, bio } — fans also like, the * Last.fm profile (listeners, plays, tags), the bio; once per artist */ extras: {}, /** artist id → { status, listeners, plays, tags } — the profile alone, * for the search hero */ profiles: {}, /** lowercase name → CatalogArtist | null — a portrait for a byline */ seeds: {}, /** "artist\u0000title" → { status, info: { description, genre, label, source } } */ albumInfo: {}, }); export function artistKey(name) { return String(name || "").trim().toLowerCase(); } /** The most-followed exact match for a name, or the best the search has. */ async function bestArtistFor(name) { const k = artistKey(name); const res = await api.catalogSearch(name); const artists = res?.artists ?? []; const exact = artists.filter((a) => artistKey(a.name) === k); const pool = exact.length ? exact : artists; return pool.reduce((top, a) => ((a.fans ?? 0) > (top?.fans ?? -1) ? a : top), null); } /** Open an artist by name — or by the search's own row (`seed`, a * CatalogArtist with its id), which skips the identity search: the row * IS the identity, namesake-safe, and its portrait is on screen at once. */ export function lookupArtist(name, seed = null) { const k = artistKey(name); if (!k) return; const cur = catalog.artists[k]; if (cur && cur.status !== "error") return; catalog.artists[k] = { status: "loading", id: seed?.id ?? null, seed: seed ?? null, page: null }; (async () => { try { const best = seed?.id != null ? seed : await bestArtistFor(name); if (!best) { catalog.artists[k] = { status: "none", id: null, seed: null, page: null }; return; } catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null }; const page = await api.catalogArtist(best.id); catalog.artists[k] = { status: "ready", id: best.id, seed: best, page }; } catch (e) { /* transport failures stay retryable — the next visit asks again */ catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) }; } })(); } /** Fans also like, the Last.fm profile and the bio — the desktop's * ArtistExtras, asked once per artist after the page is on screen. */ export function loadExtras(id, name) { if (id == null || catalog.extras[id]) return; catalog.extras[id] = { status: "loading", related: [], profile: null, bio: "", socials: [] }; Promise.all([ api.catalogRelated(id).catch(() => []), api.catalogArtistProfile(id).catch(() => null), api.catalogArtistBio(id, name).catch(() => null), /* the socials are a deferred MusicBrainz pass on the desktop too: rate-limited upstream, asked after the page is on screen */ api.catalogArtistSocials(id).catch(() => []), ]).then(([r, p, b, l]) => { const text = typeof b === "string" ? b : (b?.text ?? b?.bio ?? ""); catalog.extras[id] = { status: "ready", related: r ?? [], profile: p ?? null, bio: text ?? "", socials: Array.isArray(l) ? l : [] }; if (p) catalog.profiles[id] = { status: "ready", ...p }; }); } /** The profile alone: the search hero's tags and totals. */ export function loadProfile(id) { if (id == null || catalog.profiles[id]) return; catalog.profiles[id] = { status: "loading" }; api.catalogArtistProfile(id).then( (p) => { catalog.profiles[id] = { status: "ready", ...(p || {}) }; }, () => { catalog.profiles[id] = { status: "error" }; }, ); } /** A portrait for a byline — the same identity rule, search only. */ export function seedFor(name) { const k = artistKey(name); if (!k || k in catalog.seeds) return; catalog.seeds[k] = null; const known = catalog.artists[k]?.seed; if (known) { catalog.seeds[k] = known; return; } bestArtistFor(name).then((a) => { catalog.seeds[k] = a || null; }, () => {}); } export const infoKey = (artist, title) => String(artist || "").toLowerCase() + "\u0000" + String(title || "").toLowerCase(); /** About this record: Wikipedia's words, the genre and the label, through * the desktop's own lookup. Once per record. */ export function loadAlbumInfo(artist, title) { const k = infoKey(artist, title); if (!artist || !title || catalog.albumInfo[k]) return; catalog.albumInfo[k] = { status: "loading", info: null }; api.catalogAlbumInfo(null, artist, title).then( (info) => { catalog.albumInfo[k] = { status: "ready", info: info || null }; }, () => { catalog.albumInfo[k] = { status: "error", info: null }; }, ); } export function lookupAlbum(id) { if (id == null) return; const cur = catalog.albums[id]; if (cur && cur.status !== "error") return; catalog.albums[id] = { status: "loading", page: null }; api.catalogAlbum(id).then( (page) => { catalog.albums[id] = { status: "ready", page }; }, (e) => { catalog.albums[id] = { status: "error", page: null, error: String(e) }; }, ); } /** Deezer answers a missing artist photo with a grey silhouette IMAGE under * the hash of the empty string (the metadata crate's * DEEZER_EMPTY_PICTURE_HASH), which reads as a light disc against the dark * UI. Treat it as no picture, so the app's own placeholder shows. */ const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e"; export function picture(url) { if (!url) return null; const u = String(url); return u.includes(DEEZER_EMPTY_PICTURE_HASH) || /\/images\/[a-z]+\/\//.test(u) ? null : u; } /** "1.2M fans", the desktop's stat chip */ export function fans(n) { if (!n) return ""; if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M fans"; if (n >= 1000) return Math.round(n / 1000) + "k fans"; return n + " fans"; } export function yearOf(date) { const m = /^(\d{4})/.exec(String(date || "")); return m ? m[1] : ""; } /** Titles compared loosely: case, punctuation and bracketed suffixes * ("(Remastered)") do not make a different record. */ export function looseTitle(s) { return String(s || "") .toLowerCase() .replace(/\s*[([].*?[)\]]\s*/g, " ") .replace(/[^\p{L}\p{N}]+/gu, " ") .trim(); }