Compare commits

...

15 Commits

Author SHA1 Message Date
Deluan
2a81ec9b5a docs: update plugin system design with security enhancements
Added local network access control flag, plugin verification system, and capabilities declaration to the HLD. Updated implementation plan to remove UserPreference functionality from PoC scope while maintaining security features like local network control and hash verification. Added future extensions section outlining potential plugin types beyond metadata agents.
2025-04-13 10:18:28 -04:00
Deluan
4d4625c766 docs: Add table of contents to plugin HLD
Add a two-level TOC with links to all major sections of the document
2025-04-13 00:15:08 -04:00
Deluan
626e5a7bb0 docs: Add plugin directory structure info and implementation plan
- Add section 5.6 describing the plugin directory structure\n- Add new implementation plan document with phased approach\n- Include progress tracking with checkboxes
2025-04-13 00:10:23 -04:00
Deluan
41535b54f5 docs: Add agent system integration to plugin HLD
- Add section 3.7 describing integration with existing agent system\n- Include visualization diagram of plugin-agent architecture\n- Document plugin adapter approach and future evolutions
2025-04-12 23:30:08 -04:00
Deluan
7e835b4557 Update plugin initialization flow to pass configuration during Init() 2025-04-12 22:05:45 -04:00
Deluan
67c4fa2c9d Improve sequence diagram by breaking down Navidrome Core into specific components 2025-04-12 21:38:05 -04:00
Deluan
438fd93d8e Clarify Permission Manager responsibilities in HLD 2025-04-12 21:33:54 -04:00
Deluan
96f446c4a0 Add support for wildcard URL permissions in plugin system 2025-04-12 18:46:27 -04:00
Deluan
449dd53edf Add High-Level Design document for plugin system 2025-04-12 17:19:25 -04:00
Deluan
a6f1f7b7e3 fix(server): albumtype in Smart Playlists
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:53:16 -04:00
Deluan Quintão
49b8cfc261 fix(artwork): always select the coverArt of the first disc in multi-disc albums (#3950)
* feat(artwork): sort image files for consistent cover art selection

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(artwork): improve album cover art selection by considering disc numbers

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-11 23:39:57 -04:00
Deluan
bcea8b832a chore(deps): update Go version to 1.24.2 in go.mod 2025-04-11 23:18:00 -04:00
Deluan Quintão
58367afaea refactor: external_metadata -> external.Provider (#3903)
* tests for TopSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* convert to Ginkgo

Signed-off-by: Deluan <deluan@navidrome.org>

* consolidate tests

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata -wip

Signed-off-by: Deluan <deluan@navidrome.org>

* rename external metadata to extdata.Provider

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor test helpers

Signed-off-by: Deluan <deluan@navidrome.org>

* remove reflection

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock.Mock

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix

Signed-off-by: Deluan <deluan@navidrome.org>

* receive Agents interface in Provider constructor

Signed-off-by: Deluan <deluan@navidrome.org>

* use mock for Agents

Signed-off-by: Deluan <deluan@navidrome.org>

* tests for SimilarSongs

Signed-off-by: Deluan <deluan@navidrome.org>

* remove duplication

Signed-off-by: Deluan <deluan@navidrome.org>

* ArtistImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* AlbumImage tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix provider error handling

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateAlbumInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* UpdateArtistInfo tests - wip

Signed-off-by: Deluan <deluan@navidrome.org>

* clean up

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* fix test descriptions

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: rename extdata package to external

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 21:11:09 -04:00
Deluan
6b59f5f73a feat(ui): add genre and mood fields to AlbumSongs component
Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-08 18:13:37 -04:00
Deluan Quintão
5f0c1e7387 chore(deps) upgrade Go dependencies, including golangci-lint (#3937)
* chore(deps): update Go dependencies

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(deps): upgrade golangci-lint

Signed-off-by: Deluan <deluan@navidrome.org>

* build: upgrade golangci-lint-action to v7

Signed-off-by: Deluan <deluan@navidrome.org>

* go mod tidy

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2025-04-07 19:42:00 -03:00
38 changed files with 3650 additions and 327 deletions

View File

@@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v7
with:
version: latest
problem-matchers: true

View File

@@ -1,7 +1,7 @@
version: "2"
run:
build-tags:
- netgo
linters:
enable:
- asasalint
@@ -11,42 +11,48 @@ linters:
- copyloopvar
- dogsled
- durationcheck
- errcheck
- errorlint
- gocyclo
- gocritic
- gocyclo
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nilerr
- rowserrcheck
- staticcheck
- typecheck
- unconvert
- unused
- whitespace
issues:
exclude-rules:
- path: scanner2
linters:
- unused
linters-settings:
gocritic:
disable-all: true
enabled-checks:
- deprecatedComment
govet:
enable:
- nilness
gosec:
excludes:
- G501
- G401
- G505
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149
disable:
- staticcheck
settings:
gocritic:
disable-all: true
enabled-checks:
- deprecatedComment
gosec:
excludes:
- G501
- G401
- G505
- G115
govet:
enable:
- nilness
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -49,7 +49,7 @@ testall: testrace ##@Development Run Go and JS tests
.PHONY: testall
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code

View File

@@ -14,6 +14,7 @@ import (
"github.com/navidrome/navidrome/core/agents/lastfm"
"github.com/navidrome/navidrome/core/agents/listenbrainz"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
@@ -66,8 +67,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
@@ -80,7 +81,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
playbackServer := playback.GetInstance(dataStore)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer)
return router
}
@@ -90,8 +91,8 @@ func CreatePublicRouter() *public.Router {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
transcodingCache := core.GetTranscodingCache()
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
share := core.NewShare(dataStore)
@@ -134,8 +135,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
@@ -150,8 +151,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
fileCache := artwork.GetImageCache()
fFmpeg := ffmpeg.New()
agentsAgents := agents.GetAgents(dataStore)
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
provider := external.NewProvider(dataStore, agentsAgents)
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)

View File

@@ -8,7 +8,7 @@ import (
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -24,15 +24,15 @@ type Artwork interface {
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
em core.ExternalMetadata
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
}
type artworkReader interface {
@@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
} else {
switch artID.Kind {
case model.KindArtistArtwork:
artReader, err = newArtistReader(ctx, a, artID, a.em)
artReader, err = newArtistReader(ctx, a, artID, a.provider)
case model.KindAlbumArtwork:
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
case model.KindMediaFileArtwork:
artReader, err = newMediafileArtworkReader(ctx, a, artID)
case model.KindPlaylistArtwork:

View File

@@ -6,12 +6,14 @@ import (
"fmt"
"io"
"path/filepath"
"slices"
"strings"
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model"
)
@@ -19,14 +21,14 @@ import (
type albumArtworkReader struct {
cacheKey
a *artwork
em core.ExternalMetadata
provider external.Provider
album model.Album
updatedAt *time.Time
imgFiles []string
rootFolder string
}
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) {
func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) {
al, err := artwork.ds.Album(ctx).Get(artID.ID)
if err != nil {
return nil, err
@@ -37,7 +39,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
}
a := &albumArtworkReader{
a: artwork,
em: em,
provider: provider,
album: *al,
updatedAt: imagesUpdateAt,
imgFiles: imgFiles,
@@ -82,7 +84,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
case pattern == "external":
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em))
ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider))
case len(a.imgFiles) > 0:
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
}
@@ -112,5 +114,10 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
imgFiles = append(imgFiles, filepath.Join(path, img))
}
}
// Sort image files to ensure consistent selection of cover art
// This prioritizes files from lower-numbered disc folders by sorting the paths
slices.Sort(imgFiles)
return paths, imgFiles, &updatedAt, nil
}

