A track credited to more than one artist has exactly one navigable artist in this app and the rest are punctuation. `primaryArtist()` string-parses the credit, strips a " feat. " clause and discards the guest; it deliberately does not split on "&", "with" or "," because those live inside real artist names. Measured on a real 26,069-file library plus an 80+80 MusicBrainz sample: 13% of recordings are multi-artist upstream, while only 0.86% of files carry any structured multi-artist tag — mp3 carries zero files with multiple MUSICBRAINZ_ARTISTID across 19,840. Of 1,286 files saying "feat.", 90% have nothing structured behind it, and a sample of 80 such files was multi-artist in MB 80 times out of 80. CLAUDE.md justified plan 013's removal of the credit tables with "3 credits of 2,823 listed more than one artist". That measured our own *writer* — cachedLinkArtist was called once per credit, so a collaboration could never have been recorded. Dropping the join table was still right on cost; the evidence for "multi-artist is rare" was not. A credit is ordered parts and the credit string is derived from them, so join phrases are assembly instructions, not disassembly ones. Nothing here reconstructs a credit by searching a name inside a credit string: the stored text may come from tags while the parts come from the catalog, and those disagree for ~1 in 3 multi-artist credits. Where it comes from, after two dead ends: the canonical dump CI already streams has no join phrases and no as-credited names, and the JSON dumps cover 153,691 recordings of ~35M with *zero* overlap against a real library. So mbdump.tar.bz2 — 7.1 GB, ~13.7 min in pure-Go bzip2, whose members are alphabetical, which is what lets one pass resolve an entity's credit without buffering 35M recordings. - artist_credit_part / artist_credit_ref, multi-artist credits only: a single-artist credit is already explore_index's own artist_name. - Column layouts verified against the real 20260815 export; ErrDumpShape makes a wrong guess a failed build, not a wrong catalog. - The pass runs on every mode, not just a build. The job picks its mode from the index's own state, and a complete import means "refresh", which never enters the importer — so credits could otherwise only arrive via a rebuild that re-downloads ~205 GB. It reports whether it populated anything, which is what flips `changed` and republishes. - The importer asks whether an artifact carries the tables, on the writer where `core` is attached, so the artifact already published still imports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
133 lines
4.2 KiB
Go
133 lines
4.2 KiB
Go
package explore
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Reading multi-artist credits back out of the catalog.
|
|
//
|
|
// The tables are filled centrally (backend/explore/dumpcredits.go, and
|
|
// the artifact import) and hold only credits naming more than one
|
|
// artist: an entity with no rows here is credited to one artist, which
|
|
// explore_index's own artist_name and artist_mbid already describe.
|
|
// Absence is the common case and means "nothing to decompose", never
|
|
// "unknown".
|
|
//
|
|
// The lookup is keyed on the *recording* MBID, which both sides of the
|
|
// app already have -- a catalog row carries it and so does a local
|
|
// file (library.Track.RecordingMBID) -- so one query serves the Explore
|
|
// pages and the library's own lists without either needing to know
|
|
// where the other gets its rows.
|
|
|
|
// CreditPart is one credited artist within a credit, in credit order.
|
|
//
|
|
// CreditedName is 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". Display uses it; navigation uses
|
|
// ArtistMBID. JoinPhrase is the literal connector that follows this
|
|
// part, so a credit renders by concatenation and never by searching a
|
|
// name inside a credit string.
|
|
type CreditPart struct {
|
|
Position int `json:"position"`
|
|
ArtistMBID string `json:"artistMbid"`
|
|
CreditedName string `json:"creditedName"`
|
|
JoinPhrase string `json:"joinPhrase"`
|
|
}
|
|
|
|
// creditLookupBatch bounds how many MBIDs go into one IN clause. A
|
|
// tracklist is the caller here, so the realistic ceiling is a few
|
|
// hundred; the bound exists so a 50,000-row selection cannot build a
|
|
// statement SQLite refuses to parse.
|
|
const creditLookupBatch = 500
|
|
|
|
// GetCredits returns the decomposition of every multi-artist credit
|
|
// among the given entity MBIDs, keyed by MBID.
|
|
//
|
|
// MBIDs with a single-artist credit are simply absent from the result,
|
|
// which is what the caller wants: it renders its existing single link
|
|
// for those, and that is the same answer it would have rendered anyway.
|
|
func (si *SearchIndex) GetCredits(mbids []string) (map[string][]CreditPart, error) {
|
|
out := make(map[string][]CreditPart)
|
|
|
|
for start := 0; start < len(mbids); start += creditLookupBatch {
|
|
end := min(start+creditLookupBatch, len(mbids))
|
|
|
|
if err := si.appendCredits(mbids[start:end], out); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// appendCredits runs one batch into the accumulating result.
|
|
func (si *SearchIndex) appendCredits(
|
|
mbids []string, out map[string][]CreditPart,
|
|
) error {
|
|
args := make([]any, 0, len(mbids))
|
|
holders := make([]string, 0, len(mbids))
|
|
|
|
for _, mbid := range mbids {
|
|
if mbid == "" {
|
|
continue
|
|
}
|
|
|
|
args = append(args, dbMBID(mbid))
|
|
holders = append(holders, "?")
|
|
}
|
|
|
|
if len(args) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Ordered by position because that ordering *is* the credit's
|
|
// meaning; the caller concatenates in the order it receives.
|
|
rows, err := si.db.QueryContext(
|
|
`SELECT r.mbid, p.position, p.artist_mbid, p.credited_name, p.join_phrase
|
|
FROM artist_credit_ref r
|
|
JOIN artist_credit_part p ON p.credit_id = r.credit_id
|
|
WHERE r.mbid IN (`+strings.Join(holders, ",")+`)
|
|
ORDER BY r.mbid, p.position`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("read artist credits: %w", err)
|
|
}
|
|
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
entity dbMBID
|
|
artist dbMBID
|
|
part CreditPart
|
|
)
|
|
|
|
if err := rows.Scan(
|
|
&entity, &part.Position, &artist, &part.CreditedName, &part.JoinPhrase,
|
|
); err != nil {
|
|
return fmt.Errorf("scan artist credit: %w", err)
|
|
}
|
|
|
|
part.ArtistMBID = string(artist)
|
|
out[string(entity)] = append(out[string(entity)], part)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("read artist credits: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetCredits is the bound form: the frontend asks for a tracklist's
|
|
// worth of MBIDs at once rather than one per row.
|
|
//
|
|
// Batched for the reason every other per-row backend question here is:
|
|
// asking on hover or on render turns a list into N IPC round trips, and
|
|
// this one is asked about every row of every list in the app.
|
|
func (e *Service) GetCredits(mbids []string) (map[string][]CreditPart, error) {
|
|
return e.index.GetCredits(mbids)
|
|
}
|