feat(frontend): render a multi-artist credit as one link per artist
Every artist name in the app went through `artistLink(name, mbid)`, so a track credited to several artists rendered one link and the rest as punctuation — "2Pac feat. Snoop Dogg" linked 2Pac and left Snoop Dogg as text inside it. `creditLink(parts, fallbackName, fallbackMbid)` renders the credit from its parts: one link per credited artist, join phrases as plain text between them. The link boundaries are known by construction, which is the point — locating a name inside the stored credit string would reintroduce the mismatch the catalog exists to avoid, since that string may come from the file's tags while the parts come from MusicBrainz and the two disagree for ~1 in 3 multi-artist credits. Fewer than two parts falls through to the previous behaviour exactly, so a single-artist credit, a file with no recording MBID and a catalog that has not answered yet all render as they did before. Nothing tries to split the fallback string: "Simon & Garfunkel" is one artist, which is why primaryArtist() does not split on "&" either. The lookup is keyed on the recording MBID, which both sides already carry — a catalog row has one and so does a local file — so one binding serves Explore and the library's own lists, and no local table is needed for this. credit-store.ts, and three things in it are load-bearing: - A miss is cached as an empty array. The backend returns nothing for a single-artist credit, which is ~87% of tracks, and caching only the hits would re-request the rest on every render forever. - request() is per-row and coalesces into one call per frame. A virtualized list cannot hand over "the whole list": 50,000 rows would be 100 queries for the ~30 on screen. - It is an LRU with a counted retainedChars probe, because a cache that grows with use is a leak with a schedule. The virtualized lists push requestUpdate() into the virtualizer rather than only the host, since its rows come from its own properties — a host update alone would leave them exactly as they were. now-playing marks its geometry dirty instead, because the marquee measures the text it is about to scroll. track-list keeps the single link while a search term is active: the highlight spans are computed against the flat credit string, and mapping them onto decomposed parts is a different problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Multi-artist credits, keyed by recording MBID.
|
||||
*
|
||||
* A credit is ordered parts and the credit *string* is derived from
|
||||
* them. This store holds the parts for entities that have more than
|
||||
* one credited artist; everything else renders the single link it
|
||||
* always did.
|
||||
*
|
||||
* Three things about it are load-bearing.
|
||||
*
|
||||
* **Absence is an answer, and it is cached as one.** The backend
|
||||
* returns nothing for a single-artist credit, which is the common case
|
||||
* by a wide margin — measured on a real library, 13% of tracks are
|
||||
* multi-artist. Caching only the hits would re-request the other 87%
|
||||
* on every render, forever, which is the same shape as the bug that
|
||||
* made `explore-album-details` ask the backend on hover. A miss is
|
||||
* stored as an empty array: *asked*, not *answered*.
|
||||
*
|
||||
* **The lookup is batched, and coalesced across callers.** Every row
|
||||
* of every tracklist asks this question, and one IPC round trip per row
|
||||
* is how a 5,000-row list becomes unusable. A virtualized list cannot
|
||||
* hand over "the whole list" either — 50,000 rows is 100 queries for
|
||||
* the ~30 on screen. So `request()` is per-row and cheap: it collects
|
||||
* into a pending set and flushes once on the next frame, which turns a
|
||||
* screenful of rows into exactly one call. `ensure()` remains for a
|
||||
* caller that genuinely has a bounded list in hand.
|
||||
*
|
||||
* **It is bounded.** A cache that grows with use is a leak with a
|
||||
* schedule; a browsing afternoon touches far more credits than a
|
||||
* screenful. The cap is entries rather than bytes because a credit is
|
||||
* a handful of short strings, unlike the art caches next door.
|
||||
*/
|
||||
|
||||
import { GetCredits } from '@go/explore/service.js';
|
||||
import type { CreditPart } from '../utils/explore-link';
|
||||
import { LRUMap } from '../utils/lru-map';
|
||||
import { compact } from '../utils/binding';
|
||||
import { registerCacheProbe } from '../utils/cache-stats';
|
||||
|
||||
/**
|
||||
* Entries retained. A credit is ~4 short strings, so this is well
|
||||
* under a megabyte — sized to comfortably exceed any single list the
|
||||
* app renders, because a cap below the visible count evicts rows that
|
||||
* are still on screen and the re-render fetches them straight back.
|
||||
*/
|
||||
export const CREDIT_CACHE_LIMIT = 20_000;
|
||||
|
||||
/** An empty parts array is the negative marker: asked, no decomposition. */
|
||||
type CachedParts = readonly CreditPart[];
|
||||
|
||||
class CreditStore {
|
||||
private cache = new LRUMap<string, CachedParts>(CREDIT_CACHE_LIMIT);
|
||||
|
||||
/** MBIDs with a request in flight, so a re-render does not refetch. */
|
||||
private inFlight = new Set<string>();
|
||||
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/** Collected by request(), flushed as one batch on the next frame. */
|
||||
private pending = new Set<string>();
|
||||
|
||||
private flushHandle: number | null = null;
|
||||
|
||||
constructor() {
|
||||
registerCacheProbe('credits', () => ({
|
||||
entries: this.cache.size,
|
||||
chars: this.retainedChars(),
|
||||
limit: CREDIT_CACHE_LIMIT,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The strings actually retained, counted rather than estimated —
|
||||
* a bound that is only checkable against a guess is not checkable.
|
||||
*/
|
||||
private retainedChars(): number {
|
||||
let total = 0;
|
||||
|
||||
for (const parts of this.cache.values()) {
|
||||
for (const part of parts) {
|
||||
total +=
|
||||
part.creditedName.length +
|
||||
part.joinPhrase.length +
|
||||
part.artistMbid.length;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to "some credits arrived".
|
||||
*
|
||||
* Deliberately not per-MBID: a list fetches its rows in one call and
|
||||
* re-renders once, so a fine-grained signal would buy nothing and
|
||||
* cost a listener per row.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts for one entity, or undefined when it has not been asked
|
||||
* about yet.
|
||||
*
|
||||
* An entity with a single-artist credit returns an empty array, and
|
||||
* `creditLink` treats fewer than two parts as the fallback — so a
|
||||
* caller does not have to distinguish "not asked" from "one artist"
|
||||
* to render correctly, only to decide whether to ask.
|
||||
*/
|
||||
get(mbid: string | undefined): readonly CreditPart[] | undefined {
|
||||
if (!mbid) return undefined;
|
||||
|
||||
return this.cache.get(mbid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask about one entity, joining whatever batch is forming.
|
||||
*
|
||||
* Safe to call from a render: it is a set insert and a scheduled
|
||||
* flush, and an entity already cached or in flight is dropped. The
|
||||
* loop it looks like it might cause does not happen — after a flush
|
||||
* every requested MBID is cached, so the re-render's requests are
|
||||
* all dropped and nothing notifies again.
|
||||
*/
|
||||
request(mbid: string | undefined): void {
|
||||
if (!mbid) return;
|
||||
if (this.cache.has(mbid)) return;
|
||||
if (this.inFlight.has(mbid)) return;
|
||||
if (this.pending.has(mbid)) return;
|
||||
|
||||
this.pending.add(mbid);
|
||||
|
||||
if (this.flushHandle !== null) return;
|
||||
|
||||
// A frame, not a microtask: the point is to collect every row a
|
||||
// virtualizer renders in this pass, and those happen across the
|
||||
// whole update, not within one microtask checkpoint.
|
||||
this.flushHandle = requestAnimationFrame(() => {
|
||||
this.flushHandle = null;
|
||||
|
||||
const batch = [...this.pending];
|
||||
|
||||
this.pending.clear();
|
||||
|
||||
void this.ensure(batch);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask and read in one call, for use inside a template.
|
||||
*
|
||||
* A getter with a side effect, deliberately: the alternative is
|
||||
* every call site writing `request(x)` beside `get(x)` and one of
|
||||
* them eventually forgetting, which renders a permanently
|
||||
* single-artist credit that looks exactly like an entity with one
|
||||
* artist. Making the request the same act as the read is what
|
||||
* stops the two drifting apart.
|
||||
*/
|
||||
credits(mbid: string | undefined): readonly CreditPart[] | undefined {
|
||||
this.request(mbid);
|
||||
|
||||
return this.get(mbid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the credits for a list, skipping anything already known or
|
||||
* already being fetched.
|
||||
*
|
||||
* `has` rather than `get` for the membership test: probing must not
|
||||
* mark an entry recently-used, or scrolling past a row would keep
|
||||
* it alive ahead of one actually being rendered.
|
||||
*/
|
||||
async ensure(mbids: readonly (string | undefined)[]): Promise<void> {
|
||||
const wanted = new Set<string>();
|
||||
|
||||
for (const mbid of mbids) {
|
||||
if (!mbid) continue;
|
||||
if (this.cache.has(mbid)) continue;
|
||||
if (this.inFlight.has(mbid)) continue;
|
||||
|
||||
wanted.add(mbid);
|
||||
}
|
||||
|
||||
if (wanted.size === 0) return;
|
||||
|
||||
const batch = [...wanted];
|
||||
|
||||
for (const mbid of batch) this.inFlight.add(mbid);
|
||||
|
||||
try {
|
||||
const found = compact(await GetCredits(batch));
|
||||
|
||||
for (const mbid of batch) {
|
||||
// Every MBID asked for gets an entry, present or not:
|
||||
// the absent ones are the answer "one artist", and not
|
||||
// recording that is what would re-ask forever.
|
||||
this.cache.set(mbid, found[mbid] ?? []);
|
||||
}
|
||||
|
||||
this.notify();
|
||||
} catch (err) {
|
||||
// A credit is an enrichment: without it every name renders
|
||||
// as the single link it did before, which is a worse answer
|
||||
// rather than a broken one. Nothing user-facing is worth
|
||||
// interrupting for, so this stays in the console.
|
||||
console.error('Failed to load artist credits', err);
|
||||
} finally {
|
||||
for (const mbid of batch) this.inFlight.delete(mbid);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop everything. The tags on disk changed, so credits may have. */
|
||||
invalidate(): void {
|
||||
this.cache = new LRUMap<string, CachedParts>(CREDIT_CACHE_LIMIT);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
}
|
||||
|
||||
export const creditStore = new CreditStore();
|
||||
Reference in New Issue
Block a user