View File

@@ -0,0 +1,76 @@
package artwork
import (
"context"
"path/filepath"
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Album Artwork Reader", func() {
Describe("loadAlbumFoldersPaths", func() {
var (
ctx context.Context
ds *fakeDataStore
repo *fakeFolderRepo
album model.Album
now time.Time
expectedAt time.Time
)
BeforeEach(func() {
ctx = context.Background()
now = time.Now().Truncate(time.Second)
expectedAt = now.Add(5 * time.Minute)
// Set up the test folders with image files
repo = &fakeFolderRepo{
result: []model.Folder{
{
Path: "Artist/Album/Disc1",
ImagesUpdatedAt: expectedAt,
ImageFiles: []string{"cover.jpg", "back.jpg"},
},
{
Path: "Artist/Album/Disc2",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
{
Path: "Artist/Album/Disc10",
ImagesUpdatedAt: now,
ImageFiles: []string{"cover.jpg"},
},
},
err: nil,
}
ds = &fakeDataStore{
folderRepo: repo,
}
album = model.Album{
ID: "album1",
Name: "Album",
FolderIDs: []string{"folder1", "folder2", "folder3"},
}
})
It("returns sorted image files", func() {
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
// Check that image files are sorted alphabetically
Expect(imgFiles).To(HaveLen(4))
// The files should be sorted by full path
Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg")))
Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg")))
Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg")))
Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg")))
})
})
})

View File

@@ -14,6 +14,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
@@ -22,13 +23,13 @@ import (
type artistReader struct {
cacheKey
a *artwork
em core.ExternalMetadata
provider external.Provider
artist model.Artist
artistFolder string
imgFiles []string
}
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) {
func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) {
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return nil, err
@@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
}
a := &artistReader{
a: artwork,
em: em,
provider: provider,
artist: *ar,
artistFolder: artistFolder,
imgFiles: imgFiles,
@@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "external":
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em))
ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider))
case strings.HasPrefix(pattern, "album/"):
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
default:

View File

