mirror of
https://github.com/gabehf/Koito.git
synced 2026-04-22 12:01:52 -07:00
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:
parent
0ec7b458cc
commit
2c29499403
5 changed files with 304 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1037,3 +1037,95 @@ func TestSubmitListen_MusicBrainzUnreachableMBIDMappings(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected artist to have correct musicbrainz id")
|
||||
}
|
||||
|
||||
func TestSubmitListen_SearchByName(t *testing.T) {
|
||||
truncateTestData(t)
|
||||
|
||||
// When no MBZ IDs and no release title are provided,
|
||||
// SearchRecording should be called to resolve them
|
||||
|
||||
ctx := context.Background()
|
||||
recordingID := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000001")
|
||||
releaseID := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000002")
|
||||
releaseGroupID := uuid.MustParse("aaaaaaaa-0000-0000-0000-000000000003")
|
||||
|
||||
mbzc := &mbz.MbzMockCaller{
|
||||
SearchResults: map[string]*mbz.MusicBrainzSearchResult{
|
||||
"Some Artist\x00Some Track": {
|
||||
RecordingID: recordingID,
|
||||
ReleaseID: releaseID,
|
||||
ReleaseGroupID: releaseGroupID,
|
||||
ReleaseTitle: "Resolved Album",
|
||||
DurationMs: 240000,
|
||||
},
|
||||
},
|
||||
Releases: map[uuid.UUID]*mbz.MusicBrainzRelease{
|
||||
releaseID: {
|
||||
Title: "Resolved Album",
|
||||
ID: releaseID.String(),
|
||||
Status: "Official",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
opts := catalog.SubmitListenOpts{
|
||||
MbzCaller: mbzc,
|
||||
ArtistNames: []string{"Some Artist"},
|
||||
Artist: "Some Artist",
|
||||
TrackTitle: "Some Track",
|
||||
Time: time.Now(),
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
err := catalog.SubmitListen(ctx, store, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that the album was resolved from search
|
||||
album, err := store.GetAlbum(ctx, db.GetAlbumOpts{MusicBrainzID: releaseID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Resolved Album", album.Title)
|
||||
|
||||
// Verify the track was created with duration from search
|
||||
track, err := store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: recordingID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Some Track", track.Title)
|
||||
assert.EqualValues(t, 240, track.Duration)
|
||||
}
|
||||
|
||||
func TestSubmitListen_SearchByNameNoMatch(t *testing.T) {
|
||||
truncateTestData(t)
|
||||
|
||||
// When search returns no match, the listen should still be created
|
||||
// with a fallback album title
|
||||
|
||||
ctx := context.Background()
|
||||
mbzc := &mbz.MbzMockCaller{
|
||||
SearchResults: map[string]*mbz.MusicBrainzSearchResult{},
|
||||
}
|
||||
|
||||
opts := catalog.SubmitListenOpts{
|
||||
MbzCaller: mbzc,
|
||||
ArtistNames: []string{"Unknown Artist"},
|
||||
Artist: "Unknown Artist",
|
||||
TrackTitle: "Unknown Track",
|
||||
Time: time.Now(),
|
||||
UserID: 1,
|
||||
}
|
||||
|
||||
err := catalog.SubmitListen(ctx, store, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the listen was saved even without search results
|
||||
exists, err := store.RowExists(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM listens
|
||||
WHERE track_id = $1
|
||||
)`, 1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "expected listen row to exist")
|
||||
|
||||
// Artist should still be created
|
||||
artist, err := store.GetArtist(ctx, db.GetArtistOpts{Name: "Unknown Artist"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Unknown Artist", artist.Name)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue