Koito/internal/mbz/mock.go
safierinx-a 0855fb4bd7 Add MusicBrainz search-by-name enrichment for scrobbles without IDs
When a listen arrives with no MBZ IDs and no album title (the common
multi-scrobbler/Last.fm case), search MusicBrainz by artist+track name
to resolve recording, release, and release group IDs. This unlocks
CoverArtArchive album art, proper album association, and duration data.

New file: internal/mbz/search.go
- SearchRecording() method with Lucene query escaping
- Confidence filter: case-insensitive exact match on title + artist credit
- Release selection: prefer Official status, then first available
- Uses existing rate-limited queue (1 req/sec)

Integration in catalog.go:
- Only triggers when RecordingMbzID, ReleaseMbzID, AND ReleaseTitle are
  all missing — no impact on scrobbles that already have MBZ data
- Soft failure — search errors don't block the listen
- KOITO_DISABLE_MUSICBRAINZ handled automatically (MbzErrorCaller returns error)

Interface + mocks updated:
- SearchRecording added to MusicBrainzCaller interface
- MbzMockCaller: SearchResults map for test data
- MbzErrorCaller: returns error (existing pattern)

New tests:
- TestSubmitListen_SearchByName — mock search, verify album+duration resolved
- TestSubmitListen_SearchByNameNoMatch — verify graceful fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:01:24 +05:30

110 lines
3.2 KiB
Go

package mbz
import (
"context"
"fmt"
"slices"
"github.com/google/uuid"
)
// implements a mock caller
type MbzMockCaller struct {
Artists map[uuid.UUID]*MusicBrainzArtist
ReleaseGroups map[uuid.UUID]*MusicBrainzReleaseGroup
Releases map[uuid.UUID]*MusicBrainzRelease
Tracks map[uuid.UUID]*MusicBrainzTrack
SearchResults map[string]*MusicBrainzSearchResult
}
func (m *MbzMockCaller) GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error) {
releaseGroup, exists := m.ReleaseGroups[id]
if !exists {
return nil, fmt.Errorf("release group with ID %s not found", id)
}
return releaseGroup, nil
}
func (m *MbzMockCaller) GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error) {
release, exists := m.Releases[id]
if !exists {
return nil, fmt.Errorf("release group with ID %s not found", id)
}
return release, nil
}
func (m *MbzMockCaller) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
rg, exists := m.ReleaseGroups[RGID]
if !exists {
return nil, fmt.Errorf("release with ID %s not found", RGID)
}
var titles []string
for _, release := range rg.Releases {
if !slices.Contains(titles, release.Title) {
titles = append(titles, release.Title)
}
}
return titles, nil
}
func (m *MbzMockCaller) GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error) {
track, exists := m.Tracks[id]
if !exists {
return nil, fmt.Errorf("track with ID %s not found", id)
}
return track, nil
}
func (m *MbzMockCaller) GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error) {
artist, exists := m.Artists[id]
if !exists {
return nil, fmt.Errorf("artist with ID %s not found", id)
}
name := artist.Name
ss := make([]string, len(artist.Aliases)+1)
ss[0] = name
for i, alias := range artist.Aliases {
ss[i+1] = alias.Name
}
return ss, nil
}
func (m *MbzMockCaller) SearchRecording(ctx context.Context, artist string, track string) (*MusicBrainzSearchResult, error) {
key := artist + "\x00" + track
if result, exists := m.SearchResults[key]; exists {
return result, nil
}
return nil, nil
}
func (m *MbzMockCaller) Shutdown() {}
type MbzErrorCaller struct{}
func (m *MbzErrorCaller) GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error) {
return nil, fmt.Errorf("error: GetReleaseGroup not implemented")
}
func (m *MbzErrorCaller) GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error) {
return nil, fmt.Errorf("error: GetRelease not implemented")
}
func (m *MbzErrorCaller) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("error: GetReleaseTitles not implemented")
}
func (m *MbzErrorCaller) GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error) {
return nil, fmt.Errorf("error: GetTrack not implemented")
}
func (m *MbzErrorCaller) GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("error: GetArtistPrimaryAliases not implemented")
}
func (m *MbzErrorCaller) SearchRecording(ctx context.Context, artist string, track string) (*MusicBrainzSearchResult, error) {
return nil, fmt.Errorf("error: SearchRecording not implemented")
}
func (m *MbzErrorCaller) Shutdown() {}