diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 5b9fd648f..b25b4c100 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -72,7 +73,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) @@ -93,7 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -121,7 +124,8 @@ func CreatePublicRouter() *public.Router { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) @@ -168,7 +172,8 @@ func CreateScanner(ctx context.Context) model.Scanner { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() @@ -186,7 +191,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { metricsMetrics := metrics.GetPrometheusInstance(dataStore) manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) - provider := external.NewProvider(dataStore, agentsAgents) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) imageUploadService := core.NewImageUploadService() diff --git a/core/external/provider.go b/core/external/provider.go index 40ca34069..7e8aaba1c 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -12,6 +12,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -41,6 +42,7 @@ type Provider interface { type provider struct { ds model.DataStore ag Agents + matcher *matcher.Matcher artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -85,8 +87,8 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents) Provider { - e := &provider{ds: ds, ag: agents} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { + e := &provider{ds: ds, ag: agents, matcher: m} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e @@ -300,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongsToLibrary(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -479,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go index 8a81b4f4d..e801b7cce 100644 --- a/core/external/provider_albumimage_test.go +++ b/core/external/provider_albumimage_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -43,7 +44,7 @@ var _ = Describe("Provider - AlbumImage", func() { mockAlbumAgent = newMockAlbumInfoAgent() agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks // Mocks for GetEntityByID sequence (initial failed lookups) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 529289ed3..37d3fd81a 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -51,7 +52,7 @@ var _ = Describe("Provider - ArtistImage", func() { imageAgent: mockImageAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) // Default mocks for successful Get calls mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go deleted file mode 100644 index b3624ef3a..000000000 --- a/core/external/provider_matching_test.go +++ /dev/null @@ -1,762 +0,0 @@ -package external_test - -import ( - "context" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - Song Matching", func() { - var ds model.DataStore - var provider Provider - var agentsCombined *mockAgents - var artistRepo *mockArtistRepo - var mediaFileRepo *mockMediaFileRepo - var albumRepo *mockAlbumRepo - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() - mediaFileRepo = newMockMediaFileRepo() - albumRepo = newMockAlbumRepo() - - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - MockedAlbum: albumRepo, - } - - agentsCombined = &mockAgents{} - provider = NewProvider(ds, agentsCombined) - }) - - // Shared helper for tests that only need artist track queries (no ID/MBID matching) - setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByTitleAndArtist - queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Describe("matchSongsToLibrary priority matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.SimilarSongsMatchThreshold = 100 - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) { - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(returnedSongs, nil).Once() - - // loadTracksByID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(idMatches, nil).Once() - - // loadTracksByMBID - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasMBID := eq["mbz_recording_id"] - return hasMBID - })).Return(mbidMatches, nil).Once() - - // loadTracksByTitleAndArtist - now queries by artist name - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 2 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasArtist := eq["order_artist_name"] - return hasArtist - })).Return(artistTracks, nil).Maybe() - } - - Context("when agent returns artist and album metadata", func() { - It("matches by title + artist MBID + album MBID (highest priority)", func() { - // Song in library with all MBIDs - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", - } - // Another song with same title but different MBIDs (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist name + album name when MBIDs unavailable", func() { - // Song in library without MBIDs but with matching artist/album names - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", - } - // Another song with same title but different artist (should NOT match) - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("matches by title + artist only when album info unavailable", func() { - // Song in library with matching artist - correctMatch := model.MediaFile{ - ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", - } - // Another song with same title but different artist - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - } - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Depeche Mode"}, // No album info - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-match")) - }) - - It("does not match songs without artist info", func() { - // Songs without artist info cannot be matched since we query by artist - returnedSongs := []agents.Song{ - {Name: "Similar Song"}, // No artist/album info at all - } - - // No artist to query, so no GetAll calls for title matching - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("when matching multiple songs with the same title but different artists", func() { - It("returns distinct matches for each artist's version (covers scenario)", func() { - // Multiple covers of the same song by different artists - cover1 := model.MediaFile{ - ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - cover2 := model.MediaFile{ - ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", - } - cover3 := model.MediaFile{ - ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", - } - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, - {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three covers should be returned, not just the first one - Expect(songs).To(HaveLen(3)) - // Verify all three different versions are included - ids := []string{songs[0].ID, songs[1].ID, songs[2].ID} - Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) - }) - }) - - Context("when matching multiple songs with different precision levels", func() { - It("prefers more precise matches for each song", func() { - // Library has multiple versions of same song - preciseMatch := model.MediaFile{ - ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", - MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", - } - lessAccurateMatch := model.MediaFile{ - ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", - } - artistTwoMatch := model.MediaFile{ - ID: "artist-two", Title: "Song B", Artist: "Artist Two", - } - - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artist: "Artist Two"}, // Different artist - } - - setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - // First song should be the precise match (has all MBIDs) - Expect(songs[0].ID).To(Equal("precise")) - // Second song matches by title + artist - Expect(songs[1].ID).To(Equal("artist-two")) - }) - }) - }) - - Describe("Fuzzy matching fallback", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("with default threshold (85%)", func() { - It("matches songs with remastered suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered" - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has the remastered version (fuzzy match will find it) - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("remastered")) - }) - - It("matches songs with live suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen"}, - } - artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("live")) - }) - - It("does not match completely different songs", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles"}, - } - // Artist catalog has completely different songs - artistTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, - {ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with threshold set to 100 (exact match only)", func() { - It("only matches exact titles", func() { - conf.Server.SimilarSongsMatchThreshold = 100 - - returnedSongs := []agents.Song{ - {Name: "Paranoid Android", Artist: "Radiohead"}, - } - // Artist catalog has only remastered version - no exact match - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - }) - - Context("with lower threshold (75%)", func() { - It("matches more aggressively", func() { - conf.Server.SimilarSongsMatchThreshold = 75 - - returnedSongs := []agents.Song{ - {Name: "Song", Artist: "Artist"}, - } - artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, - } - - setupSimilarSongsExpectations(returnedSongs, artistTracks) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("extended")) - }) - }) - - Context("with fuzzy album matching", func() { - It("matches album with (Remaster) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - // Agent returns "A Night at the Opera" but library has remastered version - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has same album with remaster suffix - correctMatch := model.MediaFile{ - ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches album with (Deluxe Edition) suffix", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - correctMatch := model.MediaFile{ - ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("prefers exact album match over fuzzy album match", func() { - conf.Server.SimilarSongsMatchThreshold = 85 - - returnedSongs := []agents.Song{ - {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, - } - exactMatch := model.MediaFile{ - ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - } - fuzzyMatch := model.MediaFile{ - ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - // Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity) - Expect(songs[0].ID).To(Equal("exact")) - }) - }) - }) - - Describe("Duration matching", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - Context("when agent provides duration", func() { - It("prefers tracks with matching duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has two versions: one matching duration, one not - correctMatch := model.MediaFile{ - ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, - } - wrongDuration := model.MediaFile{ - ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct")) - }) - - It("matches tracks with close duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has track with 182.5 seconds (close to target) - closeDuration := model.MediaFile{ - ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close-duration")) - }) - - It("prefers closer duration over farther duration", func() { - // Agent returns song with duration 180000ms (180 seconds) - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has one close, one far - closeDuration := model.MediaFile{ - ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, - } - farDuration := model.MediaFile{ - ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{farDuration, closeDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("close")) - }) - - It("still matches when no tracks have matching duration", func() { - // Agent returns song with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library only has tracks with very different duration - differentDuration := model.MediaFile{ - ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Duration mismatch doesn't exclude the track; it's just scored lower - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("different")) - }) - - It("prefers title match over duration match when titles differ", func() { - // Agent returns "Similar Song" with duration 180000ms - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, - } - // Library has: - // - differentTitle: matches duration but has different title (won't pass title threshold) - // - correctTitle: doesn't match duration but has correct title (wins on title similarity) - differentTitle := model.MediaFile{ - ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, - } - correctTitle := model.MediaFile{ - ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentTitle, correctTitle}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Title similarity is the top priority, so the correct title wins despite duration mismatch - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("correct-title")) - }) - }) - - Context("when agent does not provide duration", func() { - It("matches without duration filtering (duration=0)", func() { - // Agent returns song without duration - returnedSongs := []agents.Song{ - {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, - } - // Library tracks with various durations should all be candidates - anyTrack := model.MediaFile{ - ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("any")) - }) - }) - - Context("edge cases", func() { - It("handles very short songs with close duration", func() { - // 30-second song with 1-second difference - returnedSongs := []agents.Song{ - {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, - } - shortTrack := model.MediaFile{ - ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("short")) - }) - }) - }) - - Describe("Deduplication of mismatched songs", func() { - var track model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching - - track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"} - - // Setup for GetEntityByID to return the track - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - }) - - It("removes duplicates when different input songs match the same library track", func() { - // Agent returns two different versions that will both fuzzy-match to the same library track - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, - {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should only return one track, not two duplicates - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("br-live")) - }) - - It("preserves duplicates when identical input songs match the same library track", func() { - // Agent returns the exact same song twice (intentional repetition) - returnedSongs := []agents.Song{ - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, - } - // Library has matching track - libraryTrack := model.MediaFile{ - ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return two tracks since input songs were identical - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("br")) - Expect(songs[1].ID).To(Equal("br")) - }) - - It("handles mixed scenario with both identical and different input songs", func() { - // Agent returns: Song A, Song B (different from A), Song A again (same as first) - // All three match to the same library track - returnedSongs := []agents.Song{ - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version - {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first - {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version - } - // Library only has one version - libraryTrack := model.MediaFile{ - ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - } - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // Should return 2 tracks: - // 1. First "Yesterday" (original) - // 2. Third "Yesterday" (same as first, so kept) - // Skip: Second "Yesterday (Remastered)" (different input, same library track) - // Skip: Fourth "Yesterday (Anthology)" (different input, same library track) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("yesterday")) - Expect(songs[1].ID).To(Equal("yesterday")) - }) - - It("does not deduplicate songs that match different library tracks", func() { - // Agent returns different songs that match different library tracks - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song B", Artist: "Artist"}, - {Name: "Song C", Artist: "Artist"}, - } - // Library has all three songs - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC}) - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - // All three should be returned since they match different library tracks - Expect(songs).To(HaveLen(3)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - Expect(songs[2].ID).To(Equal("track-c")) - }) - - It("respects count limit after deduplication", func() { - // Agent returns 4 songs: 2 unique + 2 that would create duplicates - returnedSongs := []agents.Song{ - {Name: "Song A", Artist: "Artist"}, - {Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track - {Name: "Song B", Artist: "Artist"}, - {Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track - } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} - - setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB}) - - // Request only 2 songs - songs, err := provider.SimilarSongs(ctx, "track-1", 2) - - Expect(err).ToNot(HaveOccurred()) - // Should return exactly 2: Song A and Song B (skipping duplicates) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("track-a")) - Expect(songs[1].ID).To(Equal("track-b")) - }) - }) -}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 1491d394e..c9a1a64ef 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -7,6 +7,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -48,7 +49,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined) + provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) Describe("dispatch by entity type", func() { diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index b73c8ab3e..4bd0e5959 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -44,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag) + p = NewProvider(ds, ag, matcher.New(ds)) }) It("returns top songs for a known artist", func() { diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 5f5d41a87..3dd8a587a 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -34,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index 0c489eadd..e309ece6e 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -37,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag) + p = external.NewProvider(ds, ag, matcher.New(ds)) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) diff --git a/core/external/provider_matching.go b/core/matcher/matcher.go similarity index 57% rename from core/external/provider_matching.go rename to core/matcher/matcher.go index 74ad56d42..40d4dc160 100644 --- a/core/external/provider_matching.go +++ b/core/matcher/matcher.go @@ -1,4 +1,4 @@ -package external +package matcher import ( "context" @@ -13,7 +13,17 @@ import ( "github.com/xrash/smetrics" ) -// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// Matcher matches agent song results to local library tracks. +type Matcher struct { + ds model.DataStore +} + +// New creates a new Matcher with the given DataStore. +func New(ds model.DataStore) *Matcher { + return &Matcher{ds: ds} +} + +// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -95,36 +105,34 @@ import ( // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := e.loadTracksByID(ctx, songs) +func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + idMatches, err := m.loadTracksByID(ctx, songs) if err != nil { return nil, fmt.Errorf("failed to load tracks by ID: %w", err) } - mbidMatches, err := e.loadTracksByMBID(ctx, songs, idMatches) + mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) } - isrcMatches, err := e.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) + isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) } - titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) + titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) if err != nil { return nil, fmt.Errorf("failed to load tracks by title: %w", err) } - return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. -// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys. func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool { _, found := lookupByIdentifiers(s, priorMatches...) return found } // lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps. -// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match. func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) { keys := []string{s.ID, s.MBID, s.ISRC} for _, m := range maps { @@ -140,10 +148,7 @@ func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (mod } // loadTracksByID fetches MediaFiles from the library using direct ID matching. -// It extracts all non-empty ID fields from the input songs and performs a single -// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup. -// Only non-missing files are returned. -func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { var ids []string for _, s := range songs { if s.ID != "" { @@ -154,7 +159,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map if len(ids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"media_file.id": ids}, squirrel.Eq{"missing": false}, @@ -172,10 +177,7 @@ func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map } // loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs. -// It extracts all non-empty MBID fields from the input songs and performs a single -// batch query against the mbz_recording_id column. Returns a map keyed by MBID for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +func (m *Matcher) loadTracksByMBID(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var mbids []string for _, s := range songs { if s.MBID != "" && !songMatchedIn(s, priorMatches...) { @@ -186,7 +188,7 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr if len(mbids) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"mbz_recording_id": mbids}, squirrel.Eq{"missing": false}, @@ -205,11 +207,8 @@ func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song, pr return matches, nil } -// loadTracksByISRC fetches MediaFiles from the library using ISRC (International Standard -// Recording Code) matching. It extracts all non-empty ISRC fields from the input songs and -// queries the tags JSON column for matching ISRC values. Returns a map keyed by ISRC for -// O(1) lookup. Only non-missing files are returned. -func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { +// loadTracksByISRC fetches MediaFiles from the library using ISRC matching. +func (m *Matcher) loadTracksByISRC(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { var isrcs []string for _, s := range songs { if s.ISRC != "" && !songMatchedIn(s, priorMatches...) { @@ -220,8 +219,9 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr if len(isrcs) == 0 { return matches, nil } - res, err := e.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ + res, err := m.ds.MediaFile(ctx).GetAllByTags(model.TagISRC, isrcs, model.QueryOptions{ Filters: squirrel.Eq{"missing": false}, + Sort: "starred desc, rating desc, year asc, compilation asc", }) if err != nil { return matches, err @@ -237,27 +237,24 @@ func (e *provider) loadTracksByISRC(ctx context.Context, songs []agents.Song, pr } // songQuery represents a normalized query for matching a song to library tracks. -// All string fields are sanitized (lowercased, diacritics removed) for comparison. -// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist. type songQuery struct { - title string // Sanitized song title - artist string // Sanitized artist name (without articles like "The") - artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching) - album string // Sanitized album name (optional, for specificity scoring) - albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching) - durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering) + title string + artist string + artistMBID string + album string + albumMBID string + durationMs uint32 } -// matchScore combines title/album similarity with metadata specificity for ranking matches +// matchScore combines title/album similarity with metadata specificity for ranking matches. type matchScore struct { - titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler) - durationProximity float64 // 0.0-1.0 (closer duration = higher, 1.0 if unknown) - albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker - specificityLevel int // 0-5 (higher = more specific metadata match) + titleSimilarity float64 + durationProximity float64 + albumSimilarity float64 + specificityLevel int } // betterThan returns true if this score beats another. -// Comparison order: title similarity > duration proximity > specificity level > album similarity func (s matchScore) betterThan(other matchScore) bool { if s.titleSimilarity != other.titleSimilarity { return s.titleSimilarity > other.titleSimilarity @@ -271,58 +268,62 @@ func (s matchScore) betterThan(other matchScore) bool { return s.albumSimilarity > other.albumSimilarity } -// computeSpecificityLevel determines how well query metadata matches a track (0-5). -// Higher values indicate more specific matches (MBIDs > names > title only). -// Uses fuzzy matching for album names with the same threshold as title matching. -func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int { - title := str.SanitizeFieldForSorting(mf.Title) - artist := str.SanitizeFieldForSortingNoArticle(mf.Artist) - album := str.SanitizeFieldForSorting(mf.Album) +// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization +// when the same track is scored against multiple queries in the inner loop. The `mf` field +// is a pointer to avoid copying the large MediaFile struct into each entry of the per-artist +// sanitized slice. +type sanitizedTrack struct { + mf *model.MediaFile + title string + artist string + album string +} - // Level 5: Title + Artist MBID + Album MBID (most specific) +func newSanitizedTrack(mf *model.MediaFile) sanitizedTrack { + return sanitizedTrack{ + mf: mf, + title: str.SanitizeFieldForSorting(mf.Title), + artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), + album: str.SanitizeFieldForSorting(mf.Album), + } +} + +// computeSpecificityLevel determines how well query metadata matches a track (0-5). +// The track's title, artist, and album fields must be pre-sanitized. +func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { if q.artistMBID != "" && q.albumMBID != "" && - mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID { + t.mf.MbzArtistID == q.artistMBID && t.mf.MbzAlbumID == q.albumMBID { return 5 } - // Level 4: Title + Artist MBID + Album name (fuzzy) if q.artistMBID != "" && q.album != "" && - mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold { + t.mf.MbzArtistID == q.artistMBID && similarityRatio(t.album, q.album) >= albumThreshold { return 4 } - // Level 3: Title + Artist name + Album name (fuzzy) if q.artist != "" && q.album != "" && - artist == q.artist && similarityRatio(album, q.album) >= albumThreshold { + t.artist == q.artist && similarityRatio(t.album, q.album) >= albumThreshold { return 3 } - // Level 2: Title + Artist MBID - if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID { + if q.artistMBID != "" && t.mf.MbzArtistID == q.artistMBID { return 2 } - // Level 1: Title + Artist name - if q.artist != "" && artist == q.artist { + if q.artist != "" && t.artist == q.artist { return 1 } - // Level 0: Title only match (but for fuzzy, title matched via similarity) - // Check if at least the title matches exactly - if title == q.title { + if t.title == q.title { return 0 } - return -1 // No exact title match, but could still be a fuzzy match + return -1 } // loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering. -// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with -// metadata specificity (MBIDs, album names) for both exact and fuzzy matches. -// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs. -func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { - queries := e.buildTitleQueries(songs, priorMatches...) +func (m *Matcher) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, priorMatches ...map[string]model.MediaFile) (map[string]model.MediaFile, error) { + queries := m.buildTitleQueries(songs, priorMatches...) if len(queries) == 0 { return map[string]model.MediaFile{}, nil } threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0 - // Group queries by artist for efficient DB access byArtist := map[string][]songQuery{} for _, q := range queries { if q.artist != "" { @@ -332,8 +333,7 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent matches := map[string]model.MediaFile{} for artist, artistQueries := range byArtist { - // Single DB query per artist - get all their tracks - tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + tracks, err := m.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"order_artist_name": artist}, squirrel.Eq{"missing": false}, @@ -344,9 +344,13 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent continue } - // Find best match for each query using unified scoring + sanitized := make([]sanitizedTrack, len(tracks)) + for i := range tracks { + sanitized[i] = newSanitizedTrack(&tracks[i]) + } + for _, q := range artistQueries { - if mf, found := e.findBestMatch(q, tracks, threshold); found { + if mf, found := m.findBestMatch(q, sanitized, threshold); found { key := q.title + "|" + q.artist if _, exists := matches[key]; !exists { matches[key] = mf @@ -357,13 +361,11 @@ func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agent return matches, nil } -// durationProximity returns a score from 0.0 to 1.0 indicating how close -// the track's duration is to the target. A perfect match returns 1.0, and the -// score decreases as the difference grows (using 1 / (1 + diff)). Returns 1.0 -// if durationMs is 0 (unknown), so duration does not influence scoring. +// durationProximity returns a score from 0.0 to 1.0 indicating how close the track's duration +// is to the target. Returns 1.0 if durationMs is 0 (unknown). func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 { - if durationMs <= 0 { - return 1.0 // Unknown duration — don't penalise + if durationMs == 0 { + return 1.0 } durationSec := float64(durationMs) / 1000.0 diff := math.Abs(durationSec - float64(mediaFileDurationSec)) @@ -371,41 +373,33 @@ func durationProximity(durationMs uint32, mediaFileDurationSec float32) float64 } // findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -// A track must meet the threshold for title similarity, then the best match is chosen by: -// 1. Highest title similarity -// 2. Duration proximity (closer duration = higher score, 1.0 if unknown) -// 3. Highest specificity level -// 4. Highest album similarity (as final tiebreaker) -func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) { +func (m *Matcher) findBestMatch(q songQuery, sanitizedTracks []sanitizedTrack, threshold float64) (model.MediaFile, bool) { var bestMatch model.MediaFile bestScore := matchScore{titleSimilarity: -1} found := false - for _, mf := range tracks { - trackTitle := str.SanitizeFieldForSorting(mf.Title) - titleSim := similarityRatio(q.title, trackTitle) + for _, t := range sanitizedTracks { + titleSim := similarityRatio(q.title, t.title) if titleSim < threshold { continue } - // Compute album similarity for tiebreaking (0.0 if no album in query) var albumSim float64 if q.album != "" { - trackAlbum := str.SanitizeFieldForSorting(mf.Album) - albumSim = similarityRatio(q.album, trackAlbum) + albumSim = similarityRatio(q.album, t.album) } score := matchScore{ titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, mf.Duration), + durationProximity: durationProximity(q.durationMs, t.mf.Duration), albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, mf, threshold), + specificityLevel: computeSpecificityLevel(q, t, threshold), } if score.betterThan(bestScore) { bestScore = score - bestMatch = mf + bestMatch = *t.mf found = true } } @@ -413,9 +407,7 @@ func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold } // buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching. -// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes -// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names). -func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { +func (m *Matcher) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery { var queries []songQuery for _, s := range songs { if songMatchedIn(s, priorMatches...) { @@ -434,18 +426,9 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st } // selectBestMatchingSongs assembles the final result by mapping input songs to their best matching -// library tracks. It iterates through the input songs in order and selects the first available match -// using priority order: ID > MBID > ISRC > title+artist. -// -// The function also handles deduplication: when multiple different input songs would match the same -// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library), -// only the first match is kept. However, if the same input song appears multiple times (intentional -// repetition), duplicates are preserved in the output. -// -// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped. -func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { +// library tracks using priority order: ID > MBID > ISRC > title+artist. +func (m *Matcher) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles { mfs := make(model.MediaFiles, 0, len(songs)) - // Track MediaFile.ID -> input song that added it, for deduplication addedBy := make(map[string]agents.Song, len(songs)) for _, t := range songs { @@ -458,11 +441,9 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by continue } - // Check for duplicate library track if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - // Only add duplicate if input songs are identical if t != prevSong { - continue // Different input songs → skip mismatch-induced duplicate + continue } } else { addedBy[mf.ID] = t @@ -473,14 +454,11 @@ func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, by return mfs } -// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist. -// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists. +// findMatchingTrack looks up a song in the match maps using priority order. func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) { - // Try identifier-based matches first (ID, MBID, ISRC) if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found { return mf, true } - // Fall back to title+artist fuzzy match key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist) if mf, ok := byTitleArtist[key]; ok { return mf, true @@ -489,9 +467,6 @@ func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[st } // similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. -// Returns a value between 0.0 (completely different) and 1.0 (identical). -// Jaro-Winkler is well-suited for matching song titles because it gives higher scores -// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered"). func similarityRatio(a, b string) float64 { if a == b { return 1.0 @@ -499,6 +474,5 @@ func similarityRatio(a, b string) float64 { if len(a) == 0 || len(b) == 0 { return 0.0 } - // JaroWinkler params: boostThreshold=0.7, prefixSize=4 return smetrics.JaroWinkler(a, b, 0.7, 4) } diff --git a/core/external/provider_matching_internal_test.go b/core/matcher/matcher_internal_test.go similarity index 89% rename from core/external/provider_matching_internal_test.go rename to core/matcher/matcher_internal_test.go index 5b9ccea3b..f111364c1 100644 --- a/core/external/provider_matching_internal_test.go +++ b/core/matcher/matcher_internal_test.go @@ -1,4 +1,4 @@ -package external +package matcher import ( . "github.com/onsi/ginkgo/v2" @@ -16,25 +16,21 @@ var _ = Describe("similarityRatio", func() { }) It("returns high similarity for remastered suffix", func() { - // Jaro-Winkler gives ~0.92 for this case ratio := similarityRatio("paranoid android", "paranoid android remastered") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns high similarity for suffix additions like (Live)", func() { - // Jaro-Winkler gives ~0.96 for this case ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live") Expect(ratio).To(BeNumerically(">=", 0.90)) }) It("returns high similarity for 'yesterday' variants (common prefix)", func() { - // Jaro-Winkler gives ~0.90 because of common prefix ratio := similarityRatio("yesterday", "yesterday once more") Expect(ratio).To(BeNumerically(">=", 0.85)) }) It("returns low similarity for same suffix", func() { - // Jaro-Winkler gives ~0.70 for this case ratio := similarityRatio("postman (live)", "taxman (live)") Expect(ratio).To(BeNumerically("<", 0.85)) }) diff --git a/core/matcher/matcher_suite_test.go b/core/matcher/matcher_suite_test.go new file mode 100644 index 000000000..44877a3c8 --- /dev/null +++ b/core/matcher/matcher_suite_test.go @@ -0,0 +1,17 @@ +package matcher_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMatcher(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Matcher Suite") +} diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go new file mode 100644 index 000000000..b1f59b258 --- /dev/null +++ b/core/matcher/matcher_test.go @@ -0,0 +1,807 @@ +package matcher_test + +import ( + "context" + "errors" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Matcher", func() { + var ds model.DataStore + var mediaFileRepo *mockMediaFileRepo + var ctx context.Context + var m *matcher.Matcher + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + mediaFileRepo = newMockMediaFileRepo() + DeferCleanup(func() { + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + ds = &tests.MockDataStore{ + MockedMediaFile: mediaFileRepo, + } + m = matcher.New(ds) + }) + + // Per-phase expectation helpers. Each `expect*Phase` registers a .Once() expectation + // that will fail the suite via AssertExpectations if the phase is NOT called. Tests + // use these to deterministically verify which matching phases fire. Phases that may + // or may not fire should use the `allow*Phase` variants instead, which register + // .Maybe() fallbacks. + expectIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(matches, nil).Once() + } + expectMBIDPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(matches, nil).Once() + } + expectISRCPhase := func(matches model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(matches, nil).Once() + } + + // allowOtherPhases installs .Maybe() catch-alls so phases that short-circuit (return + // early without hitting the DB) don't cause test failures for unexpected calls. Call + // this after expect*Phase for the phases the test actually wants to verify. + allowOtherPhases := func() { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("media_file.id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("mbz_recording_id"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInEq("missing"))). + Return(model.MediaFiles{}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(model.MediaFiles{}, nil).Maybe() + } + + // setupTitleOnlyExpectations is a convenience for fuzzy-match tests that only exercise + // the title+artist phase. The title phase uses .Maybe() because it may short-circuit + // when no songs have an artist. + setupTitleOnlyExpectations := func(artistTracks model.MediaFiles) { + mediaFileRepo.On("GetAll", mock.MatchedBy(matchFieldInAnd("order_artist_name"))). + Return(artistTracks, nil).Maybe() + } + + Describe("MatchSongsToLibrary", func() { + Context("matching by direct ID", func() { + It("matches songs with an ID field to MediaFiles by ID", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {ID: "track-1", Name: "Some Song", Artist: "Some Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-1", Title: "Some Song", Artist: "Some Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-1")) + }) + }) + + Context("matching by MBID", func() { + It("matches songs with MBID to tracks with matching mbz_recording_id", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}, + } + mbidMatch := model.MediaFile{ + ID: "track-mbid", Title: "Paranoid Android", Artist: "Radiohead", + MbzRecordingID: "abc-123", + } + expectMBIDPhase(model.MediaFiles{mbidMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-mbid")) + }) + }) + + Context("matching by ISRC", func() { + It("matches songs with ISRC to tracks with matching ISRC tag", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Paranoid Android", ISRC: "GBAYE0000351", Artist: "Radiohead"}, + } + isrcMatch := model.MediaFile{ + ID: "track-isrc", Title: "Paranoid Android", Artist: "Radiohead", + Tags: model.Tags{model.TagISRC: []string{"GBAYE0000351"}}, + } + expectISRCPhase(model.MediaFiles{isrcMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-isrc")) + }) + }) + + Context("fuzzy title+artist matching", func() { + It("matches songs by title and artist name", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode"}, + } + titleMatch := model.MediaFile{ + ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", + } + setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-title")) + }) + + It("matches songs with fuzzy title similarity", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + fuzzyMatch := model.MediaFile{ + ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-fuzzy")) + }) + + It("does not match completely different titles", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles"}, + } + differentTracks := model.MediaFiles{ + {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, + } + setupTitleOnlyExpectations(differentTracks) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("deduplication", func() { + It("removes duplicates when different input songs match the same library track", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"}, + } + libraryTrack := model.MediaFile{ + ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("br-live")) + }) + + It("preserves duplicates when identical input songs match the same library track", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + libraryTrack := model.MediaFile{ + ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", + } + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("br")) + Expect(result[1].ID).To(Equal("br")) + }) + }) + + Context("priority ordering", func() { + It("prefers ID match over MBID match", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + // Song has both ID and MBID set. The matcher should resolve via ID + // and short-circuit the MBID phase entirely, so no MBID fetch should + // occur even though an mbz_recording_id exists in the input. + songs := []agents.Song{ + {ID: "track-id", Name: "Song", MBID: "mbid-1", Artist: "Artist"}, + } + idMatch := model.MediaFile{ + ID: "track-id", Title: "Song", Artist: "Artist", + } + expectIDPhase(model.MediaFiles{idMatch}) + allowOtherPhases() + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("track-id")) + }) + }) + + Context("count limit", func() { + It("returns at most 'count' results", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + tracks := model.MediaFiles{ + {ID: "a", Title: "Song A", Artist: "Artist"}, + {ID: "b", Title: "Song B", Artist: "Artist"}, + {ID: "c", Title: "Song C", Artist: "Artist"}, + } + setupTitleOnlyExpectations(tracks) + result, err := m.MatchSongsToLibrary(ctx, songs, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + }) + }) + + Context("empty input", func() { + It("returns empty results for no songs", func() { + result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + }) + + Describe("specificity level matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 100 + }) + + It("matches by title + artist MBID + album MBID (highest priority)", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", + MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist name + album name when MBIDs unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("matches by title + artist only when album info unavailable", func() { + correctMatch := model.MediaFile{ + ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album", + } + wrongMatch := model.MediaFile{ + ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", + } + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Depeche Mode"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-match")) + }) + + It("does not match songs without artist info", func() { + songs := []agents.Song{ + {Name: "Similar Song"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("returns distinct matches for each artist's version (covers scenario)", func() { + cover1 := model.MediaFile{ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!"} + cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"} + cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"} + + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"}, + {Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + ids := []string{result[0].ID, result[1].ID, result[2].ID} + Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3")) + }) + + It("prefers more precise matches for each song", func() { + preciseMatch := model.MediaFile{ + ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", + MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1", + } + lessAccurateMatch := model.MediaFile{ + ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", + MbzArtistID: "mbid-1", + } + artistTwoMatch := model.MediaFile{ + ID: "artist-two", Title: "Song B", Artist: "Artist Two", + } + + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"}, + {Name: "Song B", Artist: "Artist Two"}, + } + + setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("precise")) + Expect(result[1].ID).To(Equal("artist-two")) + }) + }) + + Describe("fuzzy matching thresholds", func() { + Context("with default threshold (85%)", func() { + It("matches songs with remastered suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("remastered")) + }) + + It("matches songs with live suffix", func() { + conf.Server.SimilarSongsMatchThreshold = 85 + + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen"}, + } + artistTracks := model.MediaFiles{ + {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("live")) + }) + }) + + Context("with threshold set to 100 (exact match only)", func() { + It("only matches exact titles", func() { + conf.Server.SimilarSongsMatchThreshold = 100 + + songs := []agents.Song{ + {Name: "Paranoid Android", Artist: "Radiohead"}, + } + artistTracks := model.MediaFiles{ + {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Context("with lower threshold (75%)", func() { + It("matches more aggressively", func() { + conf.Server.SimilarSongsMatchThreshold = 75 + + songs := []agents.Song{ + {Name: "Song", Artist: "Artist"}, + } + artistTracks := model.MediaFiles{ + {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"}, + } + + setupTitleOnlyExpectations(artistTracks) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("extended")) + }) + }) + }) + + Describe("fuzzy album matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 85 + }) + + It("matches album with (Remaster) suffix", func() { + songs := []agents.Song{ + {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches album with (Deluxe Edition) suffix", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + wrongMatch := model.MediaFile{ + ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("prefers exact album match over fuzzy album match", func() { + songs := []agents.Song{ + {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, + } + exactMatch := model.MediaFile{ + ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", + } + fuzzyMatch := model.MediaFile{ + ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", + } + + setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("exact")) + }) + }) + + Describe("duration matching", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 100 + }) + + It("prefers tracks with matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + correctMatch := model.MediaFile{ + ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, + } + wrongDuration := model.MediaFile{ + ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct")) + }) + + It("matches tracks with close duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, + } + + setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close-duration")) + }) + + It("prefers closer duration over farther duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + closeDuration := model.MediaFile{ + ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, + } + farDuration := model.MediaFile{ + ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("close")) + }) + + It("still matches when no tracks have matching duration", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentDuration := model.MediaFile{ + ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("different")) + }) + + It("prefers title match over duration match when titles differ", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 180000}, + } + differentTitle := model.MediaFile{ + ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, + } + correctTitle := model.MediaFile{ + ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("correct-title")) + }) + + It("matches without duration filtering when agent duration is 0", func() { + songs := []agents.Song{ + {Name: "Similar Song", Artist: "Test Artist", Duration: 0}, + } + anyTrack := model.MediaFile{ + ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("any")) + }) + + It("handles very short songs with close duration", func() { + songs := []agents.Song{ + {Name: "Short Song", Artist: "Test Artist", Duration: 30000}, + } + shortTrack := model.MediaFile{ + ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, + } + + setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].ID).To(Equal("short")) + }) + }) + + Describe("deduplication edge cases", func() { + BeforeEach(func() { + conf.Server.SimilarSongsMatchThreshold = 85 + }) + + It("handles mixed scenario with both identical and different input songs", func() { + songs := []agents.Song{ + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, + {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, + {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, + } + libraryTrack := model.MediaFile{ + ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", + } + + setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("yesterday")) + Expect(result[1].ID).To(Equal("yesterday")) + }) + + It("does not deduplicate songs that match different library tracks", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song C", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + Expect(result[2].ID).To(Equal("track-c")) + }) + + It("respects count limit after deduplication", func() { + songs := []agents.Song{ + {Name: "Song A", Artist: "Artist"}, + {Name: "Song A (Live)", Artist: "Artist"}, + {Name: "Song B", Artist: "Artist"}, + {Name: "Song B (Remix)", Artist: "Artist"}, + } + trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"} + trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"} + + setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) + + result, err := m.MatchSongsToLibrary(ctx, songs, 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-a")) + Expect(result[1].ID).To(Equal("track-b")) + }) + }) +}) + +type mockMediaFileRepo struct { + mock.Mock + model.MediaFileRepository +} + +func newMockMediaFileRepo() *mockMediaFileRepo { + return &mockMediaFileRepo{} +} + +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.MediaFiles), args.Error(1) +} + +func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { + return m.GetAll(options...) +} + +func (m *mockMediaFileRepo) SetError(hasError bool) { + if hasError { + m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) + } +} + +// matchFieldInAnd returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.And whose first element is a squirrel.Eq containing the given field name. +func matchFieldInAnd(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + and, ok := opt.Filters.(squirrel.And) + if !ok || len(and) < 2 { + return false + } + eq, hasEq := and[0].(squirrel.Eq) + if !hasEq { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} + +// matchFieldInEq returns a matcher that checks whether QueryOptions.Filters is a +// squirrel.Eq containing the given field name. +func matchFieldInEq(fieldName string) func(opt model.QueryOptions) bool { + return func(opt model.QueryOptions) bool { + eq, ok := opt.Filters.(squirrel.Eq) + if !ok { + return false + } + _, hasField := eq[fieldName] + return hasField + } +} diff --git a/core/wire_providers.go b/core/wire_providers.go index 276d9556a..a2fffa34f 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" @@ -28,6 +29,7 @@ var Set = wire.NewSet( stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, + matcher.New, wire.Bind(new(external.Agents), new(*agents.Agents)), ffmpeg.New, scrobbler.GetPlayTracker,