@@ -17,7 +17,7 @@ import (
"github.com/dhowden/tag"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -157,9 +157,9 @@ func fromAlbumPlaceholder() sourceFunc {
return r, consts.PlaceholderAlbumArt, nil
}
}
func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc {
func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc {
return func() (io.ReadCloser, string, error) {
imageUrl, err := em.ArtistImage(ctx, ar.ID)
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
if err != nil {
return nil, "", err
}
@@ -168,9 +168,9 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.Exte
}
}
func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc {
func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc {
return func() (io.ReadCloser, string, error) {
imageUrl, err := em.AlbumImage(ctx, al.ID)
imageUrl, err := provider.AlbumImage(ctx, al.ID)
if err != nil {
return nil, "", err
}

270
core/external/extdata_helper_test.go vendored Normal file
View File

@@ -0,0 +1,270 @@
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 agents.AlbumInfoRetriever
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)
}

17
core/external/extdata_suite_test.go vendored Normal file
View 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")
}

View File

@@ -1,4 +1,4 @@
package core
package external
import (
"context"
@@ -31,7 +31,7 @@ const (
refreshQueueLength = 2000
)
type ExternalMetadata interface {
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)
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
@@ -40,9 +40,9 @@ type ExternalMetadata interface {
AlbumImage(ctx context.Context, id string) (*url.URL, error)
}
type externalMetadata struct {
type provider struct {
ds model.DataStore
ag *agents.Agents
ag Agents
artistQueue refreshQueue[auxArtist]
albumQueue refreshQueue[auxAlbum]
}
@@ -57,14 +57,24 @@ type auxArtist struct {
Name string
}
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
e := &externalMetadata{ds: ds, ag: agents}
type Agents interface {
agents.AlbumInfoRetriever
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 *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
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 {
@@ -81,10 +91,11 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, e
default:
return auxAlbum{}, model.ErrNotFound
}
return album, nil
}
func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) {
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)
@@ -109,7 +120,7 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod
return &album.Album, nil
}
func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
@@ -155,7 +166,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum
return album, nil
}
func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) {
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 {
@@ -177,7 +188,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist,
return artist, nil
}
func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) {
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
@@ -187,7 +198,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
return &artist.Artist, err
}
func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return auxArtist{}, err
@@ -211,7 +222,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (au
return artist, nil
}
func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
if artist.MbzArtistID == "" {
@@ -246,7 +257,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
return artist, nil
}
func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
@@ -304,7 +315,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
return similarSongs, nil
}
func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
@@ -318,24 +329,35 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
imageUrl := artist.ArtistImageUrl()
if imageUrl == "" {
return nil, agents.ErrNotFound
return nil, model.ErrNotFound
}
return url.Parse(imageUrl)
}
func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) {
album, err := e.getAlbum(ctx, id)
if err != nil {
return nil, err
}
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumInfo call canceled", err)
default:
log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
}
return nil, err
}
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "AlbumImage call canceled", ctx.Err())
return nil, ctx.Err()
if info == nil {
log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
// Return the biggest image
@@ -346,26 +368,37 @@ func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL,
}
}
if img.URL == "" {
return nil, agents.ErrNotFound
return nil, model.ErrNotFound
}
return url.Parse(img.URL)
}
func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) {
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
}
return e.getMatchingTopSongs(ctx, e.ag, artist, count)
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 *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
if errors.Is(err, agents.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
@@ -386,10 +419,11 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
} else {
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
}
return mfs, nil
}
func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) {
if mbid != "" {
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
@@ -420,7 +454,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
return &mfs[0], nil
}
func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
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
@@ -428,7 +462,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
artist.ExternalUrl = artisURL
}
func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
if err != nil {
return
@@ -438,7 +472,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
}
func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
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
@@ -456,7 +490,7 @@ func (e *externalMetadata) callGetImage(ctx context.Context, agent agents.Artist
}
}
func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
@@ -471,7 +505,7 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
artist.SimilarArtists = sa
}
func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) {
var result model.Artists
var notPresent []string
@@ -515,7 +549,7 @@ func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agen
return result, nil
}
func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) {
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,
@@ -533,7 +567,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
return artist, nil
}
func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
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 == "" {

View File

@@ -0,0 +1,303 @@
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 agentsCombined *mockAgents
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("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{
Images: []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(), "GetAlbumInfo", 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(), "GetAlbumInfo", mock.Anything, 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("GetAlbumInfo", 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(), "GetAlbumInfo", 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("GetAlbumInfo", 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(), "GetAlbumInfo", 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("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{Images: []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(), "GetAlbumInfo", 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("GetAlbumInfo", 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(), "GetAlbumInfo", 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("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{
Images: []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(), "GetAlbumInfo", 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("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{
Images: []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(), "GetAlbumInfo", 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("GetAlbumInfo", ctx, "Album One", "", "").
Return(&agents.AlbumInfo{
Images: []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(), "GetAlbumInfo", 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(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
})
})
// mockAlbumInfoAgent implementation
type mockAlbumInfoAgent struct {
mock.Mock
agents.AlbumInfoRetriever // Embed interface
}
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)
}
// Ensure mockAgent implements the interface
var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil)

View File

@@ -0,0 +1,301 @@
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", "")
})
})
// 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)

View File

@@ -0,0 +1,198 @@
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 - SimilarSongs", 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"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-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.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
mediaFileRepo.FindByMBID("mbid-3", song3)
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
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.FindByMBID("mbid-1", song1)
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-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([]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.FindByMBID("mbid-1", song1)
mediaFileRepo.FindByMBID("mbid-2", song2)
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})

193
core/external/provider_topsongs_test.go vendored Normal file
View File

@@ -0,0 +1,193 @@
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)
})
BeforeEach(func() {
// Setup expectations in individual tests
})
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
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}, nil).Once()
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"))
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)
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()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For mbid-song-2 (fails)
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // For title fallback (fails)
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())
})
})

View File

@@ -0,0 +1,170 @@
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",
Images: []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},
},
}
ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, 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.LargeImageUrl).To(Equal("http://example.com/large.jpg"))
Expect(updatedAlbum.MediumImageUrl).To(Equal("http://example.com/medium.jpg"))
Expect(updatedAlbum.SmallImageUrl).To(Equal("http://example.com/small.jpg"))
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())
})
})

View 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())
})
})

View File

