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")) }) }) })