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
165 lines
4.3 KiB
Go
165 lines
4.3 KiB
Go
//go:build indexbuild
|
|
|
|
package explore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
// writeCredits persists the scanned decompositions.
|
|
//
|
|
// Only credits some catalog entity actually points at are written: the
|
|
// dump has millions of multi-artist credits and the catalog keeps ~1.8M
|
|
// entities, so storing every credit would be most of a table nothing
|
|
// can reach.
|
|
//
|
|
// The two tables are written in one transaction, because a ref pointing
|
|
// at parts that are not there renders as a credit with no artists --
|
|
// worse than the single-artist fallback it replaced.
|
|
func (imp *dumpImporter) writeCredits(ctx context.Context, scan *creditScan) error {
|
|
tx, err := imp.si.db.BeginTx()
|
|
if err != nil {
|
|
return fmt.Errorf("credit import: begin: %w", err)
|
|
}
|
|
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
// A rebuild replaces the previous pass wholesale. These are Cache
|
|
// tables derived entirely from the dump, so there is nothing to
|
|
// merge and a stale row is a wrong credit.
|
|
for _, table := range []string{"artist_credit_part", "artist_credit_ref"} {
|
|
if _, err := tx.ExecContext(ctx, "DELETE FROM "+table); err != nil {
|
|
return fmt.Errorf("credit import: clear %s: %w", table, err)
|
|
}
|
|
}
|
|
|
|
written, err := imp.writeCreditParts(ctx, tx, scan)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
refs, err := imp.writeCreditRefs(ctx, tx, scan, written)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("credit import: commit: %w", err)
|
|
}
|
|
|
|
imp.logger.Info("credit import: complete",
|
|
"credits", len(written),
|
|
"refs", refs,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// writeCreditParts inserts the parts of every used credit and returns
|
|
// the set of credits that were actually stored.
|
|
//
|
|
// A credit is stored whole or not at all. If any of its artists has no
|
|
// MBID -- which should not happen, the dump being self-consistent, but
|
|
// would leave a part that cannot be navigated to -- the credit is
|
|
// dropped and the entity falls back to explore_index's single artist,
|
|
// which is a worse answer rather than a broken one.
|
|
func (imp *dumpImporter) writeCreditParts(
|
|
ctx context.Context, tx *sql.Tx, scan *creditScan,
|
|
) (map[int32]struct{}, error) {
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
`INSERT INTO artist_credit_part
|
|
(credit_id, position, artist_mbid, credited_name, join_phrase)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("credit import: prepare part insert: %w", err)
|
|
}
|
|
|
|
defer func() { _ = stmt.Close() }()
|
|
|
|
written := make(map[int32]struct{}, len(scan.used))
|
|
|
|
for credit := range scan.used {
|
|
parts := scan.parts[credit]
|
|
if len(parts) < 2 {
|
|
// artist_credit said more than one artist and
|
|
// artist_credit_name did not deliver them. Nothing to
|
|
// decompose, so leave the entity to its single artist.
|
|
continue
|
|
}
|
|
|
|
// Position order is the credit's meaning, and the dump is not
|
|
// obliged to emit it sorted.
|
|
sort.Slice(parts, func(i, j int) bool {
|
|
return parts[i].position < parts[j].position
|
|
})
|
|
|
|
resolved := make([][]any, 0, len(parts))
|
|
ok := true
|
|
|
|
for _, part := range parts {
|
|
gid, found := scan.artistGIDs[part.artistID]
|
|
if !found {
|
|
scan.skippedUnknownArtist++
|
|
ok = false
|
|
|
|
break
|
|
}
|
|
|
|
resolved = append(resolved, []any{
|
|
credit, part.position, gid[:], part.name, part.join,
|
|
})
|
|
}
|
|
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
for _, args := range resolved {
|
|
if _, err := stmt.ExecContext(ctx, args...); err != nil {
|
|
return nil, fmt.Errorf("credit import: insert part: %w", err)
|
|
}
|
|
}
|
|
|
|
written[credit] = struct{}{}
|
|
}
|
|
|
|
return written, nil
|
|
}
|
|
|
|
// writeCreditRefs points each kept entity at its credit, skipping any
|
|
// whose credit was not stored so a ref never dangles.
|
|
func (imp *dumpImporter) writeCreditRefs(
|
|
ctx context.Context, tx *sql.Tx, scan *creditScan, written map[int32]struct{},
|
|
) (int, error) {
|
|
stmt, err := tx.PrepareContext(ctx,
|
|
"INSERT OR REPLACE INTO artist_credit_ref (mbid, credit_id) VALUES (?, ?)",
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("credit import: prepare ref insert: %w", err)
|
|
}
|
|
|
|
defer func() { _ = stmt.Close() }()
|
|
|
|
count := 0
|
|
|
|
for mbid, credit := range scan.refs {
|
|
if _, stored := written[credit]; !stored {
|
|
continue
|
|
}
|
|
|
|
id := mbid
|
|
|
|
if _, err := stmt.ExecContext(ctx, id[:], credit); err != nil {
|
|
return 0, fmt.Errorf("credit import: insert ref: %w", err)
|
|
}
|
|
|
|
count++
|
|
}
|
|
|
|
return count, nil
|
|
}
|