feat: add similar songs functionality in agents, and Instant Mix (song-based) to UI (#4919)
* refactor: rename ArtistRadio to SimilarSongs for clarity and consistency Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement GetSimilarSongsByTrack and related functionality for song similarity retrieval Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance GetSimilarSongsByTrack to include artist and album details and update tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance song matching by implementing title and artist filtering in loadTracksByTitleAndArtist Signed-off-by: Deluan <deluan@navidrome.org> * test: add unit tests for song matching functionality in provider Signed-off-by: Deluan <deluan@navidrome.org> * refactor: extract song matching functionality into its own file Signed-off-by: Deluan <deluan@navidrome.org> * docs: clarify similarSongsFallback function description in provider.go Signed-off-by: Deluan <deluan@navidrome.org> * refactor: initialize result slice for songs with capacity based on response length Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify agent method calls for retrieving images and similar songs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: simplify agent method calls for retrieving images and similar songs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: remove outdated comments in GetSimilarSongs methods Signed-off-by: Deluan <deluan@navidrome.org> * fix: use composite key for song matches to handle duplicates by title and artist Signed-off-by: Deluan <deluan@navidrome.org> * refactor: consolidate expectations setup for similar songs tests Signed-off-by: Deluan <deluan@navidrome.org> * feat: add instant mix action to song context menu and update translations Signed-off-by: Deluan <deluan@navidrome.org> * fix(provider): handle unknown entity types in GetSimilarSongs Signed-off-by: Deluan <deluan@navidrome.org> * refactor: move playSimilar action to playbackActions and streamline song processing Signed-off-by: Deluan <deluan@navidrome.org> * format Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance instant mix functionality with loading notification and shuffle option Signed-off-by: Deluan <deluan@navidrome.org> * feat: implement fuzzy matching for similar songs based on configurable threshold Signed-off-by: Deluan <deluan@navidrome.org> * refactor: implement track matching with multiple specificity levels Signed-off-by: Deluan <deluan@navidrome.org> * refactor: enhance track matching by implementing unified scoring with specificity levels Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance deezer top tracks result with album Signed-off-by: Deluan <deluan@navidrome.org> * feat: enhance track matching with fuzzy album similarity for improved scoring Signed-off-by: Deluan <deluan@navidrome.org> * docs: document multi-phase song matching algorithm with detailed scoring and prioritization Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Vendored
+24
@@ -282,3 +282,27 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, artist, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, artist, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, name, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
Vendored
+54
-151
@@ -32,7 +32,7 @@ const (
|
||||
type Provider interface {
|
||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
|
||||
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
|
||||
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
|
||||
ArtistImage(ctx context.Context, id string) (*url.URL, error)
|
||||
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||
@@ -80,6 +80,9 @@ type Agents interface {
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
agents.SimilarSongsByTrackRetriever
|
||||
agents.SimilarSongsByAlbumRetriever
|
||||
agents.SimilarSongsByArtistRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
@@ -256,7 +259,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
|
||||
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
|
||||
_ = g.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
@@ -275,22 +278,54 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var songs []agents.Song
|
||||
|
||||
// Try entity-specific similarity first
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
|
||||
case *model.Album:
|
||||
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
|
||||
case *model.Artist:
|
||||
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
|
||||
default:
|
||||
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
if err == nil && len(songs) > 0 {
|
||||
return e.matchSongsToLibrary(ctx, songs, count)
|
||||
}
|
||||
|
||||
// Fallback to existing similar artists + top songs algorithm
|
||||
return e.similarSongsFallback(ctx, id, count)
|
||||
}
|
||||
|
||||
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
|
||||
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
|
||||
// a weighted random selection of songs to return as similar songs.
|
||||
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
|
||||
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
|
||||
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
|
||||
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -422,21 +457,20 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
|
||||
}
|
||||
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
// Enrich songs with artist info if not already present (for top songs, we know the artist)
|
||||
for i := range songs {
|
||||
if songs[i].Artist == "" {
|
||||
songs[i].Artist = artistName
|
||||
}
|
||||
if songs[i].ArtistMBID == "" {
|
||||
songs[i].ArtistMBID = artist.MbzArtistID
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
|
||||
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
@@ -447,137 +481,6 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
titleMap := map[string]string{}
|
||||
for _, s := range songs {
|
||||
// Skip if already matched by ID or MBID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
continue
|
||||
}
|
||||
sanitized := str.SanitizeFieldForSorting(s.Name)
|
||||
titleMap[sanitized] = s.Name
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(titleMap) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
titleFilters := squirrel.Or{}
|
||||
for sanitized := range titleMap {
|
||||
titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
|
||||
}
|
||||
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Or{
|
||||
squirrel.Eq{"artist_id": artist.ID},
|
||||
squirrel.Eq{"album_artist_id": artist.ID},
|
||||
},
|
||||
titleFilters,
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc ",
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
sanitized := str.SanitizeFieldForSorting(mf.Title)
|
||||
if _, ok := matches[sanitized]; !ok {
|
||||
matches[sanitized] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
// Try ID match first
|
||||
if t.ID != "" {
|
||||
if mf, ok := byID[t.ID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Try MBID match second
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to title match
|
||||
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
|
||||
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
@@ -614,7 +517,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
|
||||
}
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
|
||||
limit int, includeNotPresent bool) {
|
||||
artistName := artist.Name()
|
||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
|
||||
|
||||
-205
@@ -1,205 +0,0 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"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 - ArtistRadio", func() {
|
||||
var ds model.DataStore
|
||||
var provider Provider
|
||||
var mockAgent *mockSimilarArtistAgent
|
||||
var mockTopAgent agents.ArtistTopSongsRetriever
|
||||
var mockSimilarAgent agents.ArtistSimilarRetriever
|
||||
var agentsCombined Agents
|
||||
var artistRepo *mockArtistRepo
|
||||
var mediaFileRepo *mockMediaFileRepo
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
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.ArtistRadio(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("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)
|
||||
|
||||
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.ArtistRadio(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()
|
||||
|
||||
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.ArtistRadio(ctx, "artist-1", 5)
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
songs, err := provider.ArtistRadio(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()
|
||||
|
||||
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.ArtistRadio(ctx, "artist-1", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
||||
})
|
||||
})
|
||||
Vendored
+388
@@ -0,0 +1,388 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"github.com/xrash/smetrics"
|
||||
)
|
||||
|
||||
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
|
||||
// matching algorithm that prioritizes accuracy over recall.
|
||||
//
|
||||
// # Algorithm Overview
|
||||
//
|
||||
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
|
||||
// local music library using three matching strategies in priority order:
|
||||
//
|
||||
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
|
||||
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
|
||||
// matching mbz_recording_id
|
||||
// 3. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
|
||||
// with metadata specificity scoring
|
||||
//
|
||||
// # Matching Priority
|
||||
//
|
||||
// When selecting the final result, matches are prioritized in order: ID > MBID > Title+Artist.
|
||||
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
|
||||
//
|
||||
// # Fuzzy Matching Details
|
||||
//
|
||||
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
|
||||
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
|
||||
//
|
||||
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
|
||||
// 2. Specificity level (0-5, based on metadata precision):
|
||||
// - Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
// - Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
// - Level 3: Title + Artist name + Album name (fuzzy)
|
||||
// - Level 2: Title + Artist MBID
|
||||
// - Level 1: Title + Artist name
|
||||
// - Level 0: Title only
|
||||
// 3. Album similarity (Jaro-Winkler, as final tiebreaker)
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// Example 1 - MBID Priority:
|
||||
//
|
||||
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
|
||||
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
|
||||
// ]
|
||||
// Result: t1 (MBID match takes priority over title+artist)
|
||||
//
|
||||
// Example 2 - Specificity Ranking:
|
||||
//
|
||||
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
|
||||
// Library has: [
|
||||
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
|
||||
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
|
||||
// ]
|
||||
// Result: t2 (Level 3 beats Level 1 due to album match)
|
||||
//
|
||||
// Example 3 - Fuzzy Title Matching:
|
||||
//
|
||||
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
|
||||
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
|
||||
// With threshold=85%: Match succeeds (similarity ~0.87)
|
||||
// With threshold=100%: No match (not exact)
|
||||
//
|
||||
// # Parameters
|
||||
//
|
||||
// - ctx: Context for database operations
|
||||
// - songs: Slice of agent.Song results from external providers
|
||||
// - count: Maximum number of matches to return
|
||||
//
|
||||
// # Returns
|
||||
//
|
||||
// Returns up to 'count' MediaFiles from the library that best match the input songs,
|
||||
// preserving the original order from the agent. Songs that cannot be matched are skipped.
|
||||
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
|
||||
idMatches, err := e.loadTracksByID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
|
||||
}
|
||||
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
|
||||
}
|
||||
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, titleMatches, count), nil
|
||||
}
|
||||
|
||||
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
|
||||
// It extracts all non-empty ID fields from the input songs and performs a single
|
||||
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
|
||||
// Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var ids []string
|
||||
for _, s := range songs {
|
||||
if s.ID != "" {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(ids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"media_file.id": ids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if _, ok := matches[mf.ID]; !ok {
|
||||
matches[mf.ID] = mf
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
|
||||
// It extracts all non-empty MBID fields from the input songs and performs a single
|
||||
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
|
||||
// O(1) lookup. Only non-missing files are returned.
|
||||
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
|
||||
var mbids []string
|
||||
for _, s := range songs {
|
||||
if s.MBID != "" {
|
||||
mbids = append(mbids, s.MBID)
|
||||
}
|
||||
}
|
||||
matches := map[string]model.MediaFile{}
|
||||
if len(mbids) == 0 {
|
||||
return matches, nil
|
||||
}
|
||||
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"mbz_recording_id": mbids},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return matches, err
|
||||
}
|
||||
for _, mf := range res {
|
||||
if id := mf.MbzRecordingID; id != "" {
|
||||
if _, ok := matches[id]; !ok {
|
||||
matches[id] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// songQuery represents a normalized query for matching a song to library tracks.
|
||||
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
|
||||
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
|
||||
type songQuery struct {
|
||||
title string // Sanitized song title
|
||||
artist string // Sanitized artist name (without articles like "The")
|
||||
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
|
||||
album string // Sanitized album name (optional, for specificity scoring)
|
||||
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
|
||||
}
|
||||
|
||||
// matchScore combines title/album similarity with metadata specificity for ranking matches
|
||||
type matchScore struct {
|
||||
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
|
||||
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
|
||||
specificityLevel int // 0-5 (higher = more specific metadata match)
|
||||
}
|
||||
|
||||
// betterThan returns true if this score beats another.
|
||||
// Comparison order: title similarity > specificity level > album similarity
|
||||
func (s matchScore) betterThan(other matchScore) bool {
|
||||
if s.titleSimilarity != other.titleSimilarity {
|
||||
return s.titleSimilarity > other.titleSimilarity
|
||||
}
|
||||
if s.specificityLevel != other.specificityLevel {
|
||||
return s.specificityLevel > other.specificityLevel
|
||||
}
|
||||
return s.albumSimilarity > other.albumSimilarity
|
||||
}
|
||||
|
||||
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
|
||||
// Higher values indicate more specific matches (MBIDs > names > title only).
|
||||
// Uses fuzzy matching for album names with the same threshold as title matching.
|
||||
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
|
||||
title := str.SanitizeFieldForSorting(mf.Title)
|
||||
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
|
||||
album := str.SanitizeFieldForSorting(mf.Album)
|
||||
|
||||
// Level 5: Title + Artist MBID + Album MBID (most specific)
|
||||
if q.artistMBID != "" && q.albumMBID != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
|
||||
return 5
|
||||
}
|
||||
// Level 4: Title + Artist MBID + Album name (fuzzy)
|
||||
if q.artistMBID != "" && q.album != "" &&
|
||||
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
|
||||
return 4
|
||||
}
|
||||
// Level 3: Title + Artist name + Album name (fuzzy)
|
||||
if q.artist != "" && q.album != "" &&
|
||||
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
|
||||
return 3
|
||||
}
|
||||
// Level 2: Title + Artist MBID
|
||||
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
|
||||
return 2
|
||||
}
|
||||
// Level 1: Title + Artist name
|
||||
if q.artist != "" && artist == q.artist {
|
||||
return 1
|
||||
}
|
||||
// Level 0: Title only match (but for fuzzy, title matched via similarity)
|
||||
// Check if at least the title matches exactly
|
||||
if title == q.title {
|
||||
return 0
|
||||
}
|
||||
return -1 // No exact title match, but could still be a fuzzy match
|
||||
}
|
||||
|
||||
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
|
||||
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
|
||||
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
|
||||
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
|
||||
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
queries := e.buildTitleQueries(songs, idMatches, mbidMatches)
|
||||
if len(queries) == 0 {
|
||||
return map[string]model.MediaFile{}, nil
|
||||
}
|
||||
|
||||
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
|
||||
|
||||
// Group queries by artist for efficient DB access
|
||||
byArtist := map[string][]songQuery{}
|
||||
for _, q := range queries {
|
||||
if q.artist != "" {
|
||||
byArtist[q.artist] = append(byArtist[q.artist], q)
|
||||
}
|
||||
}
|
||||
|
||||
matches := map[string]model.MediaFile{}
|
||||
for artist, artistQueries := range byArtist {
|
||||
// Single DB query per artist - get all their tracks
|
||||
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
squirrel.Eq{"order_artist_name": artist},
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Sort: "starred desc, rating desc, year asc, compilation asc",
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find best match for each query using unified scoring
|
||||
for _, q := range artistQueries {
|
||||
if mf, found := e.findBestMatch(q, tracks, threshold); found {
|
||||
key := q.title + "|" + q.artist
|
||||
if _, exists := matches[key]; !exists {
|
||||
matches[key] = mf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
|
||||
// A track must meet the threshold for title similarity, then the best match is chosen by:
|
||||
// 1. Highest title similarity
|
||||
// 2. Highest specificity level
|
||||
// 3. Highest album similarity (as final tiebreaker)
|
||||
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
|
||||
var bestMatch model.MediaFile
|
||||
bestScore := matchScore{titleSimilarity: -1}
|
||||
found := false
|
||||
|
||||
for _, mf := range tracks {
|
||||
trackTitle := str.SanitizeFieldForSorting(mf.Title)
|
||||
titleSim := similarityRatio(q.title, trackTitle)
|
||||
|
||||
if titleSim < threshold {
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute album similarity for tiebreaking (0.0 if no album in query)
|
||||
var albumSim float64
|
||||
if q.album != "" {
|
||||
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
|
||||
albumSim = similarityRatio(q.album, trackAlbum)
|
||||
}
|
||||
|
||||
score := matchScore{
|
||||
titleSimilarity: titleSim,
|
||||
albumSimilarity: albumSim,
|
||||
specificityLevel: computeSpecificityLevel(q, mf, threshold),
|
||||
}
|
||||
|
||||
if score.betterThan(bestScore) {
|
||||
bestScore = score
|
||||
bestMatch = mf
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return bestMatch, found
|
||||
}
|
||||
|
||||
func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) []songQuery {
|
||||
var queries []songQuery
|
||||
for _, s := range songs {
|
||||
// Skip if already matched by ID or MBID
|
||||
if s.ID != "" && idMatches[s.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
|
||||
continue
|
||||
}
|
||||
queries = append(queries, songQuery{
|
||||
title: str.SanitizeFieldForSorting(s.Name),
|
||||
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
|
||||
artistMBID: s.ArtistMBID,
|
||||
album: str.SanitizeFieldForSorting(s.Album),
|
||||
albumMBID: s.AlbumMBID,
|
||||
})
|
||||
}
|
||||
return queries
|
||||
}
|
||||
|
||||
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
// Try ID match first
|
||||
if t.ID != "" {
|
||||
if mf, ok := byID[t.ID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Try MBID match second
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Fall back to title+artist match (composite key preserves duplicate titles)
|
||||
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
|
||||
if mf, ok := byTitleArtist[key]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
}
|
||||
}
|
||||
return mfs
|
||||
}
|
||||
|
||||
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
|
||||
// Returns a value between 0.0 (completely different) and 1.0 (identical).
|
||||
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
|
||||
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
|
||||
func similarityRatio(a, b string) float64 {
|
||||
if a == b {
|
||||
return 1.0
|
||||
}
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
|
||||
return smetrics.JaroWinkler(a, b, 0.7, 4)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("similarityRatio", func() {
|
||||
It("returns 1.0 for identical strings", func() {
|
||||
Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
|
||||
})
|
||||
|
||||
It("returns 0.0 for empty strings", func() {
|
||||
Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
|
||||
Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
|
||||
})
|
||||
|
||||
It("returns high similarity for remastered suffix", func() {
|
||||
// Jaro-Winkler gives ~0.92 for this case
|
||||
ratio := similarityRatio("paranoid android", "paranoid android remastered")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns high similarity for suffix additions like (Live)", func() {
|
||||
// Jaro-Winkler gives ~0.96 for this case
|
||||
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.90))
|
||||
})
|
||||
|
||||
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
|
||||
// Jaro-Winkler gives ~0.90 because of common prefix
|
||||
ratio := similarityRatio("yesterday", "yesterday once more")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for same suffix", func() {
|
||||
// Jaro-Winkler gives ~0.70 for this case
|
||||
ratio := similarityRatio("postman (live)", "taxman (live)")
|
||||
Expect(ratio).To(BeNumerically("<", 0.85))
|
||||
})
|
||||
|
||||
It("handles unicode characters", func() {
|
||||
ratio := similarityRatio("dont stop believin", "don't stop believin'")
|
||||
Expect(ratio).To(BeNumerically(">=", 0.85))
|
||||
})
|
||||
|
||||
It("returns low similarity for completely different strings", func() {
|
||||
ratio := similarityRatio("abc", "xyz")
|
||||
Expect(ratio).To(BeNumerically("<", 0.5))
|
||||
})
|
||||
|
||||
It("is symmetric", func() {
|
||||
ratio1 := similarityRatio("hello world", "hello")
|
||||
ratio2 := similarityRatio("hello", "hello world")
|
||||
Expect(ratio1).To(Equal(ratio2))
|
||||
})
|
||||
})
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
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)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
setupFuzzyExpectations := 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 now queries by artist in a single pass
|
||||
// Note: loadTracksByID and loadTracksByMBID return early when no IDs/MBIDs
|
||||
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("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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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"},
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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)",
|
||||
}
|
||||
|
||||
setupFuzzyExpectations(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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"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 - 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 ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo()
|
||||
mediaFileRepo = newMockMediaFileRepo()
|
||||
albumRepo = newMockAlbumRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
MockedAlbum: albumRepo,
|
||||
}
|
||||
|
||||
mockAgent = &mockSimilarArtistAgent{}
|
||||
mockTopAgent = mockAgent
|
||||
mockSimilarAgent = mockAgent
|
||||
|
||||
agentsCombined = &mockAgents{
|
||||
topSongsAgent: mockTopAgent,
|
||||
similarAgent: mockSimilarAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
})
|
||||
|
||||
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"}
|
||||
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
|
||||
|
||||
// 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", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
|
||||
}, 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 - no MBID matches (empty MBID means this won't be called)
|
||||
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()
|
||||
|
||||
// Mock 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(model.MediaFiles{matchedSong}, nil).Maybe()
|
||||
|
||||
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
||||
|
||||
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"))
|
||||
})
|
||||
})
|
||||
|
||||
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", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "New Life", MBID: "song-mbid", Artist: "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", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
|
||||
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
|
||||
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "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", "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()
|
||||
|
||||
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
|
||||
// and recursively calls getArtist(v.AlbumArtistID)
|
||||
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
albumRepo.On("Get", "album-1").Return(&album, 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, "album-1", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
})
|
||||
})
|
||||
|
||||
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", 5).
|
||||
Return([]agents.Song{
|
||||
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "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", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].ID).To(Equal("matched-1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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("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", 5)
|
||||
|
||||
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()
|
||||
|
||||
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"))
|
||||
})
|
||||
})
|
||||
+6
@@ -7,6 +7,8 @@ import (
|
||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||
"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"
|
||||
@@ -26,6 +28,10 @@ var _ = Describe("Provider - TopSongs", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
|
||||
conf.Server.SimilarSongsMatchThreshold = 100
|
||||
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
|
||||
Reference in New Issue
Block a user