@@ -3,6 +3,7 @@ package core
import (
"github.com/google/wire"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
@@ -13,11 +14,12 @@ var Set = wire.NewSet(
NewMediaStreamer,
GetTranscodingCache,
NewArchiver,
NewExternalMetadata,
NewPlayers,
NewShare,
NewPlaylists,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
ffmpeg.New,
scrobbler.GetPlayTracker,
playback.GetInstance,

1037
docs/hld-plugins.md Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
# Navidrome Plugin System Implementation Plan
## Progress Tracking
### Phase 1: Foundational Infrastructure
- [ ] 1.1: Plugin Manifest and Configuration
- [ ] 1.2: Basic WebAssembly Runtime Integration
- [ ] 1.3: Permission Management System
- [ ] 1.3.1: URL Allowlist Implementation
- [ ] 1.3.2: Local Network Access Control
- [ ] 1.3.3: Host Function Access Control
- [ ] 1.4: Project Structure and CLI Commands
- [ ] 1.5: Plugin Verification System
### Phase 2: Protocol Definition and Host Functions
- [ ] 2.1: Protocol Buffer Definitions
- [ ] 2.2: Host Function Implementation
- [ ] 2.3: Plugin Context Management
### Phase 3: Plugin Loading and Execution
- [ ] 3.1: WebAssembly Runtime Configuration
- [ ] 3.2: Testing Infrastructure
- [ ] 3.3: Plugin Developer Tools
### Phase 4: Agent Plugin Integration
- [ ] 4.1: Agent Plugin Adapter Implementation
- [ ] 4.2: Plugin Registration with Agent System
- [ ] 4.3: Last.fm Agent Plugin Implementation
- [ ] 4.4: Integration Testing
### Phase 5: Enhanced Management and User Experience
- [ ] 5.1: Enhanced CLI Management
- [ ] 5.2: Plugin Package Format
- [ ] 5.3: Runtime Monitoring
- [ ] 5.4: Administrative UI (Optional)
### Phase 6: Documentation and Release
- [ ] 6.1: User Documentation
- [ ] 6.2: Developer Documentation
- [ ] 6.3: Example Plugin Templates
- [ ] 6.4: Final Testing and Feature Flags
## Phase 1: Foundational Infrastructure
**Goal:** Establish the core plugin infrastructure without affecting existing functionality.
### 1.1: Plugin Manifest and Configuration
- Create plugin manifest schema and validation functions
- Add plugin-related configuration to `conf` package:
- Global plugin settings: enabled, directory, default limits
- Per-plugin settings: enabled, limits, configuration
- Add tests for manifest validation and configuration parsing
### 1.2: Basic WebAssembly Runtime Integration
- Add `knqyf263/go-plugin` dependency
- Create initial plugin loader that can:
- Discover plugin files in configured directory
- Read and validate manifests
- Basic security validation (no plugin execution yet)
- Add unit tests for plugin discovery and manifest loading
### 1.3: Permission Management System
- Implement the `PermissionManager` component:
- URL allowlist validation
- Host function allowlist validation
- Internal network access prevention
- Configuration access control
- Add comprehensive security tests for all permission rules
- Implement local network access control feature:
- Add `allowLocalNetwork` flag to manifest schema
- Update permission checks in HTTP requests
- Add configuration option for default behavior
- Add tests for local network access control
### 1.4: Project Structure and CLI Commands
- Create plugin-related directory structure:
```
plugins/
├── proto/ # Protocol Buffer definitions
├── manager.go # Plugin Manager implementation
├── host.go # Host function implementations
├── permission.go # Permission manager
└── adapters/ # Adapters for different plugin types
```
- Implement basic CLI commands for plugin management:
- `navidrome plugin list`
- `navidrome plugin info [name]`
### 1.5: Plugin Verification System
- Implement plugin binary integrity verification:
- Add hash calculation and storage during installation
- Add verification during plugin loading
- Create a local store for plugin hashes
- Add tests for plugin verification workflow
- Update CLI commands to display verification status
**Deliverable:** Foundation layer with security features including local network control and plugin verification.
## Phase 2: Protocol Definition and Host Functions
**Goal:** Define the communication protocol between Navidrome and plugins.
### 2.1: Protocol Buffer Definitions
- Define Protocol Buffer specifications for:
- Agent plugin interface
- Host functions interface
- Common request/response structures
- Generate Go code from Protocol Buffers
- Create test stubs for interface implementations
### 2.2: Host Function Implementation
- Implement core host functions:
- `GetConfig` for configuration access
- `Log` for plugin logging
- `HttpDo` for controlled HTTP access
- Add comprehensive tests for each host function
- Implement permission checks for all host functions
### 2.3: Plugin Context Management
- Create plugin context structure to track:
- Current plugin name
- Permission scope
- Runtime state
- Implement proper isolation between plugin calls
**Deliverable:** Complete protocol definition and host function implementations without executing actual plugins.
## Phase 3: Plugin Loading and Execution (Minimal)
**Goal:** Enable basic plugin loading and execution in isolation from the rest of the system.
### 3.1: WebAssembly Runtime Configuration
- Configure WebAssembly runtime with appropriate security settings
- Implement plugin initialization with configuration passing
- Add proper error handling for plugin loading failures
### 3.2: Testing Infrastructure
- Create test harness for plugin execution
- Implement simple test plugins for validation
- Add integration tests for plugin loading and execution
- Add tests for local network access
- Add tests for plugin verification and integrity checks
### 3.3: Plugin Developer Tools
- Implement development commands:
- `navidrome plugin dev [folder_path]`
- `navidrome plugin refresh [name]`
- Create basic development documentation
**Deliverable:** Working plugin loading and execution system that can be tested in isolation.
## Phase 4: Agent Plugin Integration
**Goal:** Connect the plugin system to the existing agent architecture.
### 4.1: Agent Plugin Adapter Implementation
- Create adapter that implements all agent interfaces:
- Convert between Protobuf and agent interfaces
- Implement proper error handling and timeouts
- Add trace logging for debugging
- Add unit tests for all adapter methods
- Update adapter to respect plugin's declared capabilities
### 4.2: Plugin Registration with Agent System
- Implement plugin registration with the existing agent system
- Extend configuration to support plugin agent ordering
- Make plugin agents respect the same priority system as built-in agents
### 4.3: Last.fm Agent Plugin Implementation
- Implement prototype Last.fm plugin as proof of concept
- Create plugin manifest with necessary permissions
- Add tests comparing plugin behavior to built-in agent
### 4.4: Integration Testing
- Add comprehensive integration tests for:
- Plugin discovery and loading
- Agent API functionality
- Error handling and recovery
- Configuration changes
**Deliverable:** Working plugin system with Last.fm plugin implementation that can be toggled via configuration without breaking existing functionality.
## Phase 5: Enhanced Management and User Experience
**Goal:** Improve plugin management and user experience.
### 5.1: Enhanced CLI Management
- Complete remaining CLI commands:
- `navidrome plugin install [file]`
- `navidrome plugin remove [name]`
- `navidrome plugin config-template [name]`
- Add command validation and error handling
### 5.2: Plugin Package Format
- Implement `.ndp` package format:
- Package creation
- Validation
- Installation
- Add tests for package integrity checking
### 5.3: Runtime Monitoring
- Add runtime statistics:
- Plugin execution time
- Resource usage
- Error tracking
- Implement health checks and recovery mechanisms
### 5.4: Administrative UI (Optional)
- Create basic admin UI for plugin management:
- View installed plugins
- Enable/disable plugins
- View permissions
- Configure plugins
**Deliverable:** Complete plugin management tooling with good user experience.
## Phase 6: Documentation and Release
**Goal:** Prepare the plugin system for production use and developer adoption.
### 6.1: User Documentation
- Create comprehensive user documentation:
- Plugin installation and management
- Configuration options
- Security considerations
- Troubleshooting
### 6.2: Developer Documentation
- Create plugin development guide:
- API reference
- Development workflow
- Best practices
- Examples
### 6.3: Example Plugin Templates
- Create starter templates for common plugin types:
- Basic agent plugin
- Custom service plugin
- Include CI/CD configurations
- Add examples for different permission scenarios:
- Standard external API access
- Local network access (with `allowLocalNetwork: true`)
- Different capability declarations
### 6.4: Final Testing and Feature Flags
- Add feature flag to enable/disable plugin system
- Perform comprehensive integration testing
- Address any final security concerns
**Deliverable:** Production-ready plugin system with documentation and examples.
## Risk Assessment and Mitigation
1. **Security Risks**
- **Risk**: Plugin execution could compromise system security
- **Mitigation**: Strict permission model, WebAssembly sandbox, URL validation
2. **Performance Impact**
- **Risk**: WebAssembly execution might be slower than native code
- **Mitigation**: Benchmarking, caching mechanisms, performance monitoring
3. **Backward Compatibility**
- **Risk**: Changes might break existing functionality
- **Mitigation**: Feature flags, phased integration, comprehensive testing
4. **User Experience**
- **Risk**: Plugin management could be complex for users
- **Mitigation**: Clear documentation, intuitive CLI, potential UI integration
5. **Developer Adoption**
- **Risk**: Plugin development might be too complex
- **Mitigation**: Clear documentation, example templates, developer tooling

47
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
go 1.24.1
go 1.24.2
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
@@ -24,8 +24,8 @@ require (
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.1
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.14.1
github.com/go-chi/jwtauth/v5 v5.3.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-viper/encoding/ini v0.1.1
github.com/gohugoio/hashstructure v0.5.0
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
@@ -37,31 +37,31 @@ require (
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.4
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/mattn/go-sqlite3 v1.14.27
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.23.0
github.com/onsi/gomega v1.36.2
github.com/pelletier/go-toml/v2 v2.2.3
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.24.1
github.com/pressly/goose/v3 v3.24.2
github.com/prometheus/client_golang v1.21.1
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.0
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
go.uber.org/goleak v1.3.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/image v0.25.0
golang.org/x/net v0.37.0
golang.org/x/sync v0.12.0
golang.org/x/sys v0.31.0
golang.org/x/text v0.23.0
golang.org/x/image v0.26.0
golang.org/x/net v0.38.0
golang.org/x/sync v0.13.0
golang.org/x/sys v0.32.0
golang.org/x/text v0.24.0
golang.org/x/time v0.11.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -74,19 +74,20 @@ require (
github.com/creack/pty v1.1.11 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -101,22 +102,24 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)

122
go.sum
View File

@@ -53,21 +53,21 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
@@ -85,8 +85,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro=
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -102,8 +102,6 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
@@ -114,8 +112,10 @@ github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -146,8 +146,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -160,28 +160,30 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ=
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
@@ -194,8 +196,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
@@ -209,16 +211,16 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -237,6 +239,12 @@ github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQ
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -248,13 +256,13 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -275,8 +283,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -284,8 +292,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -304,8 +312,8 @@ golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -326,8 +334,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -342,8 +350,8 @@ golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -355,17 +363,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=

View File

@@ -183,6 +183,8 @@ func (mfs MediaFiles) ToAlbum() Album {
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
a.Missing = true
embedArtPath := ""
embedArtDisc := 0
for _, m := range mfs {
// We assume these attributes are all the same for all songs in an album
a.ID = m.AlbumID
@@ -211,15 +213,15 @@ func (mfs MediaFiles) ToAlbum() Album {
comments = append(comments, m.Comment)
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
if m.HasCoverArt && a.EmbedArtPath == "" {
a.EmbedArtPath = m.Path
}
if m.DiscNumber > 0 {
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
}
tags = append(tags, m.Tags.FlattenAll()...)
a.Participants.Merge(m.Participants)
// Find the MediaFile with cover art and the lowest disc number to use for album cover
embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m)
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
a.ExplicitStatus = "c"
} else if m.ExplicitStatus == "e" {
@@ -231,6 +233,7 @@ func (mfs MediaFiles) ToAlbum() Album {
a.Missing = a.Missing && m.Missing
}
a.EmbedArtPath = embedArtPath
a.SetTags(tags)
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
a.Date, _ = allOrNothing(dates)
@@ -305,6 +308,28 @@ func fixAlbumArtist(a *Album) {
}
}
// firstArtPath determines which media file path should be used for album artwork
// based on disc number (preferring lower disc numbers) and path (for consistency)
func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) {
if !m.HasCoverArt {
return currentPath, currentDisc
}
// If current has no disc number (currentDisc == 0) or new file has lower disc number
if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) {
return m.Path, m.DiscNumber
}
// If disc numbers are equal, use path for ordering
if m.DiscNumber == currentDisc {
if m.Path < currentPath || currentPath == "" {
return m.Path, m.DiscNumber
}
}
return currentPath, currentDisc
}
type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {

View File

@@ -305,6 +305,101 @@ var _ = Describe("MediaFiles", func() {
})
})
})
Context("Album Art", func() {
When("we have media files with cover art from multiple discs", func() {
BeforeEach(func() {
mfs = MediaFiles{
{
Path: "Artist/Album/Disc2/01.mp3",
HasCoverArt: true,
DiscNumber: 2,
},
{
Path: "Artist/Album/Disc1/01.mp3",
HasCoverArt: true,
DiscNumber: 1,
},
{
Path: "Artist/Album/Disc3/01.mp3",
HasCoverArt: true,
DiscNumber: 3,
},
}
})
It("selects the cover art from the lowest disc number", func() {
album := mfs.ToAlbum()
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
})
})
When("we have media files with cover art from the same disc number", func() {
BeforeEach(func() {
mfs = MediaFiles{
{
Path: "Artist/Album/Disc1/02.mp3",
HasCoverArt: true,
DiscNumber: 1,
},
{
Path: "Artist/Album/Disc1/01.mp3",
HasCoverArt: true,
DiscNumber: 1,
},
}
})
It("selects the cover art with the lowest path alphabetically", func() {
album := mfs.ToAlbum()
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3"))
})
})
When("we have media files with some missing cover art", func() {
BeforeEach(func() {
mfs = MediaFiles{
{
Path: "Artist/Album/Disc1/01.mp3",
HasCoverArt: false,
DiscNumber: 1,
},
{
Path: "Artist/Album/Disc2/01.mp3",
HasCoverArt: true,
DiscNumber: 2,
},
}
})
It("selects the file with cover art even if from a higher disc number", func() {
album := mfs.ToAlbum()
Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3"))
})
})
When("we have media files with path names that don't correlate with disc numbers", func() {
BeforeEach(func() {
mfs = MediaFiles{
{
Path: "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically
HasCoverArt: true,
DiscNumber: 1, // But it has lowest disc number
},
{
Path: "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically
HasCoverArt: true,
DiscNumber: 2, // But it has higher disc number
},
{
Path: "Artist/Album/file-m.mp3",
HasCoverArt: true,
DiscNumber: 3,
},
}
})
It("selects the cover art from the lowest disc number regardless of path", func() {
album := mfs.ToAlbum()
Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3"))
})
})
})
})
})
})

