update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
284
core/external/extdata_helper_test.go
vendored
Normal file
284
core/external/extdata_helper_test.go
vendored
Normal file
@@ -0,0 +1,284 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// --- Shared Mock Implementations ---
|
||||
|
||||
// mockArtistRepo mocks model.ArtistRepository
|
||||
type mockArtistRepo struct {
|
||||
mock.Mock
|
||||
model.ArtistRepository
|
||||
}
|
||||
|
||||
func newMockArtistRepo() *mockArtistRepo {
|
||||
return &mockArtistRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||
for _, a := range artists {
|
||||
artistCopy := a
|
||||
m.On("Get", artistCopy.ID).Return(&artistCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Artist), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Artists), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockArtistRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByName is a helper to set up a GetAll expectation for finding by name.
|
||||
func (m *mockArtistRepo) FindByName(name string, artist model.Artist) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.Artists{artist}, nil).Once()
|
||||
}
|
||||
|
||||
// mockMediaFileRepo mocks model.MediaFileRepository
|
||||
type mockMediaFileRepo struct {
|
||||
mock.Mock
|
||||
model.MediaFileRepository
|
||||
}
|
||||
|
||||
func newMockMediaFileRepo() *mockMediaFileRepo {
|
||||
return &mockMediaFileRepo{}
|
||||
}
|
||||
|
||||
// SetData sets up basic Get expectations.
|
||||
func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||
for _, mf := range mediaFiles {
|
||||
mfCopy := mf
|
||||
m.On("Get", mfCopy.ID).Return(&mfCopy, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Get implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.MediaFile), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||
}
|
||||
|
||||
// SetError is a helper to set up a generic error for GetAll.
|
||||
func (m *mockMediaFileRepo) SetError(hasError bool) {
|
||||
if hasError {
|
||||
m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error"))
|
||||
}
|
||||
}
|
||||
|
||||
// FindByMBID is a helper to set up a GetAll expectation for finding by MBID.
|
||||
func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// FindByArtistAndTitle is a helper to set up a GetAll expectation for finding by artist/title.
|
||||
func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) {
|
||||
m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Filters != nil
|
||||
})).Return(model.MediaFiles{mediaFile}, nil).Once()
|
||||
}
|
||||
|
||||
// mockAlbumRepo mocks model.AlbumRepository
|
||||
type mockAlbumRepo struct {
|
||||
mock.Mock
|
||||
model.AlbumRepository
|
||||
}
|
||||
|
||||
func newMockAlbumRepo() *mockAlbumRepo {
|
||||
return &mockAlbumRepo{}
|
||||
}
|
||||
|
||||
// Get implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
args := m.Called(id)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*model.Album), args.Error(1)
|
||||
}
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
args := m.Called(argsSlice...)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(model.Albums), args.Error(1)
|
||||
}
|
||||
|
||||
// mockSimilarArtistAgent mocks agents implementing ArtistTopSongsRetriever and ArtistSimilarRetriever
|
||||
type mockSimilarArtistAgent struct {
|
||||
mock.Mock
|
||||
agents.Interface // Embed to satisfy methods not explicitly mocked
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) AgentName() string {
|
||||
return "mockSimilar"
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockSimilarArtistAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
// mockAgents mocks the main Agents interface used by Provider
|
||||
type mockAgents struct {
|
||||
mock.Mock // Embed testify mock
|
||||
topSongsAgent agents.ArtistTopSongsRetriever
|
||||
similarAgent agents.ArtistSimilarRetriever
|
||||
imageAgent agents.ArtistImageRetriever
|
||||
albumInfoAgent interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
bioAgent agents.ArtistBiographyRetriever
|
||||
mbidAgent agents.ArtistMBIDRetriever
|
||||
urlAgent agents.ArtistURLRetriever
|
||||
agents.Interface
|
||||
}
|
||||
|
||||
func (m *mockAgents) AgentName() string {
|
||||
return "mockCombined"
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
|
||||
if m.similarAgent != nil {
|
||||
return m.similarAgent.GetSimilarArtists(ctx, id, name, mbid, limit)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid, limit)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Artist), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
||||
if m.topSongsAgent != nil {
|
||||
return m.topSongsAgent.GetArtistTopSongs(ctx, id, artistName, mbid, count)
|
||||
}
|
||||
args := m.Called(ctx, id, artistName, mbid, count)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.Song), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
|
||||
if m.mbidAgent != nil {
|
||||
return m.mbidAgent.GetArtistMBID(ctx, id, name)
|
||||
}
|
||||
args := m.Called(ctx, id, name)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.urlAgent != nil {
|
||||
return m.urlAgent.GetArtistURL(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
|
||||
if m.bioAgent != nil {
|
||||
return m.bioAgent.GetArtistBiography(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.imageAgent != nil {
|
||||
return m.imageAgent.GetArtistImages(ctx, id, name, mbid)
|
||||
}
|
||||
args := m.Called(ctx, id, name, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
if m.albumInfoAgent != nil {
|
||||
return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid)
|
||||
}
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) != nil {
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
17
core/external/extdata_suite_test.go
vendored
Normal file
17
core/external/extdata_suite_test.go
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestExternal(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "External Suite")
|
||||
}
|
||||
733
core/external/provider.go
vendored
Normal file
733
core/external/provider.go
vendored
Normal file
@@ -0,0 +1,733 @@
|
||||
package external
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/deezer"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/random"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/navidrome/navidrome/utils/str"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSimilarArtists = 100
|
||||
refreshDelay = 5 * time.Second
|
||||
refreshTimeout = 15 * time.Second
|
||||
refreshQueueLength = 2000
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
ds model.DataStore
|
||||
ag Agents
|
||||
artistQueue refreshQueue[auxArtist]
|
||||
albumQueue refreshQueue[auxAlbum]
|
||||
}
|
||||
|
||||
type auxAlbum struct {
|
||||
model.Album
|
||||
}
|
||||
|
||||
// Name returns the appropriate album name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxAlbum) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Album.Name
|
||||
}
|
||||
return str.Clear(a.Album.Name)
|
||||
}
|
||||
|
||||
type auxArtist struct {
|
||||
model.Artist
|
||||
}
|
||||
|
||||
// Name returns the appropriate artist name for external API calls
|
||||
// based on the DevPreserveUnicodeInExternalCalls configuration option
|
||||
func (a *auxArtist) Name() string {
|
||||
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||
return a.Artist.Name
|
||||
}
|
||||
return str.Clear(a.Artist.Name)
|
||||
}
|
||||
|
||||
type Agents interface {
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
agents.ArtistBiographyRetriever
|
||||
agents.ArtistMBIDRetriever
|
||||
agents.ArtistImageRetriever
|
||||
agents.ArtistSimilarRetriever
|
||||
agents.ArtistTopSongsRetriever
|
||||
agents.ArtistURLRetriever
|
||||
}
|
||||
|
||||
func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
e := &provider{ds: ds, ag: agents}
|
||||
e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxAlbum{}, err
|
||||
}
|
||||
|
||||
var album auxAlbum
|
||||
switch v := entity.(type) {
|
||||
case *model.Album:
|
||||
album.Album = *v
|
||||
case *model.MediaFile:
|
||||
return e.getAlbum(ctx, v.AlbumID)
|
||||
default:
|
||||
return auxAlbum{}, model.ErrNotFound
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
log.Info(ctx, "Not found", "id", id)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedAt := V(album.ExternalInfoUpdatedAt)
|
||||
albumName := album.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
|
||||
album, err = e.populateAlbumInfo(ctx, album)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateAlbumInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
|
||||
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
|
||||
e.albumQueue.enqueue(&album)
|
||||
}
|
||||
|
||||
return &album.Album, nil
|
||||
}
|
||||
|
||||
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
|
||||
start := time.Now()
|
||||
albumName := album.Name()
|
||||
info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if errors.Is(err, agents.ErrNotFound) {
|
||||
return album, nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
|
||||
"elapsed", time.Since(start), err)
|
||||
return album, err
|
||||
}
|
||||
|
||||
album.ExternalInfoUpdatedAt = P(time.Now())
|
||||
album.ExternalUrl = info.URL
|
||||
|
||||
if info.Description != "" {
|
||||
album.Description = info.Description
|
||||
}
|
||||
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err == nil && len(images) > 0 {
|
||||
sort.Slice(images, func(i, j int) bool {
|
||||
return images[i].Size > images[j].Size
|
||||
})
|
||||
|
||||
album.LargeImageUrl = images[0].URL
|
||||
|
||||
if len(images) >= 2 {
|
||||
album.MediumImageUrl = images[1].URL
|
||||
}
|
||||
|
||||
if len(images) >= 3 {
|
||||
album.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
|
||||
}
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
|
||||
var artist auxArtist
|
||||
switch v := entity.(type) {
|
||||
case *model.Artist:
|
||||
artist.Artist = *v
|
||||
case *model.MediaFile:
|
||||
return e.getArtist(ctx, v.ArtistID)
|
||||
case *model.Album:
|
||||
return e.getArtist(ctx, v.AlbumArtistID)
|
||||
default:
|
||||
return auxArtist{}, model.ErrNotFound
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
|
||||
artist, err := e.refreshArtistInfo(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent)
|
||||
return &artist.Artist, err
|
||||
}
|
||||
|
||||
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
|
||||
// If we don't have any info, retrieves it now
|
||||
updatedAt := V(artist.ExternalInfoUpdatedAt)
|
||||
artistName := artist.Name()
|
||||
if updatedAt.IsZero() {
|
||||
log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
|
||||
artist, err = e.populateArtistInfo(ctx, artist)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// If info is expired, trigger a populateArtistInfo in the background
|
||||
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
|
||||
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
|
||||
e.artistQueue.enqueue(&artist)
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
|
||||
start := time.Now()
|
||||
// Get MBID first, if it is not yet available
|
||||
artistName := artist.Name()
|
||||
if artist.MbzArtistID == "" {
|
||||
mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
|
||||
if mbid != "" && err == nil {
|
||||
artist.MbzArtistID = mbid
|
||||
}
|
||||
}
|
||||
|
||||
// Call all registered agents and collect information
|
||||
g := errgroup.Group{}
|
||||
g.SetLimit(2)
|
||||
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.Wait()
|
||||
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
|
||||
return artist, ctx.Err()
|
||||
}
|
||||
|
||||
artist.ExternalInfoUpdatedAt = P(time.Now())
|
||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||
"elapsed", time.Since(start), err)
|
||||
} else {
|
||||
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
|
||||
}
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistRadio(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)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistRadio 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())
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
topCount := max(count, 20)
|
||||
topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
weight := topCount * (4 + artistWeight)
|
||||
for _, mf := range topSongs {
|
||||
weightedSongs.Add(mf, weight)
|
||||
weight -= 4
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
err = addArtist(artist.Artist, weightedSongs, count, 10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range artist.SimilarArtists {
|
||||
err := addArtist(a, weightedSongs, count, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var similarSongs model.MediaFiles
|
||||
for len(similarSongs) < count && weightedSongs.Size() > 0 {
|
||||
s, err := weightedSongs.Pick()
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error getting weighted song", err)
|
||||
continue
|
||||
}
|
||||
similarSongs = append(similarSongs, s)
|
||||
}
|
||||
|
||||
return similarSongs, nil
|
||||
}
|
||||
|
||||
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
artist, err := e.getArtist(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.callGetImage(ctx, e.ag, &artist)
|
||||
if utils.IsCtxDone(ctx) {
|
||||
log.Warn(ctx, "ArtistImage call canceled", ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
imageUrl := artist.ArtistImageUrl()
|
||||
if imageUrl == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(imageUrl)
|
||||
}
|
||||
|
||||
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
|
||||
album, err := e.getAlbum(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
albumName := album.Name()
|
||||
images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "GetAlbumImages call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
// Return the biggest image
|
||||
var img agents.ExternalImage
|
||||
for _, i := range images {
|
||||
if img.Size <= i.Size {
|
||||
img = i
|
||||
}
|
||||
}
|
||||
if img.URL == "" {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return url.Parse(img.URL)
|
||||
}
|
||||
|
||||
func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
|
||||
artist, err := e.findArtistByName(ctx, artistName)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, agents.ErrNotFound):
|
||||
log.Trace(ctx, "TopSongs not found", "name", artistName)
|
||||
return nil, model.ErrNotFound
|
||||
case errors.Is(err, context.Canceled):
|
||||
log.Debug(ctx, "TopSongs call canceled", err)
|
||||
default:
|
||||
log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
|
||||
artistName := artist.Name()
|
||||
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, 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, mbidMatches)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
|
||||
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
|
||||
|
||||
if len(mfs) == 0 {
|
||||
log.Debug(ctx, "No matching top songs found", "name", artistName)
|
||||
} else {
|
||||
log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
|
||||
}
|
||||
|
||||
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) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
|
||||
titleMap := map[string]string{}
|
||||
for _, s := range songs {
|
||||
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, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
|
||||
var mfs model.MediaFiles
|
||||
for _, t := range songs {
|
||||
if len(mfs) == count {
|
||||
break
|
||||
}
|
||||
if t.MBID != "" {
|
||||
if mf, ok := byMBID[t.MBID]; ok {
|
||||
mfs = append(mfs, mf)
|
||||
continue
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return
|
||||
}
|
||||
artist.ExternalUrl = artisURL
|
||||
}
|
||||
|
||||
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
|
||||
bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
bio = str.SanitizeText(bio)
|
||||
bio = strings.ReplaceAll(bio, "\n", " ")
|
||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
||||
}
|
||||
|
||||
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
|
||||
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sort.Slice(images, func(i, j int) bool { return images[i].Size > images[j].Size })
|
||||
|
||||
if len(images) >= 1 {
|
||||
artist.LargeImageUrl = images[0].URL
|
||||
}
|
||||
if len(images) >= 2 {
|
||||
artist.MediumImageUrl = images[1].URL
|
||||
}
|
||||
if len(images) >= 3 {
|
||||
artist.SmallImageUrl = images[2].URL
|
||||
}
|
||||
}
|
||||
|
||||
func (e *provider) callGetSimilar(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)
|
||||
if len(similar) == 0 || err != nil {
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
|
||||
log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
artist.SimilarArtists = sa
|
||||
}
|
||||
|
||||
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) {
|
||||
var result model.Artists
|
||||
var notPresent []string
|
||||
|
||||
artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name })
|
||||
|
||||
// Query all artists at once
|
||||
clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer {
|
||||
return squirrel.Like{"artist.name": name}
|
||||
})
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Or(clauses),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, artist := range artists {
|
||||
artistMap[artist.Name] = artist
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
// Process the similar artists
|
||||
for _, s := range similar {
|
||||
if artist, found := artistMap[s.Name]; found {
|
||||
result = append(result, artist)
|
||||
count++
|
||||
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
notPresent = append(notPresent, s.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Then fill up with non-present artists
|
||||
if includeNotPresent && count < limit {
|
||||
for _, s := range notPresent {
|
||||
// Let the ID empty to indicate that the artist is not present in the DB
|
||||
sa := model.Artist{Name: s}
|
||||
result = append(result, sa)
|
||||
|
||||
count++
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
|
||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Like{"artist.name": artistName},
|
||||
Max: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(artists) == 0 {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return &auxArtist{Artist: artists[0]}, nil
|
||||
}
|
||||
|
||||
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
|
||||
var ids []string
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if sa.ID == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, sa.ID)
|
||||
}
|
||||
|
||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||
Filters: squirrel.Eq{"artist.id": ids},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Use a map and iterate through original array, to keep the same order
|
||||
artistMap := make(map[string]model.Artist)
|
||||
for _, sa := range similar {
|
||||
artistMap[sa.ID] = sa
|
||||
}
|
||||
|
||||
var loaded model.Artists
|
||||
for _, sa := range artist.SimilarArtists {
|
||||
if len(loaded) >= count {
|
||||
break
|
||||
}
|
||||
la, ok := artistMap[sa.ID]
|
||||
if !ok {
|
||||
if !includeNotPresent {
|
||||
continue
|
||||
}
|
||||
la = sa
|
||||
la.ID = ""
|
||||
}
|
||||
loaded = append(loaded, la)
|
||||
}
|
||||
artist.SimilarArtists = loaded
|
||||
return nil
|
||||
}
|
||||
|
||||
type refreshQueue[T any] chan<- *T
|
||||
|
||||
func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] {
|
||||
queue := make(chan *T, refreshQueueLength)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(refreshDelay):
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
select {
|
||||
case item := <-queue:
|
||||
_, _ = processFn(ctx, *item)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return queue
|
||||
}
|
||||
|
||||
func (q *refreshQueue[T]) enqueue(item *T) {
|
||||
select {
|
||||
case *q <- item:
|
||||
default: // It is ok to miss a refresh request
|
||||
}
|
||||
}
|
||||
364
core/external/provider_albumimage_test.go
vendored
Normal file
364
core/external/provider_albumimage_test.go
vendored
Normal file
@@ -0,0 +1,364 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"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 - AlbumImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockAlbumAgent *mockAlbumInfoAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockAlbum" // Configure mock agent
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockAlbumAgent = newMockAlbumInfoAgent()
|
||||
|
||||
agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent}
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks
|
||||
// Mocks for GetEntityByID sequence (initial failed lookups)
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
// Default mock for non-existent entities - Use Maybe() for flexibility
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the album is not found in the DB", func() {
|
||||
// Arrange: Explicitly expect the full GetEntityByID sequence for "not-found"
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "not-found")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
agentErr := errors.New("agent failure")
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("agent failure"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(ctx)
|
||||
// Mock the necessary DB calls *before* canceling the context
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Expect the agent call even if context is cancelled, returning the context error
|
||||
mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once()
|
||||
// Cancel the context *before* calling the function under test
|
||||
cancelCtx()
|
||||
|
||||
imgURL, err := provider.AlbumImage(cctx, "album-1")
|
||||
|
||||
Expect(err).To(MatchError("context canceled"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
// Agent should now be called, verify this expectation
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("derives album ID from MediaFile ID", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1"
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once()
|
||||
// Explicitly mock agent call for this test
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/single.jpg", Size: 700},
|
||||
}, nil).Once()
|
||||
|
||||
expectedURL, _ := url.Parse("http://example.com/single.jpg")
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-1")
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if deriving album ID fails", func() {
|
||||
// Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found"
|
||||
mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once()
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once()
|
||||
|
||||
imgURL, err := provider.AlbumImage(ctx, "mf-no-album")
|
||||
|
||||
Expect(err).To(MatchError("data not found"))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
Context("Unicode handling in album names", func() {
|
||||
var albumWithEnDash *model.Album
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
|
||||
normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in album name
|
||||
albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/album.jpg")
|
||||
|
||||
// Mock the album agent to return an image for the album
|
||||
mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/album.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
|
||||
It("preserves Unicode characters in album names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.AlbumImage(ctx, "album-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockAlbumInfoAgent implementation
|
||||
type mockAlbumInfoAgent struct {
|
||||
mock.Mock
|
||||
agents.AlbumInfoRetriever
|
||||
agents.AlbumImageRetriever
|
||||
}
|
||||
|
||||
func newMockAlbumInfoAgent() *mockAlbumInfoAgent {
|
||||
m := new(mockAlbumInfoAgent)
|
||||
m.On("AgentName").Return("mockAlbum").Maybe()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*agents.AlbumInfo), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, name, artist, mbid)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]agents.ExternalImage), args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interfaces
|
||||
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil)
|
||||
362
core/external/provider_artistimage_test.go
vendored
Normal file
362
core/external/provider_artistimage_test.go
vendored
Normal file
@@ -0,0 +1,362 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"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 - ArtistImage", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var provider Provider
|
||||
var mockArtistRepo *mockArtistRepo
|
||||
var mockAlbumRepo *mockAlbumRepo
|
||||
var mockMediaFileRepo *mockMediaFileRepo
|
||||
var mockImageAgent *mockArtistImageAgent
|
||||
var agentsCombined *mockAgents
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.Agents = "mockImage" // Configure only the mock agent
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
mockArtistRepo = newMockArtistRepo()
|
||||
mockAlbumRepo = newMockAlbumRepo()
|
||||
mockMediaFileRepo = newMockMediaFileRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedArtist: mockArtistRepo,
|
||||
MockedAlbum: mockAlbumRepo,
|
||||
MockedMediaFile: mockMediaFileRepo,
|
||||
}
|
||||
|
||||
mockImageAgent = newMockArtistImageAgent()
|
||||
|
||||
// Use the mockAgents from helper, setting the specific agent
|
||||
agentsCombined = &mockAgents{
|
||||
imageAgent: mockImageAgent,
|
||||
}
|
||||
|
||||
provider = NewProvider(ds, agentsCombined)
|
||||
|
||||
// Default mocks for successful Get calls
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe()
|
||||
mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe()
|
||||
mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe()
|
||||
// Default mock for non-existent entities
|
||||
mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
|
||||
// Default successful image agent response
|
||||
mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
}, nil).Maybe()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
mockArtistRepo.AssertExpectations(GinkgoT())
|
||||
mockAlbumRepo.AssertExpectations(GinkgoT())
|
||||
mockMediaFileRepo.AssertExpectations(GinkgoT())
|
||||
mockImageAgent.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns the largest image URL when successful", func() {
|
||||
// Arrange
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the artist is not found in the DB", func() {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "not-found")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns the agent error if the agent fails", func() {
|
||||
// Arrange
|
||||
agentErr := errors.New("agent failure")
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns ErrNotFound", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if the agent returns no images", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns context error if context is canceled before agent call", func() {
|
||||
// Arrange
|
||||
cctx, cancelCtx := context.WithCancel(context.Background())
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well
|
||||
mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) {
|
||||
cancelCtx() // Cancel context *during* the DB call simulation
|
||||
}).Once()
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(cctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
})
|
||||
|
||||
It("derives artist ID from MediaFile ID", func() {
|
||||
// Arrange: Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("derives artist ID from Album ID", func() {
|
||||
// Arrange: Add mock for the initial GetEntityByID lookup
|
||||
mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
|
||||
// Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "album-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("returns ErrNotFound if derived artist is not found", func() {
|
||||
// Arrange
|
||||
// Add mocks for the initial GetEntityByID lookups
|
||||
mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once()
|
||||
mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once()
|
||||
// Add expectation for the recursive GetEntityByID call for the MediaFileRepo
|
||||
mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe()
|
||||
// The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist")
|
||||
|
||||
// Assert
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(imgURL).To(BeNil())
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence
|
||||
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist")
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found")
|
||||
mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("handles different image orders from agent", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/small.jpg", Size: 200},
|
||||
{URL: "http://example.com/large.jpg", Size: 1000},
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/large.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
It("handles agent returning only one image", func() {
|
||||
// Arrange
|
||||
mockImageAgent.Mock = mock.Mock{} // Reset default expectation
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/medium.jpg", Size: 500},
|
||||
}, nil).Once()
|
||||
expectedURL, _ := url.Parse("http://example.com/medium.jpg")
|
||||
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-1")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
|
||||
})
|
||||
|
||||
Context("Unicode handling in artist names", func() {
|
||||
var artistWithEnDash *model.Artist
|
||||
var expectedURL *url.URL
|
||||
|
||||
const (
|
||||
originalArtistName = "Run–D.M.C." // Artist name with en dash
|
||||
normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Test with en dash (–) in artist name like "Run–D.M.C."
|
||||
artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
|
||||
mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
|
||||
mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
|
||||
|
||||
expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
|
||||
|
||||
// Mock the image agent to return an image for the artist
|
||||
mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
|
||||
Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/rundmc.jpg", Size: 1000},
|
||||
}, nil).Once()
|
||||
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is true", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = true
|
||||
})
|
||||
It("preserves Unicode characters in artist names", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This is the key assertion: ensure the original Unicode name is used
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
|
||||
})
|
||||
})
|
||||
|
||||
When("DevPreserveUnicodeInExternalCalls is false", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||
})
|
||||
|
||||
It("normalizes Unicode characters", func() {
|
||||
// Act
|
||||
imgURL, err := provider.ArtistImage(ctx, "artist-endash")
|
||||
|
||||
// Assert
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(imgURL).To(Equal(expectedURL))
|
||||
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
|
||||
// This assertion ensures the normalized name is used (en dash → hyphen)
|
||||
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// mockArtistImageAgent implementation using testify/mock
|
||||
// This remains local as it's specific to testing the ArtistImage functionality
|
||||
type mockArtistImageAgent struct {
|
||||
mock.Mock
|
||||
agents.ArtistImageRetriever // Embed interface
|
||||
}
|
||||
|
||||
// Constructor for the mock agent
|
||||
func newMockArtistImageAgent() *mockArtistImageAgent {
|
||||
mock := new(mockArtistImageAgent)
|
||||
// Set default AgentName if needed, although usually called via mockAgents
|
||||
mock.On("AgentName").Return("mockImage").Maybe()
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) AgentName() string {
|
||||
args := m.Called()
|
||||
return args.String(0)
|
||||
}
|
||||
|
||||
func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) {
|
||||
args := m.Called(ctx, id, artistName, mbid)
|
||||
// Need careful type assertion for potentially nil slice
|
||||
var res []agents.ExternalImage
|
||||
if args.Get(0) != nil {
|
||||
res = args.Get(0).([]agents.ExternalImage)
|
||||
}
|
||||
return res, args.Error(1)
|
||||
}
|
||||
|
||||
// Ensure mockAgent implements the interface
|
||||
var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil)
|
||||
196
core/external/provider_artistradio_test.go
vendored
Normal file
196
core/external/provider_artistradio_test.go
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"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()
|
||||
|
||||
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
|
||||
return opt.Max == 0 && opt.Filters != nil
|
||||
})).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"))
|
||||
})
|
||||
})
|
||||
274
core/external/provider_topsongs_test.go
vendored
Normal file
274
core/external/provider_topsongs_test.go
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
_ "github.com/navidrome/navidrome/core/agents/lastfm"
|
||||
_ "github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||
_ "github.com/navidrome/navidrome/core/agents/spotify"
|
||||
. "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 - TopSongs", func() {
|
||||
var (
|
||||
p Provider
|
||||
artistRepo *mockArtistRepo // From provider_helper_test.go
|
||||
mediaFileRepo *mockMediaFileRepo // From provider_helper_test.go
|
||||
ag *mockAgents // Consolidated mock from export_test.go
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
|
||||
artistRepo = newMockArtistRepo() // Use helper mock
|
||||
mediaFileRepo = newMockMediaFileRepo() // Use helper mock
|
||||
|
||||
// Configure tests.MockDataStore to use the testify/mock-based repos
|
||||
ds := &tests.MockDataStore{
|
||||
MockedArtist: artistRepo,
|
||||
MockedMediaFile: mediaFileRepo,
|
||||
}
|
||||
|
||||
ag = new(mockAgents)
|
||||
|
||||
p = NewProvider(ds, ag)
|
||||
})
|
||||
|
||||
It("returns top songs for a known artist", 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", 2).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", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns nil for an unknown artist", func() {
|
||||
// Mock artist not found
|
||||
artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Unknown Artist", 5)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when the agent returns an error", 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 error
|
||||
agentErr := errors.New("agent error")
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(agentErr))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when the agent returns ErrNotFound", 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 ErrNotFound
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns fewer songs if count is less than available top songs", 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 (only need 1 for the test)
|
||||
agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching track
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, 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())
|
||||
})
|
||||
|
||||
It("returns fewer songs if fewer matching tracks are found", 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", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock finding matching tracks (only find song 1 on bulk query)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title fallback for song2
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
It("returns error when context is canceled during agent call", 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()
|
||||
|
||||
// Setup context that will be canceled
|
||||
canceledCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Mock agent call to return context canceled error
|
||||
ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once()
|
||||
|
||||
cancel() // Cancel the context before calling
|
||||
songs, err := p.TopSongs(canceledCtx, "Artist One", 5)
|
||||
|
||||
Expect(err).To(MatchError(context.Canceled))
|
||||
Expect(songs).To(BeNil())
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("falls back to title matching when MbzRecordingID is missing", 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 with songs that have NO MBID (empty string)
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching
|
||||
{Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Since there are no MBIDs, loadTracksByMBID should not make any database call
|
||||
// loadTracksByTitle should make a database call for title matching
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"}
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1"))
|
||||
Expect(songs[1].ID).To(Equal("song-2"))
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
mediaFileRepo.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("combines MBID and title matching when some songs have missing MbzRecordingID", 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 with mixed MBID availability
|
||||
agentSongs := []agents.Song{
|
||||
{Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID
|
||||
{Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching
|
||||
}
|
||||
ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once()
|
||||
|
||||
// Mock the MBID query (finds song1 by MBID)
|
||||
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
|
||||
|
||||
// Mock the title fallback query (finds song2 by title)
|
||||
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"}
|
||||
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once()
|
||||
|
||||
songs, err := p.TopSongs(ctx, "Artist One", 2)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(2))
|
||||
Expect(songs[0].ID).To(Equal("song-1")) // Found by MBID
|
||||
Expect(songs[1].ID).To(Equal("song-2")) // Found by title
|
||||
artistRepo.AssertExpectations(GinkgoT())
|
||||
ag.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())
|
||||
})
|
||||
})
|
||||
167
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
167
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
@@ -0,0 +1,167 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockAlbumRepo *tests.MockAlbumRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||
})
|
||||
|
||||
It("returns error when album is not found", func() {
|
||||
album, err := p.UpdateAlbumInfo(ctx, "al-not-found")
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(album).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("populates info when album exists but has no external info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-existing",
|
||||
Name: "Test Album",
|
||||
AlbumArtist: "Test Artist",
|
||||
MbzAlbumID: "mbid-album",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedInfo := &agents.AlbumInfo{
|
||||
URL: "http://example.com/album",
|
||||
Description: "Album Description",
|
||||
}
|
||||
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil)
|
||||
ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{
|
||||
{URL: "http://example.com/large.jpg", Size: 300},
|
||||
{URL: "http://example.com/medium.jpg", Size: 200},
|
||||
{URL: "http://example.com/small.jpg", Size: 100},
|
||||
}, nil)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(updatedAlbum.ID).To(Equal("al-existing"))
|
||||
Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album"))
|
||||
Expect(updatedAlbum.Description).To(Equal("Album Description"))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when album exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-cached",
|
||||
Name: "Cached Album",
|
||||
AlbumArtist: "Cached Artist",
|
||||
ExternalUrl: "http://cached.com/album",
|
||||
Description: "Cached Desc",
|
||||
LargeImageUrl: "http://cached.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-cached")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2)
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-expired",
|
||||
Name: "Expired Album",
|
||||
AlbumArtist: "Expired Artist",
|
||||
ExternalUrl: "http://expired.com/album",
|
||||
Description: "Expired Desc",
|
||||
LargeImageUrl: "http://expired.com/large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-expired")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
It("returns error when agent fails to get album info", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-error",
|
||||
Name: "Agent Error Album",
|
||||
AlbumArtist: "Agent Error Artist",
|
||||
MbzAlbumID: "mbid-agent-error",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
expectedErr := errors.New("agent communication failed")
|
||||
ag.On("GetAlbumInfo", ctx, "Agent Error Album", "Agent Error Artist", "mbid-agent-error").Return(nil, expectedErr)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-error")
|
||||
|
||||
Expect(err).To(MatchError(expectedErr))
|
||||
Expect(updatedAlbum).To(BeNil())
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns original album when agent returns ErrNotFound", func() {
|
||||
originalAlbum := &model.Album{
|
||||
ID: "al-agent-notfound",
|
||||
Name: "Agent NotFound Album",
|
||||
AlbumArtist: "Agent NotFound Artist",
|
||||
MbzAlbumID: "mbid-agent-notfound",
|
||||
}
|
||||
mockAlbumRepo.SetData(model.Albums{*originalAlbum})
|
||||
|
||||
ag.On("GetAlbumInfo", ctx, "Agent NotFound Album", "Agent NotFound Artist", "mbid-agent-notfound").Return(nil, agents.ErrNotFound)
|
||||
|
||||
updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-notfound")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedAlbum).NotTo(BeNil())
|
||||
Expect(*updatedAlbum).To(Equal(*originalAlbum))
|
||||
Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil())
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
@@ -0,0 +1,229 @@
|
||||
package external_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"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/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func init() {
|
||||
log.SetLevel(log.LevelDebug)
|
||||
}
|
||||
|
||||
var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||
var (
|
||||
ctx context.Context
|
||||
p external.Provider
|
||||
ds *tests.MockDataStore
|
||||
ag *mockAgents
|
||||
mockArtistRepo *tests.MockArtistRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
|
||||
ctx = GinkgoT().Context()
|
||||
ds = new(tests.MockDataStore)
|
||||
ag = new(mockAgents)
|
||||
p = external.NewProvider(ds, ag)
|
||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||
})
|
||||
|
||||
It("returns error when artist is not found", func() {
|
||||
artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)
|
||||
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
Expect(artist).To(BeNil())
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
|
||||
})
|
||||
|
||||
It("populates info when artist exists but has no external info", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-existing",
|
||||
Name: "Test Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedMBID := "mbid-artist-123"
|
||||
expectedBio := "Artist Bio"
|
||||
expectedURL := "http://artist.url"
|
||||
expectedImages := []agents.ExternalImage{
|
||||
{URL: "http://large.jpg", Size: 300},
|
||||
{URL: "http://medium.jpg", Size: 200},
|
||||
{URL: "http://small.jpg", Size: 100},
|
||||
}
|
||||
rawSimilar := []agents.Artist{
|
||||
{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
|
||||
{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
|
||||
{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}
|
||||
|
||||
ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
|
||||
ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||
ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()
|
||||
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-existing"))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
|
||||
Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
|
||||
Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
|
||||
Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||
Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))
|
||||
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
|
||||
It("returns cached info when artist exists and info is not expired", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-cached",
|
||||
Name: "Cached Artist",
|
||||
MbzArtistID: "mbid-cached",
|
||||
ExternalUrl: "http://cached.url",
|
||||
Biography: "Cached Bio",
|
||||
LargeImageUrl: "http://cached_large.jpg",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
|
||||
Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
|
||||
Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
|
||||
Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||
now := time.Now()
|
||||
expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-expired",
|
||||
Name: "Expired Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||
})
|
||||
|
||||
It("includes non-present similar artists when includeNotPresent is true", func() {
|
||||
now := time.Now()
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-similar-test",
|
||||
Name: "Similar Test Artist",
|
||||
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||
SimilarArtists: model.Artists{
|
||||
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||
{ID: "", Name: "Similar Absent Raw"},
|
||||
{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
|
||||
},
|
||||
}
|
||||
similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
|
||||
Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
|
||||
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||
Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
|
||||
Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
|
||||
Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
|
||||
})
|
||||
|
||||
It("updates ArtistInfo even if an optional agent call fails", func() {
|
||||
originalArtist := &model.Artist{
|
||||
ID: "ar-agent-fail",
|
||||
Name: "Agent Fail Artist",
|
||||
}
|
||||
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||
|
||||
expectedErr := errors.New("agent MBID failed")
|
||||
ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
|
||||
ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||
ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||
ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()
|
||||
|
||||
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedArtist).NotTo(BeNil())
|
||||
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||
ag.AssertExpectations(GinkgoT())
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user