diff --git a/backend/explore/librarymbid.go b/backend/explore/librarymbid.go new file mode 100644 index 0000000..ac368bb --- /dev/null +++ b/backend/explore/librarymbid.go @@ -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() +} diff --git a/backend/library/library.go b/backend/library/library.go index ca85bd5..a7f84f5 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -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 diff --git a/backend/metadata/tags.go b/backend/metadata/tags.go index 0d01d90..fd41732 100644 --- a/backend/metadata/tags.go +++ b/backend/metadata/tags.go @@ -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 + } + } + } +}