# 015 — Multi-artist credits, navigable > **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it. ## The problem A track credited to more than one artist has exactly one navigable artist in this app, and the others are punctuation. `audio_files` carries `artist_credit` (the credit as tagged, for display) and `artist_id` (one artist, for grouping and browsing). `primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that one artist by *string-parsing* the credit: it strips a " feat. " clause, and deliberately does not split on `&`, `x`, `with` or `,` because those appear inside real artist names. So "Lana Del Rey ft. Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely, and "Alina Baraz & Galimatias" stores one artist whose name is the whole credit. ### What the measurement says Measured 2026-08-16 against a real 26,069-file library (19,840 mp3, 6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80 MusicBrainz `inc=artist-credits` sample. - **13%** of a random sample of the library's recordings have more than one credited artist in MusicBrainz (10 of 79 resolved). Extrapolates to ~3,250 of the 24,989 files carrying a recording MBID. - **0.86%** of files (224) carry any structured multi-artist signal in their own tags. mp3 carries **zero** files with multiple `MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87. - **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)** have nothing structured behind it. A sample of 80 such files was multi-artist in MB **80 of 80 times**. CLAUDE.md currently justifies plan 013's removal of `artist_credit` / `artist_credit_artist` with "3 credits of 2,823 listed more than one artist". That figure measured **our own writer**, not the library: `cachedLinkArtist` was called exactly once per credit (`e7748f1^:backend/library/library.go:1842`), so a collaboration could never have been recorded, and the three were resolution collisions on shared credit text. Dropping the join table was still correct — it only ever held one row, so it was pure join cost — but the stated evidence does not support "multi-artist is rare". Correcting that claim is part of this plan. ### Why the tags cannot answer it Deriving the decomposition locally, with no network, works **79% of the time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105, flac 100/110), and the failures are systematic rather than random: ``` ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi' ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi'] ``` `ARTISTS` holds **canonical** artist names; `ARTIST` holds **as-credited** names. Locating one inside the other fails on "Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and on Unicode (`Michel'le` vs `Michel’le`, `K-Ci` vs `K‐Ci` — U+2010, not a hyphen). That distinction is precisely what a join phrase encodes, and it is why this cannot be a tag-parsing feature. Two format details that will mislead anyone re-running the probe: Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and as **true repeated Vorbis keys** on flac, so a probe splitting only on NUL undercounts mp3 to zero. ## The shape MusicBrainz models a credit as ordered parts, and the credit *string* is derived from them — `artist_credit.name` is a cached render, nothing more. Each participant is `(position, artist, name, join_phrase)`, where `artist` is the MBID (canonical, what you navigate to) and `name` is the credited spelling (what you display). **Join phrases are assembly instructions, not disassembly instructions.** Rendering is a concatenation, never a search: ``` for each (position, artist_mbid, credited_name, join_phrase): emit link(credited_name -> artist_mbid) emit text(join_phrase) ``` The link positions are known **by construction**. This is load-bearing: if we instead located each `credited_name` inside the stored `artist_credit` text, we would reintroduce the mismatch above — the stored string may have come from the tags while the parts come from the catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of 90 sampled credits rendered exactly equal to the tag string). Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs `'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`; `'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was edited after tagging or Picard versions differ; either way the search would miss or match the wrong span. So `audio_files.artist_credit` stops being the source of truth and becomes the **fallback**, used only where there are no parts. ## Where the data comes from The catalog carries the decomposition; no user ever makes a per-recording call. Two sources were ruled out first, both cheaply: - **The canonical dump — which is what CI already pulls (`dumpimport.go:84-85`) — does not have it.** `canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list) and `artist_credit_name`, but that last column is the *rendered* string. Splitting it on CI needs the as-credited names, so CI would fail exactly the way a local parse does. - **The JSON dumps do not cover the catalog.** `json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and holds **153,691 recordings**, not ~35M. Measured against the test library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is some other subset and is not usable. That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at the 20260815 export), from `https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four members are needed: | member | why | approx rows | | --- | --- | --- | | `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M | | `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M | | `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M | | `mbdump/release_group` | same, for album credits | ~2M | ### Coverage is not a concern Of 24,885 distinct recording MBIDs in the test library, **24,808 (99.7%)** already have an `explore_index` recording row, measured against a database at 2,052,200 rows — i.e. shipped-artifact coverage, not a local build's. The popularity filter does not strand the long tail here. ## Status - **Phase 1 — done.** `backend/explore/dumpcredits.go` + `dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its own `credits_import_done` marker. - **Phase 2 — done.** `cmd/indexexport` writes the two tables; `artifactimport.go` reads them behind `artifactHasCredits()`. - **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits` reads the catalog tables keyed on the *recording* MBID, which both sides of the app already carry — a catalog row has one and so does a local file (`library.Track.RecordingMBID`). So one binding serves the Explore pages and the library's own lists, and all ten artist-link call sites render credits today without a local table. - **Phase 3 (`file_artists`) — not started, and now an offline-resilience task rather than a prerequisite.** The table is deliberately *not* declared yet: nothing writes or reads it, and a schema file plus a datamap note describing behaviour that does not exist is a claim the code cannot back. Its remaining value is that credits currently vanish when the catalog is absent or still downloading, which is precisely the `no-index` state `ShelfPage.State` exists to describe. Materialising into `file_artists` is what makes a library stand on its own. **Nothing renders yet in practice**, because no published artifact carries credit tables — every credit falls back to its single link until an index build with Phase 1 runs and is exported. **Column layouts are verified against the real 20260815 export**, not taken from the schema docs — `artist(id, gid, …)`, `artist_credit(id, name, artist_count, …)`, `artist_credit_name(credit, position, artist, name, join_phrase)` and `recording(id, gid, name, artist_credit, …)` were each read out of the dump. `release_group` shares `recording`'s first four columns and is the one layout still taken on trust; `ErrDumpShape` turns a wrong guess into a loud failure rather than a quietly wrong catalog. **Still unrun: the ingest against the real 7.1 GB dump.** Everything is covered by tests over a synthetic tar, which cannot catch a surprise in the other ~35M rows. ### Phase 1 — Ingest credits on CI New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with the rest of `dumpimport.go`'s stages. **Constraint from `b98840e`:** `cmd/indexbuild` is built `CGO_ENABLED=0` in a plain `golang` container and must not reach the Wails `application` package — `TestIndexToolsDoNotImportWails` walks `go list -deps -tags indexbuild`. Nothing here should need it, but a new `ServiceStartup` hook on a package this imports is how it comes back. Go's `compress/bzip2` is pure Go and decompress-only, which is all this needs. **Measured, 20260815 export.** Tar members are **alphabetical**, and that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and `artist_credit_name` (237 MB) all fall inside the first ~900 MB compressed, while `recording` and `release_group` come later. So the maps are complete before the rows that consume them arrive, and no recording data is ever buffered. Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed / 8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) — **~13.7 min** for the whole file single-threaded, and less because the stream can stop after `release_group` rather than reading the `series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle dominates, as it already does for every other dump here. Do not, however, *depend* on the ordering: assert it and fall back to buffering if a future export reorders, rather than silently emitting nothing. - `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB) - `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B = ~200 MB) - `recording` / `release_group` -> emit `gid -> credit_id` **only for MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B = ~22 MB), which is what keeps 35M rows from being held Peak ~300 MB, one sequential pass. **Only multi-artist credits are stored.** A single-artist credit is `(name, "")` and is already fully described by `explore_index`'s `artist_name` / `artist_mbid`; storing it would triple the table for nothing. Post-filter after loading, once the row count per credit is known. New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails the build — both are `Cache`, matching `explore_index`): ``` artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase) ``` with `explore_index.artist_credit_id` as the link. Credits are **shared** — an album's twelve tracks by one artist share one credit row — which is the opposite of 013's local verdict, and correctly so: 1:1 in a local library, genuinely many-to-one at 2M-row catalog scale. ### Phase 2 — Ship them in the artifact `cmd/indexexport` currently creates exactly two tables in the artifact (`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`), so this is a structural addition, not a column. Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit, at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a ~0.6 GB install that is acceptable; it must be measured rather than assumed before merge. `artifactimport.go` must read it **only if present**, on the writer handle where `core` is attached — the `artifactHasTotals()` / `artifactStoresText()` pattern (`artifactimport.go:145-175`), one step up from a column to a table. An artifact published before this exists is still a perfectly good catalog and must import as one that declines to answer. Adding this to the importer's SELECT list without the probe is how every already-published artifact starts failing. `artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync with the exporter by `TestArtifactColumnsMatchExporter`. ### Phase 3 — Materialize locally ``` file_artists(audio_file_id, position, artist_id, credited_name, join_phrase) ``` `credited_name` is stored **per row**, not looked up from `artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is the whole point. Filled at scan/import time by joining `audio_files.recording_mbid` against the catalog. **Materialized rather than resolved live**, because the catalog is a downloaded artifact that can be absent or still arriving — that is why `ShelfPage.State` has a `no-index` value — and a library whose track rows lose their artists when the catalog is missing is worse than today. That implies a backfill for the case where the catalog arrives *after* the library was scanned. It registers with `jobs` (progress, cancel) like every other long pass, and takes a **distinct kind** from `index-build`, since `job-controls.ts` keys its "you will discard hours of downloading" confirmation on that kind. `artists` gains rows for guests who own no files. **This changes what the artists grid shows** and is an open question below. ### Phase 4 — Render `utils/explore-link.ts` gains a credit-rendering entry point taking ordered parts and returning a `TemplateResult`. Every row and detail view already renders artist names through it, so they inherit multi-artist links without individually knowing credits exist — the property that made centralising it worthwhile. Its existing fallback philosophy already covers the no-parts case: "a list where some rows are clickable and others silently are not reads as a bug, not as a statement about metadata." Where there are no parts (no recording MBID, or no catalog row — ~4% of the test library) render today's behaviour: the flat `artist_credit` string with one link to the primary artist. **Do not split the string there.** There is genuinely no information to split on, and that is the one place the temptation returns. `primaryArtist()` stays exactly as it is. It remains the fallback and is still what `artist_id` means. ## Open questions 1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3 multi-artist files). Rendering the catalog's decomposition is what makes names navigable; preserving the file's is what makes the app reflect the user's files. Leaning toward: render the catalog decomposition, keep `artist_credit` as the fallback string. Wants a deliberate decision, not an accident. 2. **Do guest artists appear in the artists grid?** Phase 3 creates `artists` rows for people who own no files. The grid currently means "artists in your library" and joins `audio_files`. A guest on one track is arguably in the library and arguably not. Whichever way, the ownership question stays "is there a file" — that rule does not bend. 3. **`release_group` credits** are ingested in the same pass for nearly nothing, but album-artist rendering is a separate surface. Ship the data in phase 1, render in a follow-up rather than widening phase 4. 4. **Our own `tagwriter`** does not write `ARTISTS` or multiple `MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the very field this rests on — the same shape as the existing track-totals note. Out of scope here; worth recording. ## Verification - Coverage: re-run the library probe and assert `file_artists` is populated for ~13% of files, not ~0.9%. - `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new tables. - `TestIndexToolsDoNotImportWails` still passes with the new stage. - An artifact **without** the credits table imports cleanly (the `artifactHasTotals` regression shape). - Round-trip: a known multi-artist recording renders each name as a separate link with the correct join phrases between them.