View File

@@ -50,6 +50,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
mf.MbzAlbumType = md.String(model.TagReleaseType)
// ReplayGain
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)

View File

@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
@@ -30,37 +31,37 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
type Router struct {
http.Handler
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
externalMetadata core.ExternalMetadata
playlists core.Playlists
scanner scanner.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
ds model.DataStore
artwork artwork.Artwork
streamer core.MediaStreamer
archiver core.Archiver
players core.Players
provider external.Provider
playlists core.Playlists
scanner scanner.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
playback playback.PlaybackServer
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker,
players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
) *Router {
r := &Router{
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
externalMetadata: externalMetadata,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
ds: ds,
artwork: artwork,
streamer: streamer,
archiver: archiver,
players: players,
provider: provider,
playlists: playlists,
scanner: scanner,
broker: broker,
scrobbler: scrobbler,
share: share,
playback: playback,
}
r.Handler = r.routes()
return r

View File

@@ -210,7 +210,7 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
return nil, err
}
album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id)
album, err := api.provider.UpdateAlbumInfo(ctx, id)
if err != nil {
return nil, err
@@ -278,7 +278,7 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m
count := p.IntOr("count", 20)
includeNotPresent := p.BoolOr("includeNotPresent", false)
artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent)
artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent)
if err != nil {
return nil, nil, err
}
@@ -343,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
}
count := p.IntOr("count", 50)
songs, err := api.externalMetadata.SimilarSongs(ctx, id, count)
songs, err := api.provider.SimilarSongs(ctx, id, count)
if err != nil {
return nil, err
}
@@ -377,8 +377,8 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
}
count := p.IntOr("count", 50)
songs, err := api.externalMetadata.TopSongs(ctx, artist, count)
if err != nil {
songs, err := api.provider.TopSongs(ctx, artist, count)
if err != nil && !errors.Is(err, model.ErrNotFound) {
return nil, err
}

View File

@@ -10,56 +10,56 @@ import (
func CreateMockAlbumRepo() *MockAlbumRepo {
return &MockAlbumRepo{
data: make(map[string]*model.Album),
Data: make(map[string]*model.Album),
}
}
type MockAlbumRepo struct {
model.AlbumRepository
data map[string]*model.Album
all model.Albums
err bool
Data map[string]*model.Album
All model.Albums
Err bool
Options model.QueryOptions
}
func (m *MockAlbumRepo) SetError(err bool) {
m.err = err
m.Err = err
}
func (m *MockAlbumRepo) SetData(albums model.Albums) {
m.data = make(map[string]*model.Album, len(albums))
m.all = albums
for i, a := range m.all {
m.data[a.ID] = &m.all[i]
m.Data = make(map[string]*model.Album, len(albums))
m.All = albums
for i, a := range m.All {
m.Data[a.ID] = &m.All[i]
}
}
func (m *MockAlbumRepo) Exists(id string) (bool, error) {
if m.err {
if m.Err {
return false, errors.New("unexpected error")
}
_, found := m.data[id]
_, found := m.Data[id]
return found, nil
}
func (m *MockAlbumRepo) Get(id string) (*model.Album, error) {
if m.err {
if m.Err {
return nil, errors.New("unexpected error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockAlbumRepo) Put(al *model.Album) error {
if m.err {
if m.Err {
return errors.New("unexpected error")
}
if al.ID == "" {
al.ID = id.NewRandom()
}
m.data[al.ID] = al
m.Data[al.ID] = al
return nil
}
@@ -67,17 +67,17 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) {
if len(qo) > 0 {
m.Options = qo[0]
}
if m.err {
if m.Err {
return nil, errors.New("unexpected error")
}
return m.all, nil
return m.All, nil
}
func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.err {
if m.Err {
return errors.New("unexpected error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
@@ -85,15 +85,15 @@ func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
return model.ErrNotFound
}
func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) {
return int64(len(m.all)), nil
return int64(len(m.All)), nil
}
func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
if m.err {
if m.Err {
return nil, errors.New("unexpected error")
}
return func(yield func(model.Album, error) bool) {
for _, a := range m.data {
for _, a := range m.Data {
if a.ID == "error" {
if !yield(*a, errors.New("error")) {
break
@@ -110,4 +110,11 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
}, nil
}
func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
if m.Err {
return errors.New("unexpected error")
}
return nil
}
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)

View File

@@ -10,61 +10,61 @@ import (
func CreateMockArtistRepo() *MockArtistRepo {
return &MockArtistRepo{
data: make(map[string]*model.Artist),
Data: make(map[string]*model.Artist),
}
}
type MockArtistRepo struct {
model.ArtistRepository
data map[string]*model.Artist
err bool
Data map[string]*model.Artist
Err bool
}
func (m *MockArtistRepo) SetError(err bool) {
m.err = err
m.Err = err
}
func (m *MockArtistRepo) SetData(artists model.Artists) {
m.data = make(map[string]*model.Artist)
m.Data = make(map[string]*model.Artist)
for i, a := range artists {
m.data[a.ID] = &artists[i]
m.Data[a.ID] = &artists[i]
}
}
func (m *MockArtistRepo) Exists(id string) (bool, error) {
if m.err {
if m.Err {
return false, errors.New("Error!")
}
_, found := m.data[id]
_, found := m.Data[id]
return found, nil
}
func (m *MockArtistRepo) Get(id string) (*model.Artist, error) {
if m.err {
if m.Err {
return nil, errors.New("Error!")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error {
if m.err {
if m.Err {
return errors.New("error")
}
if ar.ID == "" {
ar.ID = id.NewRandom()
}
m.data[ar.ID] = ar
m.Data[ar.ID] = ar
return nil
}
func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.err {
if m.Err {
return errors.New("error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
@@ -72,4 +72,26 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
return model.ErrNotFound
}
func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
if m.Err {
return nil, errors.New("mock repo error")
}
var allArtists model.Artists
for _, artist := range m.Data {
allArtists = append(allArtists, *artist)
}
// Apply Max=1 if present (simple simulation for findArtistByName)
if len(options) > 0 && options[0].Max == 1 && len(allArtists) > 0 {
return allArtists[:1], nil
}
return allArtists, nil
}
func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error {
if m.Err {
return errors.New("mock repo error")
}
return nil
}
var _ model.ArtistRepository = (*MockArtistRepo)(nil)

View File

@@ -6,12 +6,12 @@ import (
type MockedGenreRepo struct {
Error error
data map[string]model.Genre
Data map[string]model.Genre
}
func (r *MockedGenreRepo) init() {
if r.data == nil {
r.data = make(map[string]model.Genre)
if r.Data == nil {
r.Data = make(map[string]model.Genre)
}
}
@@ -22,7 +22,7 @@ func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) {
r.init()
var all model.Genres
for _, g := range r.data {
for _, g := range r.Data {
all = append(all, g)
}
return all, nil
@@ -33,6 +33,6 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error {
return r.Error
}
r.init()
r.data[g.ID] = *g
r.Data[g.ID] = *g
return nil
}

View File

@@ -7,14 +7,14 @@ import (
type MockLibraryRepo struct {
model.LibraryRepository
data map[int]model.Library
Data map[int]model.Library
Err error
}
func (m *MockLibraryRepo) SetData(data model.Libraries) {
m.data = make(map[int]model.Library)
m.Data = make(map[int]model.Library)
for _, d := range data {
m.data[d.ID] = d
m.Data[d.ID] = d
}
}
@@ -22,14 +22,14 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error)
if m.Err != nil {
return nil, m.Err
}
return maps.Values(m.data), nil
return maps.Values(m.Data), nil
}
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
if m.Err != nil {
return "", m.Err
}
if lib, ok := m.data[id]; ok {
if lib, ok := m.Data[id]; ok {
return lib.Path, nil
}
return "", model.ErrNotFound

View File

@@ -14,40 +14,40 @@ import (
func CreateMockMediaFileRepo() *MockMediaFileRepo {
return &MockMediaFileRepo{
data: make(map[string]*model.MediaFile),
Data: make(map[string]*model.MediaFile),
}
}
type MockMediaFileRepo struct {
model.MediaFileRepository
data map[string]*model.MediaFile
err bool
Data map[string]*model.MediaFile
Err bool
}
func (m *MockMediaFileRepo) SetError(err bool) {
m.err = err
m.Err = err
}
func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) {
m.data = make(map[string]*model.MediaFile)
m.Data = make(map[string]*model.MediaFile)
for i, mf := range mfs {
m.data[mf.ID] = &mfs[i]
m.Data[mf.ID] = &mfs[i]
}
}
func (m *MockMediaFileRepo) Exists(id string) (bool, error) {
if m.err {
if m.Err {
return false, errors.New("error")
}
_, found := m.data[id]
_, found := m.Data[id]
return found, nil
}
func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
if m.err {
if m.Err {
return nil, errors.New("error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
// Intentionally clone the file and remove participants. This should
// catch any caller that actually means to call GetWithParticipants
res := *d
@@ -58,52 +58,52 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
}
func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) {
if m.err {
if m.Err {
return nil, errors.New("error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
}
func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
if m.err {
if m.Err {
return nil, errors.New("error")
}
values := slices.Collect(maps.Values(m.data))
values := slices.Collect(maps.Values(m.Data))
return slice.Map(values, func(p *model.MediaFile) model.MediaFile {
return *p
}), nil
}
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
if m.err {
if m.Err {
return errors.New("error")
}
if mf.ID == "" {
mf.ID = id.NewRandom()
}
m.data[mf.ID] = mf
m.Data[mf.ID] = mf
return nil
}
func (m *MockMediaFileRepo) Delete(id string) error {
if m.err {
if m.Err {
return errors.New("error")
}
if _, ok := m.data[id]; !ok {
if _, ok := m.Data[id]; !ok {
return model.ErrNotFound
}
delete(m.data, id)
delete(m.Data, id)
return nil
}
func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
if m.err {
if m.Err {
return errors.New("error")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
d.PlayCount++
d.PlayDate = &timestamp
return nil
@@ -112,12 +112,12 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
}
func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) {
if m.err {
if m.Err {
return nil, errors.New("error")
}
var res = make(model.MediaFiles, len(m.data))
var res = make(model.MediaFiles, len(m.Data))
i := 0
for _, a := range m.data {
for _, a := range m.Data {
if a.AlbumID == artistId {
res[i] = *a
i++
@@ -128,17 +128,17 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro
}
func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
if m.err {
if m.Err {
return nil, errors.New("error")
}
var res model.MediaFiles
for _, a := range m.data {
for _, a := range m.Data {
if a.LibraryID == libId && a.Missing {
res = append(res, *a)
}
}
for _, a := range m.data {
for _, a := range m.Data {
if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool {
return mediaFile.PID == a.PID
}) != -1 {

View File

@@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
type MockedPropertyRepo struct {
model.PropertyRepository
Error error
data map[string]string
Data map[string]string
}
func (p *MockedPropertyRepo) init() {
if p.data == nil {
p.data = make(map[string]string)
if p.Data == nil {
p.Data = make(map[string]string)
}
}
@@ -19,7 +19,7 @@ func (p *MockedPropertyRepo) Put(id string, value string) error {
return p.Error
}
p.init()
p.data[id] = value
p.Data[id] = value
return nil
}
@@ -28,7 +28,7 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) {
return "", p.Error
}
p.init()
if v, ok := p.data[id]; ok {
if v, ok := p.Data[id]; ok {
return v, nil
}
return "", model.ErrNotFound
@@ -39,8 +39,8 @@ func (p *MockedPropertyRepo) Delete(id string) error {
return p.Error
}
p.init()
if _, ok := p.data[id]; ok {
delete(p.data, id)
if _, ok := p.Data[id]; ok {
delete(p.Data, id)
return nil
}
return model.ErrNotFound

View File

@@ -9,9 +9,9 @@ import (
type MockedRadioRepo struct {
model.RadioRepository
data map[string]*model.Radio
all model.Radios
err bool
Data map[string]*model.Radio
All model.Radios
Err bool
Options model.QueryOptions
}
@@ -20,44 +20,44 @@ func CreateMockedRadioRepo() *MockedRadioRepo {
}
func (m *MockedRadioRepo) SetError(err bool) {
m.err = err
m.Err = err
}
func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) {
if m.err {
if m.Err {
return 0, errors.New("error")
}
return int64(len(m.data)), nil
return int64(len(m.Data)), nil
}
func (m *MockedRadioRepo) Delete(id string) error {
if m.err {
if m.Err {
return errors.New("Error!")
}
_, found := m.data[id]
_, found := m.Data[id]
if !found {
return errors.New("not found")
}
delete(m.data, id)
delete(m.Data, id)
return nil
}
func (m *MockedRadioRepo) Exists(id string) (bool, error) {
if m.err {
if m.Err {
return false, errors.New("Error!")
}
_, found := m.data[id]
_, found := m.Data[id]
return found, nil
}
func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) {
if m.err {
if m.Err {
return nil, errors.New("Error!")
}
if d, ok := m.data[id]; ok {
if d, ok := m.Data[id]; ok {
return d, nil
}
return nil, model.ErrNotFound
@@ -67,19 +67,19 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
if len(qo) > 0 {
m.Options = qo[0]
}
if m.err {
if m.Err {
return nil, errors.New("Error!")
}
return m.all, nil
return m.All, nil
}
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
if m.err {
if m.Err {
return errors.New("error")
}
if radio.ID == "" {
radio.ID = id.NewRandom()
}
m.data[radio.ID] = radio
m.Data[radio.ID] = radio
return nil
}

View File

@@ -8,7 +8,7 @@ import (
type MockedScrobbleBufferRepo struct {
Error error
data model.ScrobbleEntries
Data model.ScrobbleEntries
}
func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo {
@@ -20,7 +20,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) {
return nil, m.Error
}
userIds := make(map[string]struct{})
for _, e := range m.data {
for _, e := range m.Data {
if e.Service == service {
userIds[e.UserID] = struct{}{}
}
@@ -36,7 +36,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string,
if m.Error != nil {
return m.Error
}
m.data = append(m.data, model.ScrobbleEntry{
m.Data = append(m.Data, model.ScrobbleEntry{
MediaFile: model.MediaFile{ID: mediaFileId},
Service: service,
UserID: userId,
@@ -50,7 +50,7 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble
if m.Error != nil {
return nil, m.Error
}
for _, e := range m.data {
for _, e := range m.Data {
if e.Service == service && e.UserID == userId {
return &e, nil
}
@@ -63,13 +63,13 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error {
return m.Error
}
newData := model.ScrobbleEntries{}
for _, e := range m.data {
for _, e := range m.Data {
if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID {
continue
}
newData = append(newData, e)
}
m.data = newData
m.Data = newData
return nil
}
@@ -77,5 +77,5 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) {
if m.Error != nil {
return 0, m.Error
}
return int64(len(m.data)), nil
return int64(len(m.Data)), nil
}

View File

@@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
type MockedUserPropsRepo struct {
model.UserPropsRepository
Error error
data map[string]string
Data map[string]string
}
func (p *MockedUserPropsRepo) init() {
if p.data == nil {
p.data = make(map[string]string)
if p.Data == nil {
p.Data = make(map[string]string)
}
}
@@ -19,7 +19,7 @@ func (p *MockedUserPropsRepo) Put(userId, key string, value string) error {
return p.Error
}
p.init()
p.data[userId+key] = value
p.Data[userId+key] = value
return nil
}
@@ -28,7 +28,7 @@ func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) {
return "", p.Error
}
p.init()
if v, ok := p.data[userId+key]; ok {
if v, ok := p.Data[userId+key]; ok {
return v, nil
}
return "", model.ErrNotFound
@@ -39,8 +39,8 @@ func (p *MockedUserPropsRepo) Delete(userId, key string) error {
return p.Error
}
p.init()
if _, ok := p.data[userId+key]; ok {
delete(p.data, userId+key)
if _, ok := p.Data[userId+key]; ok {
delete(p.Data, userId+key)
return nil
}
return model.ErrNotFound

View File

@@ -124,6 +124,14 @@ const AlbumSongs = (props) => {
size: isDesktop && <SizeField source="size" sortable={false} />,
channels: isDesktop && <NumberField source="channels" sortable={false} />,
bpm: isDesktop && <NumberField source="bpm" sortable={false} />,
genre: <TextField source="genre" sortable={false} />,
mood: isDesktop && (
<FunctionField
source="mood"
render={(r) => r.tags?.mood?.[0] ?? ''}
sortable={false}
/>
),
rating: isDesktop && config.enableStarRating && (
<RatingField
resource={'song'}
@@ -139,7 +147,16 @@ const AlbumSongs = (props) => {
resource: 'albumSong',
columns: toggleableFields,
omittedColumns: ['title'],
defaultOff: ['channels', 'bpm', 'year', 'playCount', 'playDate', 'size'],
defaultOff: [
'channels',
'bpm',
'year',
'playCount',
'playDate',
'size',
'mood',
'genre',
],
})
const bulkActionsLabel = isDesktop