feat: extract MusicBrainz IDs from audio tags and store in library DB

Migration 13 adds nullable mbid TEXT columns to artists,
release_groups, and recordings with partial indexes.

Metadata extraction (tags.go) now reads MusicBrainz IDs from Raw()
tags — handles both Vorbis (musicbrainz_artistid) and ID3v2
(MusicBrainz Artist Id) key formats.

Scan pipeline (library.go) updates MBIDs after entity upsert via
raw SQL UPDATE. Only sets mbid if currently NULL (preserves existing
values on rescan).

LibraryMBIDIndex (librarymbid.go) provides:
- CheckMBIDs: batch lookup for 'In Library' badges
- GetArtistMBID: single artist name→MBID lookup
- AllArtistMBIDs: full dump for search index Tier 3

MBIDs will be populated on next library rescan. Existing files
need a rescan to backfill.
This commit is contained in:
2026-03-26 09:24:39 -04:00
parent 806de8fd45
commit b941057a46
3 changed files with 229 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
package explore
import (
"yellowjacket/backend/database"
)
// LibraryMBIDIndex provides fast MBID lookups against the local
// music library. Used for "In Library" badges on explore search
// results and for sharing artist images with local views.
type LibraryMBIDIndex struct {
db *database.DB
}
// NewLibraryMBIDIndex creates a library MBID lookup service.
func NewLibraryMBIDIndex(db *database.DB) *LibraryMBIDIndex {
return &LibraryMBIDIndex{db: db}
}
// CheckMBIDs returns which of the given MBIDs exist in the local
// library. The returned map has MBID → entity type ("artist",
// "release_group", or "recording").
func (idx *LibraryMBIDIndex) CheckMBIDs(mbids []string) map[string]string {
if len(mbids) == 0 {
return nil
}
result := make(map[string]string, len(mbids))
// Check each table. For a small number of MBIDs this is fine.
// For bulk checks we'd use a temp table join, but search results
// are capped at ~30 MBIDs total.
for _, mbid := range mbids {
if mbid == "" {
continue
}
// Check artists.
if idx.exists("artists", mbid) {
result[mbid] = "artist"
continue
}
// Check release groups.
if idx.exists("release_groups", mbid) {
result[mbid] = "release_group"
continue
}
// Check recordings.
if idx.exists("recordings", mbid) {
result[mbid] = "recording"
}
}
return result
}
// GetArtistMBID returns the MBID for a local artist by name, or "".
func (idx *LibraryMBIDIndex) GetArtistMBID(artistName string) string {
rows, err := idx.db.QueryContext(
"SELECT mbid FROM artists WHERE name = ? AND mbid IS NOT NULL LIMIT 1",
artistName,
)
if err != nil {
return ""
}
defer func() { _ = rows.Close() }()
if rows.Next() {
var mbid string
if err := rows.Scan(&mbid); err == nil {
return mbid
}
}
return ""
}
// AllArtistMBIDs returns all (name, mbid) pairs for artists that
// have MBIDs. Used by the search index Tier 3 for direct matching.
func (idx *LibraryMBIDIndex) AllArtistMBIDs() map[string]string {
rows, err := idx.db.QueryContext(
"SELECT name, mbid FROM artists WHERE mbid IS NOT NULL AND mbid != ''",
)
if err != nil {
return nil
}
defer func() { _ = rows.Close() }()
result := make(map[string]string)
for rows.Next() {
var name, mbid string
if err := rows.Scan(&name, &mbid); err == nil {
result[name] = mbid
}
}
return result
}
func (idx *LibraryMBIDIndex) exists(table, mbid string) bool {
//nolint:gosec // table name is hardcoded from internal callers only
rows, err := idx.db.QueryContext(
"SELECT 1 FROM "+table+" WHERE mbid = ? LIMIT 1",
mbid,
)
if err != nil {
return false
}
defer func() { _ = rows.Close() }()
return rows.Next()
}
+50
View File
@@ -1234,9 +1234,59 @@ func (l *Library) processMetadata(
}
}
// 7. Update MusicBrainz IDs (if present in tags).
if releaseGroupID.Valid {
l.updateMBIDs(cache, tags, artistName, releaseGroupID.Int64, recording.ID)
} else {
l.updateMBIDs(cache, tags, artistName, 0, recording.ID)
}
return recording.ID, nil
}
// updateMBIDs writes MusicBrainz IDs from audio file tags to the
// corresponding database entities. Uses raw SQL since the sqlc
// queries predate the mbid columns. Skips silently if tags have
// no MBIDs.
func (l *Library) updateMBIDs(
cache *entityCache,
tags *metadata.TrackMetadata,
artistName string,
releaseGroupID int64,
recordingID int64,
) {
// Artist MBID — prefer album artist, fall back to track artist.
artistMBID := tags.AlbumArtistMBID
if artistMBID == "" {
artistMBID = tags.ArtistMBID
}
if artistMBID != "" {
if artist, ok := cache.artists[artistName]; ok {
_, _ = l.db.ExecContext(
"UPDATE artists SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
artistMBID, artist.ID,
)
}
}
// Release group MBID.
if tags.ReleaseGroupMBID != "" && releaseGroupID > 0 {
_, _ = l.db.ExecContext(
"UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
tags.ReleaseGroupMBID, releaseGroupID,
)
}
// Recording MBID.
if tags.RecordingMBID != "" && recordingID > 0 {
_, _ = l.db.ExecContext(
"UPDATE recordings SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
tags.RecordingMBID, recordingID,
)
}
}
// processCoverArt saves cover art to disk and upserts the DB record,
// using the cache to skip work for previously seen images. When
// thumbChan is non-nil, thumbnail generation is dispatched to the
+60
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strings"
"github.com/dhowden/tag"
)
@@ -30,6 +31,13 @@ type TrackMetadata struct {
Lyrics string
Comment string
// MusicBrainz IDs (from tags, may be empty)
ArtistMBID string
AlbumArtistMBID string
ReleaseGroupMBID string
ReleaseMBID string
RecordingMBID string
// Cover art (if present)
Picture *PictureData
@@ -90,6 +98,9 @@ func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
FileFormat: string(m.FileType()),
}
// Extract MusicBrainz IDs from raw tags.
extractMBIDs(m.Raw(), meta)
// Extract picture if present
if pic := m.Picture(); pic != nil {
meta.Picture = &PictureData{
@@ -101,3 +112,52 @@ func ExtractTagsFromReader(r io.ReadSeeker) (*TrackMetadata, error) {
return meta, nil
}
// mbidTagKeys maps TrackMetadata field names to the possible raw
// tag keys across formats (ID3v2 TXXX, Vorbis, MP4). All keys
// are lowercased for case-insensitive matching.
var mbidTagKeys = map[string][]string{
"ArtistMBID": {"musicbrainz_artistid", "musicbrainz artist id"},
"AlbumArtistMBID": {"musicbrainz_albumartistid", "musicbrainz album artist id"},
"ReleaseGroupMBID": {"musicbrainz_releasegroupid", "musicbrainz release group id"},
"ReleaseMBID": {"musicbrainz_albumid", "musicbrainz album id"},
"RecordingMBID": {"musicbrainz_trackid", "musicbrainz recording id"},
}
// extractMBIDs populates the MBID fields of meta from the raw tag
// map. Handles varying key names across ID3v2, Vorbis, and MP4.
func extractMBIDs(raw map[string]interface{}, meta *TrackMetadata) {
if len(raw) == 0 {
return
}
// Build a lowercased key → value map for case-insensitive lookup.
normalized := make(map[string]string, len(raw))
for k, v := range raw {
if s, ok := v.(string); ok {
normalized[strings.ToLower(k)] = s
}
}
for field, keys := range mbidTagKeys {
for _, key := range keys {
if val, ok := normalized[key]; ok && val != "" {
switch field {
case "ArtistMBID":
meta.ArtistMBID = val
case "AlbumArtistMBID":
meta.AlbumArtistMBID = val
case "ReleaseGroupMBID":
meta.ReleaseGroupMBID = val
case "ReleaseMBID":
meta.ReleaseMBID = val
case "RecordingMBID":
meta.RecordingMBID = val
}
break
}
}
}
}