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:
2026-08-17 08:26:34 -04:00
co-authored by Claude Opus 5
parent b3737d30af
commit dcabec8b1d
12 changed files with 569 additions and 18 deletions
+78
View File
@@ -294,3 +294,81 @@ async function openAlbum(
navigate(target, detail);
}
/**
* One credited artist within a multi-artist credit.
*
* Mirrors `artist_credit_part` / `file_artists`: the name **as
* credited** (which is not the artist's own name — MusicBrainz credits
* "Snoop Dogg" on a track by the artist called "Snoop Doggy Dogg"), the
* MBID to navigate to, and the literal connector that follows this
* part.
*/
export interface CreditPart {
/** The name as credited. Display uses this. */
creditedName: string;
/** The artist's MusicBrainz ID. Navigation uses this. */
artistMbid: string;
/** The connector following this part: " feat. ", " & ", ", ", "". */
joinPhrase: string;
}
/**
* Render a credit as links, one per credited artist, with the join
* phrases as plain text between them.
*
* Join phrases are **assembly instructions, not disassembly
* instructions**. This concatenates parts; it never searches for a
* name inside a credit string. That distinction is the whole point:
* the stored credit text may have come from a file's tags while the
* parts come from the catalog, and measured on a real library those
* disagree for about one in three multi-artist credits ("Skrillex
* feat. Swae Lee" tagged against "Skrillex & Swae Lee" upstream). A
* search would miss, or match the wrong span. Building from parts,
* the link boundaries are known by construction.
*
* Falls back to `artistLink(fallbackName, fallbackMbid)` — today's
* behaviour exactly — when there are no parts. That is the common
* case and not a degraded one: a single-artist credit *is* one link,
* and a file with no recording MBID or no catalog row has nothing to
* decompose. Do not try to split the fallback string; there is
* genuinely no information in it to split on.
*
* @param parts - The credit's parts in position order, if known.
* @param fallbackName - The credit as a single string.
* @param fallbackMbid - The primary artist's MBID.
*/
export function creditLink(
parts: readonly CreditPart[] | undefined,
fallbackName: string,
fallbackMbid: string,
): TemplateResult | string {
// One part is one link, so it is the fallback rather than a special
// case — and a zero-part credit reaching here would otherwise
// render as nothing at all, which is worse than the single-artist
// answer it replaced.
if (!parts || parts.length < 2) {
return artistLink(fallbackName, fallbackMbid);
}
return html`${parts.map(
(part) =>
html`${artistLink(part.creditedName, part.artistMbid)}${part.joinPhrase}`,
)}`;
}
/**
* The plain-text form of a credit, for `title=` attributes and any
* other place that needs a string rather than a template.
*
* Rendered from the same parts by the same concatenation, so the
* tooltip cannot disagree with the links beneath it.
*/
export function creditText(
parts: readonly CreditPart[] | undefined,
fallbackName: string,
): string {
if (!parts || parts.length < 2) return fallbackName;
return parts.map((p) => p.creditedName + p.joinPhrase).join('');
}