mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
* feat(agents): enhance song matching by removing unwanted duplicates while preserving identical entries Signed-off-by: Deluan <deluan@navidrome.org> * refactor: consolidate duplicate checks Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
763 lines
28 KiB
Go
763 lines
28 KiB
Go
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"))
|
|
})
|
|
})
|
|
})
|