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>
This commit is contained in:
safierinx-a 2026-03-25 00:01:24 +05:30
parent c2d393aa03
commit 0855fb4bd7
5 changed files with 304 additions and 0 deletions

View file

@ -102,6 +102,21 @@ func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error
artistIDs[i] = artist.ID
l.Debug().Any("artist", artist).Msg("Matched listen to artist")
}
// Search MusicBrainz by name when no MBZ IDs or album title are available
if opts.RecordingMbzID == uuid.Nil && opts.ReleaseMbzID == uuid.Nil && opts.ReleaseTitle == "" {
result, err := opts.MbzCaller.SearchRecording(ctx, opts.Artist, opts.TrackTitle)
if err == nil && result != nil {
opts.RecordingMbzID = result.RecordingID
opts.ReleaseMbzID = result.ReleaseID
opts.ReleaseGroupMbzID = result.ReleaseGroupID
opts.ReleaseTitle = result.ReleaseTitle
if opts.Duration == 0 && result.DurationMs > 0 {
opts.Duration = int32(result.DurationMs / 1000)
}
}
}
rg, err := AssociateAlbum(ctx, store, AssociateAlbumOpts{
ReleaseMbzID: opts.ReleaseMbzID,
ReleaseGroupMbzID: opts.ReleaseGroupMbzID,