mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
* refactor(artwork): move artworkItemName into core/artwork as ItemName * feat(external): add RefreshInfo to force an external info refresh RefreshInfo re-fetches and re-saves external info for one artist or album, bypassing the TTL check that UpdateArtistInfo/UpdateAlbumInfo use. It is synchronous; callers that must not block detach it themselves. Also makes MockArtistRepo/MockAlbumRepo.UpdateExternalInfo persist to Data (previously a no-op) and adds the new method to the e2e noopProvider, both required so the interface addition compiles and is observable in tests. * feat(external): broadcast RefreshResource after external info is saved populateArtistInfo and populateAlbumInfo now emit the same RefreshResource event the artwork worker uses, so the UI learns about both foreground and background metadata refreshes. * feat(nativeapi): replace artwork refresh endpoint with metadata refresh * feat(ui): add refreshMetadata to the data provider * feat(ui): add a Refresh Metadata item to the album and artist context menus * fix(ui): re-fetch artist info when the record is refreshed * test: fix mislabeled spec, add kind-gate negative case, guard nil mock maps - Rename the RefreshInfo spec that claimed to cover the save-failure/broadcast path: SetError(true) fails Get too, so it only proves RefreshInfo bails out early at getArtist. - Add a spec proving playlist refreshes skip the external-info step, since that asymmetry (al/ar only) was documented but unasserted. - Add lazy nil-map init to MockAlbumRepo/MockArtistRepo.UpdateExternalInfo so a composite-literal-constructed mock doesn't panic on first save. * test: relocate discArtworkName specs from cmd to core/artwork artworkItemName moved into core/artwork as ItemName in an earlier commit, but its disc-name specs stayed behind in cmd/artwork_test.go, reaching across packages. Move them to core/artwork/item_name_test.go where the code now lives. * fix(ui): shape refreshMetadata like a react-admin response react-admin validates custom dataProvider methods and rejects any response without a `data` key, so the raw httpClient promise made every click surface an error toast instead of the success message. The unit test mocked useDataProvider, which skips that validation. Also folds "which kinds have external info" into external.HasInfo so the handler stops restating it, drops the nil-broker guard that only existed for tests, and delegates the mocks' UpdateExternalInfo to Put. * refactor(external): unexport infoKinds Only HasInfo is used outside the package, so the slice itself does not need to be exported. * refactor(artwork): fold ItemName into housekeeping.go next to Refresh ItemName exists to guard Refresh from ids that would orphan a queue row, and both callers invoke them back to back. A separate file hid that pairing; it was only split out to keep the move out of cmd/ legible in review. * fix(nativeapi): return 500 when the refresh lookup fails for a non-ErrNotFound reason A transient repository error told the admin the id did not exist, and the error was dropped without a log line, so nothing pointed at the real cause. Also drops the inherited claim that clearing artwork state shows a placeholder. Reads fall back to local resolution, so that only holds when there is no local art. * fix(ui): move Refresh Metadata above Get Info in the context menu Menu order follows key insertion order in the options object, so the new spec pins the position rather than leaving it to be shuffled by the next addition.
1074 lines
50 KiB
Go
1074 lines
50 KiB
Go
package external_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
"github.com/navidrome/navidrome/core/agents"
|
|
. "github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/core/matcher"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/stretchr/testify/mock"
|
|
)
|
|
|
|
var _ = Describe("Provider - SimilarSongs", func() {
|
|
var ds model.DataStore
|
|
var provider Provider
|
|
var mockAgent *mockSimilarArtistAgent
|
|
var mockTopAgent agents.ArtistTopSongsRetriever
|
|
var mockSimilarAgent agents.ArtistSimilarRetriever
|
|
var agentsCombined *mockAgents
|
|
var artistRepo *mockArtistRepo
|
|
var mediaFileRepo *mockMediaFileRepo
|
|
var albumRepo *mockAlbumRepo
|
|
var playlistRepo *tests.MockPlaylistRepo
|
|
var playlistTrackRepo *tests.MockPlaylistTrackRepo
|
|
var genreRepo *tests.MockedGenreRepo
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ctx = GinkgoT().Context()
|
|
|
|
artistRepo = newMockArtistRepo()
|
|
mediaFileRepo = newMockMediaFileRepo()
|
|
albumRepo = newMockAlbumRepo()
|
|
playlistTrackRepo = &tests.MockPlaylistTrackRepo{}
|
|
playlistRepo = tests.CreateMockPlaylistRepo()
|
|
playlistRepo.TracksRepo = playlistTrackRepo
|
|
genreRepo = &tests.MockedGenreRepo{}
|
|
|
|
ds = &tests.MockDataStore{
|
|
MockedArtist: artistRepo,
|
|
MockedMediaFile: mediaFileRepo,
|
|
MockedAlbum: albumRepo,
|
|
MockedPlaylist: playlistRepo,
|
|
MockedGenre: genreRepo,
|
|
}
|
|
|
|
mockAgent = &mockSimilarArtistAgent{}
|
|
mockTopAgent = mockAgent
|
|
mockSimilarAgent = mockAgent
|
|
|
|
agentsCombined = &mockAgents{
|
|
topSongsAgent: mockTopAgent,
|
|
similarAgent: mockSimilarAgent,
|
|
}
|
|
|
|
provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{})
|
|
})
|
|
|
|
// Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive:
|
|
// no spec here asserts how many times the entity is looked up.
|
|
stubTrackEntity := func() {
|
|
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
|
|
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
|
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe()
|
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe()
|
|
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
|
}
|
|
|
|
Describe("dispatch by entity type", func() {
|
|
Context("when ID is a MediaFile (track)", func() {
|
|
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
|
|
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
|
|
|
|
// Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping.
|
|
dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"}
|
|
dmParticipant := model.Participant{Artist: dmArtist}
|
|
matchedSong := model.MediaFile{
|
|
ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode",
|
|
Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}},
|
|
}
|
|
|
|
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
|
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1).
|
|
Return([]agents.Song{
|
|
{Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}},
|
|
}, nil).Once()
|
|
|
|
// Matcher artist resolution: resolve Depeche Mode in the artist table.
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe()
|
|
|
|
// ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it.
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Eq)
|
|
return ok
|
|
})).Return(model.MediaFiles{}, nil).Maybe()
|
|
|
|
// MBID phase: won't fire (empty MBID).
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
and, ok := opt.Filters.(squirrel.And)
|
|
if !ok || len(and) < 1 {
|
|
return false
|
|
}
|
|
eq, hasEq := and[0].(squirrel.Eq)
|
|
if !hasEq {
|
|
return false
|
|
}
|
|
_, hasMBID := eq["mbz_recording_id"]
|
|
return hasMBID
|
|
})).Return(model.MediaFiles{}, nil).Maybe()
|
|
|
|
// Matcher track-fetch: subquery returns the matched song with participants.
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
and, ok := opt.Filters.(squirrel.And)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, f := range and {
|
|
sql, _, err := f.ToSql()
|
|
if err == nil && strings.Contains(sql, "media_file_artists") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("matched-1"))
|
|
})
|
|
|
|
It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
|
|
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
|
|
artist := model.Artist{ID: "artist-1", Name: "Artist"}
|
|
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
|
|
|
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
|
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
|
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
|
|
// and recursively calls getArtist(v.ArtistID)
|
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
|
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
|
|
|
|
// Then it recurses with the artist-1 ID
|
|
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist}, nil).Maybe()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 0 && opt.Filters != nil
|
|
})).Return(model.Artists{}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("song-1"))
|
|
})
|
|
|
|
It("tops the mix up with the fallback when the agent's picks alone are too few", func() {
|
|
stubTrackEntity()
|
|
|
|
// The agent knows one track of the three asked for.
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
|
|
Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("agent-1", "song-1"))
|
|
})
|
|
|
|
It("reaches a unique pick that sits past the count-th repeat", func() {
|
|
stubTrackEntity()
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Artist{}, nil).Maybe()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Song{}, nil).Maybe()
|
|
|
|
// "Song B" sits past the second repeat, so a matcher capped at count never reaches it.
|
|
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2).
|
|
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
|
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
|
|
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
|
|
}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 2)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("t-a", "t-b"))
|
|
})
|
|
|
|
It("keeps topping up when the agent's picks repeat a track", func() {
|
|
stubTrackEntity()
|
|
|
|
// The matcher re-emits a track when the same input song repeats, so these three
|
|
// picks resolve to only two distinct library tracks.
|
|
repeated := agents.Song{Name: "Song A", MBID: "mbid-a"}
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3).
|
|
Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
|
{ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"},
|
|
{ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"},
|
|
}, nil).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Album", func() {
|
|
It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
|
|
album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
|
|
matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
|
|
|
|
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
|
|
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
|
|
|
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1).
|
|
Return([]agents.Song{
|
|
{Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
|
}, nil).Once()
|
|
|
|
// Mock loadTracksByID - no ID matches
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Eq)
|
|
return ok
|
|
})).Return(model.MediaFiles{}, nil).Once()
|
|
|
|
// Mock loadTracksByMBID - MBID match
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
and, ok := opt.Filters.(squirrel.And)
|
|
if !ok || len(and) < 1 {
|
|
return false
|
|
}
|
|
_, hasEq := and[0].(squirrel.Eq)
|
|
return hasEq
|
|
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "album-1", 1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("matched-1"))
|
|
})
|
|
|
|
It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() {
|
|
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
|
|
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"}
|
|
|
|
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
|
|
|
|
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
|
|
Return(nil, agents.ErrNotFound).Once()
|
|
|
|
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
sql, args, err := opt.Filters.ToSql()
|
|
return err == nil && strings.Contains(sql, "album_id") &&
|
|
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1"))
|
|
})).Return(model.MediaFiles{seed}, nil).Once()
|
|
|
|
// seedMix falls back to the seed itself when the agent finds nothing.
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("seed-1"))
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() {
|
|
It("samples the album's tracks and returns their track-similars", func() {
|
|
album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"}
|
|
artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "al-1").Return(&album, nil).Once()
|
|
|
|
// AudioMuse doesn't implement album similarity -> empty.
|
|
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
// sampleAlbumTracks -> GetRandom(album_id) -> one seed track
|
|
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
sql, args, err := opt.Filters.ToSql()
|
|
return err == nil && strings.Contains(sql, "album_id") &&
|
|
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1"))
|
|
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once()
|
|
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5).
|
|
Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
|
|
|
|
// Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A".
|
|
aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"}
|
|
matchedTrack := model.MediaFile{
|
|
ID: "m1", Title: "AudioMuseResult", Artist: "A",
|
|
Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}},
|
|
}
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "al-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
Expect(songs[0].ID).To(Equal("m1"))
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Artist", func() {
|
|
It("calls GetSimilarSongsByArtist and returns matched songs", func() {
|
|
artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
|
|
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
|
|
|
|
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1).
|
|
Return([]agents.Song{
|
|
{Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}},
|
|
}, nil).Once()
|
|
|
|
// Mock loadTracksByID - no ID matches
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Eq)
|
|
return ok
|
|
})).Return(model.MediaFiles{}, nil).Once()
|
|
|
|
// Mock loadTracksByMBID - MBID match
|
|
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
and, ok := opt.Filters.(squirrel.And)
|
|
if !ok || len(and) < 1 {
|
|
return false
|
|
}
|
|
_, hasEq := and[0].(squirrel.Eq)
|
|
return hasEq
|
|
})).Return(model.MediaFiles{matchedSong}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("matched-1"))
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() {
|
|
It("samples the artist's tracks and returns their track-similars", func() {
|
|
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
|
|
// Get is called twice: once to resolve the entity, once inside similarSongsFallback.
|
|
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
|
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
// similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups).
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Artist{}, nil).Maybe()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Song{}, nil).Maybe()
|
|
|
|
// Seeds come from the participant join covering both roles, so an artist credited
|
|
// only on the album (compilations, classical) still yields seeds.
|
|
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
sql, args, err := opt.Filters.ToSql()
|
|
return err == nil && strings.Contains(sql, "media_file_artists") &&
|
|
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) &&
|
|
slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String()))
|
|
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
|
|
Return([]agents.Song{{Name: "Result"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() {
|
|
It("tops the mix up with the artist's own track-similars", func() {
|
|
artist := model.Artist{ID: "ar-1", Name: "Thin Artist"}
|
|
topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"}
|
|
|
|
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
|
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
// No similar artist is in the library, so the fallback yields only the seed artist's
|
|
// own matching top song: one track for a mix of five.
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetRandom", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "ar-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("top-1", "mix-1"))
|
|
})
|
|
})
|
|
|
|
Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() {
|
|
It("does not pay for seed-track sampling", func() {
|
|
artist := model.Artist{ID: "ar-1", Name: "The Artist"}
|
|
artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe()
|
|
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3).
|
|
Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
|
{ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"},
|
|
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
|
|
}, nil).Once()
|
|
|
|
// The similar-artists fallback supplies the third track, so the mix is full.
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Song{}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "ar-1", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
|
mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything)
|
|
})
|
|
})
|
|
|
|
Context("when ID is a Playlist", func() {
|
|
It("samples playlist tracks and returns their track-similars", func() {
|
|
pls := model.Playlist{ID: "pl-1", Name: "My List"}
|
|
seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
|
|
|
|
// GetEntityByID order: Artist, Album, Playlist(hit)
|
|
artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
|
|
// samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: seedTrack},
|
|
})
|
|
|
|
// seedMix -> GetSimilarSongsByTrack for the seed
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
|
|
Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once()
|
|
|
|
// Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups).
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
})
|
|
|
|
It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() {
|
|
// A smart playlist materializes no playlist_tracks until it is evaluated, so sampling
|
|
// without the refresh would mix an empty seed set.
|
|
pls := model.Playlist{ID: "pl-smart", Name: "Smart"}
|
|
artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
})
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Song{}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-smart", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
Expect(playlistRepo.TracksRefreshed).To(BeTrue())
|
|
})
|
|
|
|
It("returns an error instead of panicking when the track repository is unavailable", func() {
|
|
// Tracks() logs and returns a nil repository when its own lookup fails.
|
|
pls := model.Playlist{ID: "pl-nil", Name: "Gone"}
|
|
artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistRepo.TracksRepo = nil
|
|
|
|
_, err := provider.SimilarSongs(ctx, "pl-nil", 5)
|
|
|
|
Expect(err).To(MatchError(model.ErrNotFound))
|
|
})
|
|
|
|
It("does not seed a mix with missing tracks", func() {
|
|
pls := model.Playlist{ID: "pl-missing", Name: "Missing"}
|
|
artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
})
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return([]agents.Song{}, nil).Maybe()
|
|
|
|
_, err := provider.SimilarSongs(ctx, "pl-missing", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql()
|
|
Expect(sqlErr).ToNot(HaveOccurred())
|
|
Expect(sql).To(ContainSubstring("missing"))
|
|
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
|
})
|
|
|
|
It("keeps a later seed's picks when an earlier seed overlaps it", func() {
|
|
// The matcher stops once it has count matches, and it re-emits the shared track, so
|
|
// matching only count of the merged set would spend slots on the duplicate.
|
|
pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"}
|
|
artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
|
})
|
|
shared := agents.Song{ID: "x", Name: "X"}
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
|
|
Return([]agents.Song{shared}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
|
|
Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
|
{ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"},
|
|
}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot")
|
|
})
|
|
|
|
It("returns a track once when two seeds recommend it", func() {
|
|
// The matcher re-emits a track when two inputs are identical, so overlapping
|
|
// recommendations would otherwise take two slots in the mix.
|
|
pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"}
|
|
artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
|
})
|
|
shared := agents.Song{ID: "m1", Name: "Shared"}
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
|
|
Return([]agents.Song{shared}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5).
|
|
Return([]agents.Song{shared}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1), "the shared recommendation must appear once")
|
|
})
|
|
|
|
It("does not seed a mix twice with a track the playlist repeats", func() {
|
|
pls := model.Playlist{ID: "pl-dup", Name: "Dupes"}
|
|
artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
// The same file at two positions, which playlists allow.
|
|
dup := model.MediaFile{ID: "s1", Title: "Seed One"}
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}})
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-dup", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1), "the repeated track must appear once")
|
|
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1)
|
|
})
|
|
|
|
It("clamps an enormous count before it reaches the queries", func() {
|
|
// count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL
|
|
// limit unless Max is positive, so the query would hydrate the whole library.
|
|
pls := model.Playlist{ID: "pl-huge", Name: "Huge"}
|
|
artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
})
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
_, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
agentsCombined.AssertExpectations(GinkgoT())
|
|
})
|
|
|
|
It("does not panic when the caller asks for a non-positive count", func() {
|
|
// Subsonic passes count straight through, so a negative one reaches the provider.
|
|
// The guard returns before any lookup, so no repository setup is needed.
|
|
songs, err := provider.SimilarSongs(ctx, "pl-neg", -1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(BeEmpty())
|
|
})
|
|
|
|
It("blends results from every seed, not just the first", func() {
|
|
pls := model.Playlist{ID: "pl-blend", Name: "Blend"}
|
|
artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}},
|
|
{MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}},
|
|
})
|
|
|
|
// Each seed returns a full count's worth, as a real similarity agent does. Asking for
|
|
// one more than seed one can supply makes this independent of the final shuffle:
|
|
// three of the four matches always include a seed-two track.
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3).
|
|
Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3).
|
|
Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{
|
|
{ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"},
|
|
{ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"},
|
|
}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-blend", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
ids := ids(songs)
|
|
Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix")
|
|
})
|
|
|
|
It("falls back to the seed tracks themselves when no similar songs are found", func() {
|
|
pls := model.Playlist{ID: "pl-2", Name: "Fallback List"}
|
|
seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"}
|
|
seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"}
|
|
|
|
artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
|
|
playlistTrackRepo.SetData(model.PlaylistTracks{
|
|
{MediaFile: seed1},
|
|
{MediaFile: seed2},
|
|
})
|
|
|
|
// Both seeds come back empty, so the mix must fall back to the seeds themselves.
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-2", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(2))
|
|
Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2"))
|
|
})
|
|
|
|
It("samples the album when the agent's picks are not in this library", func() {
|
|
// Last.fm answers from its own catalogue, so a small library can match none of it.
|
|
// An unmatched non-empty answer must not shortcut the sampling fallback.
|
|
album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"}
|
|
artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "al-nm").Return(&album, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5).
|
|
Return([]agents.Song{{Name: "Not In Library"}}, nil).Once()
|
|
artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe()
|
|
|
|
// The matcher resolves nothing; the sampled seed is what reaches the mix.
|
|
mediaFileRepo.On("GetRandom", mock.Anything).
|
|
Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "al-nm", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("s1"))
|
|
})
|
|
|
|
It("caps agent calls at maxSeeds when the repository ignores the bound", func() {
|
|
// Isolates seedMix's own cap: the album sampler bounds the query with Max, so this
|
|
// exercises the guard by having the repo hand back more rows than were asked for.
|
|
album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"}
|
|
artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "al-cap").Return(&album, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
var overflow model.MediaFiles
|
|
for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} {
|
|
overflow = append(overflow, model.MediaFile{ID: id, Title: id})
|
|
}
|
|
mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
|
|
Return([]agents.Song{}, nil)
|
|
|
|
_, err := provider.SimilarSongs(ctx, "al-cap", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
|
|
})
|
|
|
|
It("still finds maxSeeds distinct seeds when the leading positions repeat", func() {
|
|
// The sampler over-fetches for exactly this case: a page bounded at maxSeeds could
|
|
// be entirely one repeated file and collapse to a single seed.
|
|
pls := model.Playlist{ID: "pl-3", Name: "Big List"}
|
|
dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"}
|
|
tracks := model.PlaylistTracks{
|
|
{MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup},
|
|
}
|
|
for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} {
|
|
tracks = append(tracks, model.PlaylistTrack{
|
|
MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"},
|
|
})
|
|
}
|
|
|
|
artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once()
|
|
playlistRepo.SetData(model.Playlists{pls})
|
|
playlistTrackRepo.SetData(tracks)
|
|
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5).
|
|
Return([]agents.Song{}, nil)
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "pl-3", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(5))
|
|
Expect(playlistTrackRepo.Options.Sort).To(Equal("random"))
|
|
agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5)
|
|
})
|
|
})
|
|
|
|
Context("when ID is a Genre (not resolved by GetEntityByID)", func() {
|
|
It("samples genre songs and returns their track-similars", func() {
|
|
// GetEntityByID misses everywhere; the empty/auto-created mocks need no setup.
|
|
artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
|
albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
|
mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once()
|
|
genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}}
|
|
|
|
// sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan)
|
|
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
if opt.Filters == nil {
|
|
return false
|
|
}
|
|
sql, args, err := opt.Filters.ToSql()
|
|
return err == nil && strings.Contains(sql, "media_file_tags") &&
|
|
!strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false))
|
|
})).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once()
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5).
|
|
Return([]agents.Song{{Name: "Similar"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "g-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).ToNot(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
It("returns similar songs from main artist and similar artists", func() {
|
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
|
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
|
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
|
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
|
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
|
|
|
|
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
|
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist1}, nil).Once()
|
|
|
|
// New similar songs by artist returns ErrNotFound to trigger fallback
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return(nil, agents.ErrNotFound).Once()
|
|
|
|
similarAgentsResp := []agents.Artist{
|
|
{Name: "Similar Artist", MBID: "similar-mbid"},
|
|
}
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
|
Return(similarAgentsResp, nil).Once()
|
|
|
|
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
|
|
// MBID lookup returns empty (no match)
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Eq)
|
|
return opt.Max == 0 && ok
|
|
})).Return(model.Artists{}, nil).Once()
|
|
// Name lookup returns the similar artist
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Or)
|
|
return opt.Max == 0 && ok
|
|
})).Return(model.Artists{similarArtist}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return([]agents.Song{
|
|
{Name: "Song One", MBID: "mbid-1"},
|
|
{Name: "Song Two", MBID: "mbid-2"},
|
|
}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
|
Return([]agents.Song{
|
|
{Name: "Song Three", MBID: "mbid-3"},
|
|
}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(3))
|
|
for _, song := range songs {
|
|
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
|
|
}
|
|
})
|
|
|
|
It("keeps picking until the fallback holds count distinct tracks", func() {
|
|
// A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed
|
|
// count of entries lets those duplicates eat the budget and strand unique candidates.
|
|
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"}
|
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
|
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
|
|
|
|
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
|
|
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice()
|
|
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice()
|
|
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
|
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist1}, nil).Maybe()
|
|
|
|
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3).
|
|
Return([]agents.Song{}, nil).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
|
Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Eq)
|
|
return opt.Max == 0 && ok
|
|
})).Return(model.Artists{}, nil).Once()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
_, ok := opt.Filters.(squirrel.Or)
|
|
return opt.Max == 0 && ok
|
|
})).Return(model.Artists{similarArtist}, nil).Once()
|
|
|
|
shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"}
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).
|
|
Return(model.MediaFiles{shared}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
|
|
Return([]agents.Song{
|
|
{Name: "Shared", MBID: "mbid-a"},
|
|
{Name: "B", MBID: "mbid-b"},
|
|
{Name: "C", MBID: "mbid-c"},
|
|
}, nil).Once()
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{
|
|
shared,
|
|
{ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"},
|
|
{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"},
|
|
}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "track-1", 3)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c"))
|
|
})
|
|
|
|
It("returns ErrNotFound when artist is not found", func() {
|
|
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
|
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
|
albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{}, nil).Maybe()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
|
|
|
|
Expect(err).To(Equal(model.ErrNotFound))
|
|
Expect(songs).To(BeNil())
|
|
})
|
|
|
|
It("returns songs from main artist when GetSimilarArtists returns error", func() {
|
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
|
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
|
|
|
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist1}, nil).Maybe()
|
|
|
|
// New similar songs by artist returns ErrNotFound to trigger fallback
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return(nil, agents.ErrNotFound).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
|
Return(nil, errors.New("error getting similar artists")).Once()
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 0 && opt.Filters != nil
|
|
})).Return(model.Artists{}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return([]agents.Song{
|
|
{Name: "Song One", MBID: "mbid-1"},
|
|
}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(Equal("song-1"))
|
|
})
|
|
|
|
It("returns empty list when GetArtistTopSongs returns error", func() {
|
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
|
|
|
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist1}, nil).Maybe()
|
|
|
|
// New similar songs by artist returns ErrNotFound to trigger fallback
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return(nil, agents.ErrNotFound).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 0 && opt.Filters != nil
|
|
})).Return(model.Artists{}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return(nil, errors.New("error getting top songs")).Once()
|
|
|
|
// Fallback yields nothing, so the sampling path is tried and also finds no tracks.
|
|
mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
sql, args, err := opt.Filters.ToSql()
|
|
return err == nil && strings.Contains(sql, "artist_id") &&
|
|
strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1"))
|
|
})).Return(model.MediaFiles{}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(BeEmpty())
|
|
})
|
|
|
|
It("respects count parameter", func() {
|
|
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
|
|
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
|
|
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
|
|
|
|
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 1 && opt.Filters != nil
|
|
})).Return(model.Artists{artist1}, nil).Maybe()
|
|
|
|
// New similar songs by artist returns ErrNotFound to trigger fallback
|
|
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return(nil, agents.ErrNotFound).Once()
|
|
|
|
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
|
|
Return([]agents.Artist{}, nil).Once()
|
|
|
|
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
|
return opt.Max == 0 && opt.Filters != nil
|
|
})).Return(model.Artists{}, nil).Once()
|
|
|
|
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
|
|
Return([]agents.Song{
|
|
{Name: "Song One", MBID: "mbid-1"},
|
|
{Name: "Song Two", MBID: "mbid-2"},
|
|
}, nil).Once()
|
|
|
|
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
|
|
|
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(songs).To(HaveLen(1))
|
|
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
|
|
})
|
|
})
|