diff --git a/internal/library/mbids.go b/internal/library/mbids.go new file mode 100644 index 00000000..a1064684 --- /dev/null +++ b/internal/library/mbids.go @@ -0,0 +1,59 @@ +package library + +// MusicBrainz tag keys vary by audio format. dhowden/tag's Raw() map exposes +// the format-specific entries as-is — so we look up multiple candidates and +// take the first non-empty match. +// +// References: +// - ID3v2 (MP3): TXXX frames keyed by description. +// - Vorbis (FLAC/OGG): all-caps comment names. +// - MP4 (M4A): iTunes freeform atoms ("----:com.apple.iTunes:..."). + +var albumMBIDKeys = []string{ + "TXXX:MusicBrainz Album Id", + "MUSICBRAINZ_ALBUMID", + "----:com.apple.iTunes:MusicBrainz Album Id", +} + +var artistMBIDKeys = []string{ + "TXXX:MusicBrainz Artist Id", + "MUSICBRAINZ_ARTISTID", + "----:com.apple.iTunes:MusicBrainz Artist Id", +} + +// extractMBIDs reads MusicBrainz album and artist IDs from a tag.Metadata +// Raw() map. Returns ("", "") when the keys are absent. Multi-value keys +// (some formats list multiple artist IDs for collaborations) take only the +// first value — Minstrel uses the primary release artist for MBCAA lookup. +func extractMBIDs(raw map[string]interface{}) (albumMBID, artistMBID string) { + albumMBID = firstStringValue(raw, albumMBIDKeys) + artistMBID = firstStringValue(raw, artistMBIDKeys) + return +} + +func firstStringValue(raw map[string]interface{}, keys []string) string { + for _, k := range keys { + v, ok := raw[k] + if !ok || v == nil { + continue + } + switch x := v.(type) { + case string: + if x != "" { + return x + } + case []string: + if len(x) > 0 && x[0] != "" { + return x[0] + } + case []interface{}: + if len(x) == 0 { + continue + } + if s, ok := x[0].(string); ok && s != "" { + return s + } + } + } + return "" +} diff --git a/internal/library/mbids_test.go b/internal/library/mbids_test.go new file mode 100644 index 00000000..00c92e21 --- /dev/null +++ b/internal/library/mbids_test.go @@ -0,0 +1,69 @@ +package library + +import "testing" + +func TestExtractMBIDs_ID3v2(t *testing.T) { + raw := map[string]interface{}{ + "TXXX:MusicBrainz Album Id": "abc-123", + "TXXX:MusicBrainz Artist Id": "def-456", + } + a, ar := extractMBIDs(raw) + if a != "abc-123" || ar != "def-456" { + t.Errorf("got (%q, %q), want (\"abc-123\", \"def-456\")", a, ar) + } +} + +func TestExtractMBIDs_Vorbis(t *testing.T) { + raw := map[string]interface{}{ + "MUSICBRAINZ_ALBUMID": "abc", + "MUSICBRAINZ_ARTISTID": "def", + } + a, ar := extractMBIDs(raw) + if a != "abc" || ar != "def" { + t.Errorf("got (%q, %q)", a, ar) + } +} + +func TestExtractMBIDs_MP4(t *testing.T) { + raw := map[string]interface{}{ + "----:com.apple.iTunes:MusicBrainz Album Id": "abc", + "----:com.apple.iTunes:MusicBrainz Artist Id": "def", + } + a, ar := extractMBIDs(raw) + if a != "abc" || ar != "def" { + t.Errorf("got (%q, %q)", a, ar) + } +} + +func TestExtractMBIDs_MissingKeysReturnEmpty(t *testing.T) { + a, ar := extractMBIDs(map[string]interface{}{}) + if a != "" || ar != "" { + t.Errorf("got (%q, %q), want empty", a, ar) + } +} + +func TestExtractMBIDs_MultiValueArtistTakesFirst(t *testing.T) { + // Some FLAC tags list collaborators as multi-value MUSICBRAINZ_ARTISTID. + // Minstrel uses the primary release artist; first wins. + raw := map[string]interface{}{ + "MUSICBRAINZ_ARTISTID": []string{"primary-mbid", "collab-mbid"}, + } + _, ar := extractMBIDs(raw) + if ar != "primary-mbid" { + t.Errorf("got %q, want %q", ar, "primary-mbid") + } +} + +func TestExtractMBIDs_NilEntryIgnored(t *testing.T) { + raw := map[string]interface{}{ + "TXXX:MusicBrainz Album Id": nil, + "TXXX:MusicBrainz Artist Id": "real-id", + } + a, ar := extractMBIDs(raw) + if a != "" { + t.Errorf("nil album entry should be ignored, got %q", a) + } + if ar != "real-id" { + t.Errorf("artist = %q", ar) + } +} diff --git a/internal/library/scanner.go b/internal/library/scanner.go index fc095265..42b06485 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -131,6 +131,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta if err != nil { return fmt.Errorf("tag read: %w", err) } + albumMBID, artistMBID := extractMBIDs(meta.Raw()) artistName := meta.Artist() if artistName == "" { @@ -145,11 +146,11 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta trackTitle = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) } - artist, err := s.resolveArtist(ctx, q, artistName) + artist, err := s.resolveArtist(ctx, q, artistName, artistMBID) if err != nil { return fmt.Errorf("artist: %w", err) } - album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year()) + album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID) if err != nil { return fmt.Errorf("album: %w", err) } @@ -197,7 +198,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta return nil } -func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name string) (dbq.Artist, error) { +func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) { existing, err := q.GetArtistByName(ctx, name) if err == nil { return existing, nil @@ -205,13 +206,18 @@ func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name string if !errors.Is(err, pgx.ErrNoRows) { return dbq.Artist{}, err } - return q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + params := dbq.UpsertArtistParams{ Name: name, SortName: sortKey(name), - }) + } + if mbid != "" { + m := mbid + params.Mbid = &m + } + return q.UpsertArtist(ctx, params) } -func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int) (dbq.Album, error) { +func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int, mbid string) (dbq.Album, error) { existing, err := q.GetAlbumByArtistAndTitle(ctx, dbq.GetAlbumByArtistAndTitleParams{ArtistID: artistID, Title: title}) if err == nil { return existing, nil @@ -233,6 +239,10 @@ func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgt s.logger.Warn("library scan: dropping invalid release year", "year", year, "album", title) } + if mbid != "" { + m := mbid + params.Mbid = &m + } return q.UpsertAlbum(ctx, params) }