package external_test import ( "context" "errors" "math" "slices" "strings" "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" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" ) var _ = Describe("Provider - SimilarSongs", func() { var ds model.DataStore var provider Provider var mockAgent *mockSimilarArtistAgent var mockTopAgent agents.ArtistTopSongsRetriever var mockSimilarAgent agents.ArtistSimilarRetriever var agentsCombined *mockAgents var artistRepo *mockArtistRepo var mediaFileRepo *mockMediaFileRepo var albumRepo *mockAlbumRepo var playlistRepo *tests.MockPlaylistRepo var playlistTrackRepo *tests.MockPlaylistTrackRepo var genreRepo *tests.MockedGenreRepo var ctx context.Context BeforeEach(func() { ctx = GinkgoT().Context() artistRepo = newMockArtistRepo() mediaFileRepo = newMockMediaFileRepo() albumRepo = newMockAlbumRepo() playlistTrackRepo = &tests.MockPlaylistTrackRepo{} playlistRepo = tests.CreateMockPlaylistRepo() playlistRepo.TracksRepo = playlistTrackRepo genreRepo = &tests.MockedGenreRepo{} ds = &tests.MockDataStore{ MockedArtist: artistRepo, MockedMediaFile: mediaFileRepo, MockedAlbum: albumRepo, MockedPlaylist: playlistRepo, MockedGenre: genreRepo, } mockAgent = &mockSimilarArtistAgent{} mockTopAgent = mockAgent mockSimilarAgent = mockAgent agentsCombined = &mockAgents{ topSongsAgent: mockTopAgent, similarAgent: mockSimilarAgent, } provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) }) // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: // no spec here asserts how many times the entity is looked up. stubTrackEntity := func() { track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} artist := model.Artist{ID: "artist-1", Name: "Artist"} artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe() artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() } Describe("dispatch by entity type", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} dmParticipant := model.Participant{Artist: dmArtist} matchedSong := model.MediaFile{ ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, } // GetEntityByID tries Artist, Album, Playlist, then MediaFile 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() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1). Return([]agents.Song{ {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() // Matcher artist resolution: resolve Depeche Mode in the artist table. artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok })).Return(model.MediaFiles{}, nil).Maybe() // MBID phase: won't fire (empty MBID). 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(model.MediaFiles{}, nil).Maybe() // Matcher track-fetch: subquery returns the matched song with participants. mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok { return false } for _, f := range and { sql, _, err := f.ToSql() if err == nil && strings.Contains(sql, "media_file_artists") { return true } } return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "track-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("matched-1")) }) It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() { track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} artist := model.Artist{ID: "artist-1", Name: "Artist"} song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile 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() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything). Return([]agents.Song{}, nil).Once() // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile // and recursively calls getArtist(v.ArtistID) 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() // Then it recurses with the artist-1 ID artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist}, nil).Maybe() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). Return([]agents.Artist{}, nil).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 0 && opt.Filters != nil })).Return(model.Artists{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() songs, err := provider.SimilarSongs(ctx, "track-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("song-1")) }) It("tops the mix up with the fallback when the agent's picks alone are too few", func() { stubTrackEntity() // The agent knows one track of the three asked for. agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything). Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). Return([]agents.Artist{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything). Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once() songs, err := provider.SimilarSongs(ctx, "track-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("agent-1", "song-1")) }) It("reaches a unique pick that sits past the count-th repeat", func() { stubTrackEntity() mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Artist{}, nil).Maybe() mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Song{}, nil).Maybe() // "Song B" sits past the second repeat, so a matcher capped at count never reaches it. repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2). Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, }, nil).Once() songs, err := provider.SimilarSongs(ctx, "track-1", 2) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("t-a", "t-b")) }) It("keeps topping up when the agent's picks repeat a track", func() { stubTrackEntity() // The matcher re-emits a track when the same input song repeats, so these three // picks resolve to only two distinct library tracks. repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, }, nil).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). Return([]agents.Artist{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything). Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once() songs, err := provider.SimilarSongs(ctx, "track-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) }) }) Context("when ID is an Album", func() { It("calls GetSimilarSongsByAlbum and returns matched songs", func() { album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"} matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} // GetEntityByID tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1). Return([]agents.Song{ {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok })).Return(model.MediaFiles{}, nil).Once() // Mock loadTracksByMBID - MBID match mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { return false } _, hasEq := and[0].(squirrel.Eq) return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() songs, err := provider.SimilarSongs(ctx, "album-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("matched-1")) }) It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() { album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"} artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { sql, args, err := opt.Filters.ToSql() return err == nil && strings.Contains(sql, "album_id") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1")) })).Return(model.MediaFiles{seed}, nil).Once() // seedMix falls back to the seed itself when the agent finds nothing. agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything). Return([]agents.Song{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "album-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("seed-1")) }) }) Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() { It("samples the album's tracks and returns their track-similars", func() { album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"} artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "al-1").Return(&album, nil).Once() // AudioMuse doesn't implement album similarity -> empty. agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5). Return([]agents.Song{}, nil).Once() // sampleAlbumTracks -> GetRandom(album_id) -> one seed track mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { sql, args, err := opt.Filters.ToSql() return err == nil && strings.Contains(sql, "album_id") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1")) })).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5). Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() // Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A". aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"} matchedTrack := model.MediaFile{ ID: "m1", Title: "AudioMuseResult", Artist: "A", Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}}, } artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "al-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) Expect(songs[0].ID).To(Equal("m1")) }) }) Context("when ID is an Artist", func() { It("calls GetSimilarSongsByArtist and returns matched songs", func() { artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"} matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1). Return([]agents.Song{ {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() // Mock loadTracksByID - no ID matches mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return ok })).Return(model.MediaFiles{}, nil).Once() // Mock loadTracksByMBID - MBID match mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { and, ok := opt.Filters.(squirrel.And) if !ok || len(and) < 1 { return false } _, hasEq := and[0].(squirrel.Eq) return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("matched-1")) }) }) Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() { It("samples the artist's tracks and returns their track-similars", func() { artist := model.Artist{ID: "ar-1", Name: "The Artist"} // Get is called twice: once to resolve the entity, once inside similarSongsFallback. artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5). Return([]agents.Song{}, nil).Once() // similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups). mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Artist{}, nil).Maybe() mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Song{}, nil).Maybe() // Seeds come from the participant join covering both roles, so an artist credited // only on the album (compilations, classical) still yields seeds. mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { sql, args, err := opt.Filters.ToSql() return err == nil && strings.Contains(sql, "media_file_artists") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) && slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String())) })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). Return([]agents.Song{{Name: "Result"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "ar-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) }) }) Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() { It("tops the mix up with the artist's own track-similars", func() { artist := model.Artist{ID: "ar-1", Name: "Thin Artist"} topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"} artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5). Return([]agents.Song{}, nil).Once() // No similar artist is in the library, so the fallback yields only the seed artist's // own matching top song: one track for a mix of five. mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15). Return([]agents.Artist{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything). Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once() mediaFileRepo.On("GetRandom", mock.Anything). Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything). Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything). Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once() songs, err := provider.SimilarSongs(ctx, "ar-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("top-1", "mix-1")) }) }) Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() { It("does not pay for seed-track sampling", func() { artist := model.Artist{ID: "ar-1", Name: "The Artist"} artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3). Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ {ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"}, {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, }, nil).Once() // The similar-artists fallback supplies the third track, so the mix is full. mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15). Return([]agents.Artist{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything). Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything). Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once() mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Song{}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "ar-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything) }) }) Context("when ID is a Playlist", func() { It("samples playlist tracks and returns their track-similars", func() { pls := model.Playlist{ID: "pl-1", Name: "My List"} seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} // GetEntityByID order: Artist, Album, Playlist(hit) artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) // samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: seedTrack}, }) // seedMix -> GetSimilarSongsByTrack for the seed agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() // Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups). artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "pl-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) }) It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() { // A smart playlist materializes no playlist_tracks until it is evaluated, so sampling // without the refresh would mix an empty seed set. pls := model.Playlist{ID: "pl-smart", Name: "Smart"} artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, }) agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Song{}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "pl-smart", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) Expect(playlistRepo.TracksRefreshed).To(BeTrue()) }) It("returns an error instead of panicking when the track repository is unavailable", func() { // Tracks() logs and returns a nil repository when its own lookup fails. pls := model.Playlist{ID: "pl-nil", Name: "Gone"} artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistRepo.TracksRepo = nil _, err := provider.SimilarSongs(ctx, "pl-nil", 5) Expect(err).To(MatchError(model.ErrNotFound)) }) It("does not seed a mix with missing tracks", func() { pls := model.Playlist{ID: "pl-missing", Name: "Missing"} artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, }) agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return([]agents.Song{}, nil).Maybe() _, err := provider.SimilarSongs(ctx, "pl-missing", 5) Expect(err).ToNot(HaveOccurred()) sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql() Expect(sqlErr).ToNot(HaveOccurred()) Expect(sql).To(ContainSubstring("missing")) Expect(args).To(ContainElement(false), "must exclude missing files, not select them") }) It("keeps a later seed's picks when an earlier seed overlaps it", func() { // The matcher stops once it has count matches, and it re-emits the shared track, so // matching only count of the merged set would spend slots on the duplicate. pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"} artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, }) shared := agents.Song{ID: "x", Name: "X"} agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). Return([]agents.Song{shared}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ {ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"}, }, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot") }) It("returns a track once when two seeds recommend it", func() { // The matcher re-emits a track when two inputs are identical, so overlapping // recommendations would otherwise take two slots in the mix. pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"} artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, }) shared := agents.Song{ID: "m1", Name: "Shared"} agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). Return([]agents.Song{shared}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5). Return([]agents.Song{shared}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1), "the shared recommendation must appear once") }) It("does not seed a mix twice with a track the playlist repeats", func() { pls := model.Playlist{ID: "pl-dup", Name: "Dupes"} artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) // The same file at two positions, which playlists allow. dup := model.MediaFile{ID: "s1", Title: "Seed One"} playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}}) agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). Return([]agents.Song{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "pl-dup", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1), "the repeated track must appear once") agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1) }) It("clamps an enormous count before it reaches the queries", func() { // count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL // limit unless Max is positive, so the query would hydrate the whole library. pls := model.Playlist{ID: "pl-huge", Name: "Huge"} artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, }) agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500). Return([]agents.Song{}, nil).Once() _, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64) Expect(err).ToNot(HaveOccurred()) agentsCombined.AssertExpectations(GinkgoT()) }) It("does not panic when the caller asks for a non-positive count", func() { // Subsonic passes count straight through, so a negative one reaches the provider. // The guard returns before any lookup, so no repository setup is needed. songs, err := provider.SimilarSongs(ctx, "pl-neg", -1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(BeEmpty()) }) It("blends results from every seed, not just the first", func() { pls := model.Playlist{ID: "pl-blend", Name: "Blend"} artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, }) // Each seed returns a full count's worth, as a real similarity agent does. Asking for // one more than seed one can supply makes this independent of the final shuffle: // three of the four matches always include a seed-two track. agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ {ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"}, {ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"}, }, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "pl-blend", 3) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) ids := ids(songs) Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") }) It("falls back to the seed tracks themselves when no similar songs are found", func() { pls := model.Playlist{ID: "pl-2", Name: "Fallback List"} seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"} artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(model.PlaylistTracks{ {MediaFile: seed1}, {MediaFile: seed2}, }) // Both seeds come back empty, so the mix must fall back to the seeds themselves. agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). Return([]agents.Song{}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5). Return([]agents.Song{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "pl-2", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2")) }) It("samples the album when the agent's picks are not in this library", func() { // Last.fm answers from its own catalogue, so a small library can match none of it. // An unmatched non-empty answer must not shortcut the sampling fallback. album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"} artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "al-nm").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5). Return([]agents.Song{{Name: "Not In Library"}}, nil).Once() artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() // The matcher resolves nothing; the sampled seed is what reaches the mix. mediaFileRepo.On("GetRandom", mock.Anything). Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5). Return([]agents.Song{}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "al-nm", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("s1")) }) It("caps agent calls at maxSeeds when the repository ignores the bound", func() { // Isolates seedMix's own cap: the album sampler bounds the query with Max, so this // exercises the guard by having the repo hand back more rows than were asked for. album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"} artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "al-cap").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5). Return([]agents.Song{}, nil).Once() var overflow model.MediaFiles for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} { overflow = append(overflow, model.MediaFile{ID: id, Title: id}) } mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). Return([]agents.Song{}, nil) _, err := provider.SimilarSongs(ctx, "al-cap", 5) Expect(err).ToNot(HaveOccurred()) agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) }) It("still finds maxSeeds distinct seeds when the leading positions repeat", func() { // The sampler over-fetches for exactly this case: a page bounded at maxSeeds could // be entirely one repeated file and collapse to a single seed. pls := model.Playlist{ID: "pl-3", Name: "Big List"} dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"} tracks := model.PlaylistTracks{ {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, } for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} { tracks = append(tracks, model.PlaylistTrack{ MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"}, }) } artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() playlistRepo.SetData(model.Playlists{pls}) playlistTrackRepo.SetData(tracks) agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). Return([]agents.Song{}, nil) songs, err := provider.SimilarSongs(ctx, "pl-3", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(5)) Expect(playlistTrackRepo.Options.Sort).To(Equal("random")) agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) }) }) Context("when ID is a Genre (not resolved by GetEntityByID)", func() { It("samples genre songs and returns their track-similars", func() { // GetEntityByID misses everywhere; the empty/auto-created mocks need no setup. artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}} // sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan) mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { if opt.Filters == nil { return false } sql, args, err := opt.Filters.ToSql() return err == nil && strings.Contains(sql, "media_file_tags") && !strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). Return([]agents.Song{{Name: "Similar"}}, nil).Once() mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "g-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).ToNot(BeEmpty()) }) }) }) It("returns similar songs from main artist and similar artists", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist1}, nil).Once() // New similar songs by artist returns ErrNotFound to trigger fallback agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() similarAgentsResp := []agents.Artist{ {Name: "Similar Artist", MBID: "similar-mbid"}, } mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). Return(similarAgentsResp, nil).Once() // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name // MBID lookup returns empty (no match) artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return opt.Max == 0 && ok })).Return(model.Artists{}, nil).Once() // Name lookup returns the similar artist artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Or) return opt.Max == 0 && ok })).Return(model.Artists{similarArtist}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return([]agents.Song{ {Name: "Song One", MBID: "mbid-1"}, {Name: "Song Two", MBID: "mbid-2"}, }, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). Return([]agents.Song{ {Name: "Song Three", MBID: "mbid-3"}, }, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) for _, song := range songs { Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3")) } }) It("keeps picking until the fallback holds count distinct tracks", func() { // A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed // count of entries lets those duplicates eat the budget and strand unique candidates. track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"} artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice() artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist1}, nil).Maybe() agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3). Return([]agents.Song{}, nil).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Eq) return opt.Max == 0 && ok })).Return(model.Artists{}, nil).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { _, ok := opt.Filters.(squirrel.Or) return opt.Max == 0 && ok })).Return(model.Artists{similarArtist}, nil).Once() shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"} mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")). Return(model.MediaFiles{shared}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). Return([]agents.Song{ {Name: "Shared", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}, {Name: "C", MBID: "mbid-c"}, }, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{ shared, {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, {ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}, }, nil).Once() songs, err := provider.SimilarSongs(ctx, "track-1", 3) Expect(err).ToNot(HaveOccurred()) Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) }) It("returns ErrNotFound when artist is not found", func() { artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{}, nil).Maybe() songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5) Expect(err).To(Equal(model.ErrNotFound)) Expect(songs).To(BeNil()) }) It("returns songs from main artist when GetSimilarArtists returns error", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist1}, nil).Maybe() // New similar songs by artist returns ErrNotFound to trigger fallback agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). Return(nil, errors.New("error getting similar artists")).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 0 && opt.Filters != nil })).Return(model.Artists{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return([]agents.Song{ {Name: "Song One", MBID: "mbid-1"}, }, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("song-1")) }) It("returns empty list when GetArtistTopSongs returns error", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist1}, nil).Maybe() // New similar songs by artist returns ErrNotFound to trigger fallback agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). Return([]agents.Artist{}, nil).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 0 && opt.Filters != nil })).Return(model.Artists{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, errors.New("error getting top songs")).Once() // Fallback yields nothing, so the sampling path is tried and also finds no tracks. mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { sql, args, err := opt.Filters.ToSql() return err == nil && strings.Contains(sql, "artist_id") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1")) })).Return(model.MediaFiles{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(BeEmpty()) }) It("respects count parameter", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 1 && opt.Filters != nil })).Return(model.Artists{artist1}, nil).Maybe() // New similar songs by artist returns ErrNotFound to trigger fallback agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). Return([]agents.Artist{}, nil).Once() artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { return opt.Max == 0 && opt.Filters != nil })).Return(model.Artists{}, nil).Once() mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return([]agents.Song{ {Name: "Song One", MBID: "mbid-1"}, {Name: "Song Two", MBID: "mbid-2"}, }, nil).Once() mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(BeElementOf("song-1", "song-2")) }) })