fix(server): ensure that similar artists retrieved from provider are no more than limit (#4267)

* fix(provider): ensure that similar artists retreived from provider are no more than limit

* add overlimit multiplier
This commit is contained in:
Kendall Garner 2025-06-29 16:19:29 +00:00 committed by GitHub
parent 4f83987840
commit 91e7f7b5c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 68 additions and 9 deletions

View file

@ -128,6 +128,7 @@ type configOptions struct {
DevInsightsInitialDelay time.Duration DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool DevEnablePlayerInsights bool
DevPluginCompilationTimeout time.Duration DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
} }
type scannerOptions struct { type scannerOptions struct {
@ -599,6 +600,7 @@ func setViperDefaults() {
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true) viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute) viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
} }
func init() { func init() {

View file

@ -258,6 +258,8 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
return "", ErrNotFound return "", ErrNotFound
} }
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) { func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
switch id { switch id {
case consts.UnknownArtistID: case consts.UnknownArtistID:
@ -265,6 +267,9 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
case consts.VariousArtistsID: case consts.VariousArtistsID:
return nil, nil return nil, nil
} }
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now() start := time.Now()
for _, agentName := range a.getEnabledAgentNames() { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName) ag := a.getAgent(agentName)
@ -278,7 +283,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
if !ok { if !ok {
continue continue
} }
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, limit) similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
if len(similar) > 0 && err == nil { if len(similar) > 0 && err == nil {
if log.IsGreaterOrEqualTo(log.LevelTrace) { if log.IsGreaterOrEqualTo(log.LevelTrace) {
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
@ -320,6 +325,8 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
return nil, ErrNotFound return nil, ErrNotFound
} }
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
switch id { switch id {
case consts.UnknownArtistID: case consts.UnknownArtistID:
@ -327,6 +334,9 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
case consts.VariousArtistsID: case consts.VariousArtistsID:
return nil, nil return nil, nil
} }
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
start := time.Now() start := time.Now()
for _, agentName := range a.getEnabledAgentNames() { for _, agentName := range a.getEnabledAgentNames() {
ag := a.getAgent(agentName) ag := a.getAgent(agentName)
@ -340,7 +350,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
if !ok { if !ok {
continue continue
} }
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, count) songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
if len(songs) > 0 && err == nil { if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil return songs, nil

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
@ -19,6 +20,7 @@ var _ = Describe("Agents", func() {
var ds model.DataStore var ds model.DataStore
var mfRepo *tests.MockMediaFileRepo var mfRepo *tests.MockMediaFileRepo
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx, cancel = context.WithCancel(context.Background()) ctx, cancel = context.WithCancel(context.Background())
mfRepo = tests.CreateMockMediaFileRepo() mfRepo = tests.CreateMockMediaFileRepo()
ds = &tests.MockDataStore{MockedMediaFile: mfRepo} ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
@ -240,6 +242,7 @@ var _ = Describe("Agents", func() {
Describe("GetArtistTopSongs", func() { Describe("GetArtistTopSongs", func() {
It("returns on first match", func() { It("returns on first match", func() {
conf.Server.DevExternalArtistFetchMultiplier = 1
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
Name: "A Song", Name: "A Song",
MBID: "mbid444", MBID: "mbid444",
@ -247,6 +250,7 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2)) Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
}) })
It("skips the agent if it returns an error", func() { It("skips the agent if it returns an error", func() {
conf.Server.DevExternalArtistFetchMultiplier = 1
mock.Err = errors.New("error") mock.Err = errors.New("error")
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2) _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
@ -258,6 +262,14 @@ var _ = Describe("Agents", func() {
Expect(err).To(MatchError(ErrNotFound)) Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty()) Expect(mock.Args).To(BeEmpty())
}) })
It("fetches with multiplier", func() {
conf.Server.DevExternalArtistFetchMultiplier = 2
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
Name: "A Song",
MBID: "mbid444",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4))
})
}) })
Describe("GetAlbumInfo", func() { Describe("GetAlbumInfo", func() {

View file

@ -560,7 +560,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila
return return
} }
start := time.Now() start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent) sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start)) log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil { if err != nil {
return return
@ -568,7 +568,7 @@ func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimila
artist.SimilarArtists = sa artist.SimilarArtists = sa
} }
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) { func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
var result model.Artists var result model.Artists
var notPresent []string var notPresent []string
@ -591,21 +591,33 @@ func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artis
artistMap[artist.Name] = artist artistMap[artist.Name] = artist
} }
count := 0
// Process the similar artists // Process the similar artists
for _, s := range similar { for _, s := range similar {
if artist, found := artistMap[s.Name]; found { if artist, found := artistMap[s.Name]; found {
result = append(result, artist) result = append(result, artist)
count++
if count >= limit {
break
}
} else { } else {
notPresent = append(notPresent, s.Name) notPresent = append(notPresent, s.Name)
} }
} }
// Then fill up with non-present artists // Then fill up with non-present artists
if includeNotPresent { if includeNotPresent && count < limit {
for _, s := range notPresent { for _, s := range notPresent {
// Let the ID empty to indicate that the artist is not present in the DB // Let the ID empty to indicate that the artist is not present in the DB
sa := model.Artist{Name: s} sa := model.Artist{Name: s}
result = append(result, sa) result = append(result, sa)
count++
if count >= limit {
break
}
} }
} }

View file

@ -42,10 +42,6 @@ var _ = Describe("Provider - TopSongs", func() {
p = NewProvider(ds, ag) p = NewProvider(ds, ag)
}) })
BeforeEach(func() {
// Setup expectations in individual tests
})
It("returns top songs for a known artist", func() { It("returns top songs for a known artist", func() {
// Mock finding the artist // Mock finding the artist
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
@ -248,4 +244,31 @@ var _ = Describe("Provider - TopSongs", func() {
ag.AssertExpectations(GinkgoT()) ag.AssertExpectations(GinkgoT())
mediaFileRepo.AssertExpectations(GinkgoT()) mediaFileRepo.AssertExpectations(GinkgoT())
}) })
It("only returns requested count when provider returns additional items", func() {
// Mock finding the artist
artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"}
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once()
// Mock agent response
agentSongs := []agents.Song{
{Name: "Song One", MBID: "mbid-song-1"},
{Name: "Song Two", MBID: "mbid-song-2"},
}
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
// Mock finding matching tracks (both returned in a single query)
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"}
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := p.TopSongs(ctx, "Artist One", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
artistRepo.AssertExpectations(GinkgoT())
ag.AssertExpectations(GinkgoT())
mediaFileRepo.AssertExpectations(GinkgoT())
})
}) })