mirror of
https://github.com/navidrome/navidrome.git
synced 2025-05-07 13:51:10 +03:00
Merge branch 'master' into dlna-spike
This commit is contained in:
commit
58c8399e73
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@ -71,7 +71,7 @@ jobs:
|
|||||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v6
|
uses: golangci/golangci-lint-action@v7
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
problem-matchers: true
|
problem-matchers: true
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
version: "2"
|
||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
@ -11,42 +11,48 @@ linters:
|
|||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errcheck
|
|
||||||
- errorlint
|
- errorlint
|
||||||
- gocyclo
|
|
||||||
- gocritic
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
- gosec
|
- gosec
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
- misspell
|
||||||
- nakedret
|
- nakedret
|
||||||
- nilerr
|
- nilerr
|
||||||
- rowserrcheck
|
- rowserrcheck
|
||||||
- staticcheck
|
|
||||||
- typecheck
|
|
||||||
- unconvert
|
- unconvert
|
||||||
- unused
|
|
||||||
- whitespace
|
- whitespace
|
||||||
|
disable:
|
||||||
issues:
|
- staticcheck
|
||||||
exclude-rules:
|
settings:
|
||||||
- path: scanner2
|
gocritic:
|
||||||
linters:
|
disable-all: true
|
||||||
- unused
|
enabled-checks:
|
||||||
|
- deprecatedComment
|
||||||
linters-settings:
|
gosec:
|
||||||
gocritic:
|
excludes:
|
||||||
disable-all: true
|
- G501
|
||||||
enabled-checks:
|
- G401
|
||||||
- deprecatedComment
|
- G505
|
||||||
govet:
|
- G115
|
||||||
enable:
|
govet:
|
||||||
- nilness
|
enable:
|
||||||
gosec:
|
- nilness
|
||||||
excludes:
|
exclusions:
|
||||||
- G501
|
generated: lax
|
||||||
- G401
|
presets:
|
||||||
- G505
|
- comments
|
||||||
- G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
formatters:
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
2
Makefile
2
Makefile
@ -49,7 +49,7 @@ testall: testrace ##@Development Run Go and JS tests
|
|||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
lint: ##@Development Lint Go code
|
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
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
|
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/core/agents/lastfm"
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
@ -82,8 +83,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.GetAgents(dataStore)
|
agentsAgents := agents.GetAgents(dataStore)
|
||||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := core.GetTranscodingCache()
|
transcodingCache := core.GetTranscodingCache()
|
||||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
@ -96,7 +97,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||||||
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
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
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,8 +107,8 @@ func CreatePublicRouter() *public.Router {
|
|||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.GetAgents(dataStore)
|
agentsAgents := agents.GetAgents(dataStore)
|
||||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := core.GetTranscodingCache()
|
transcodingCache := core.GetTranscodingCache()
|
||||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
@ -150,8 +151,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
|
|||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.GetAgents(dataStore)
|
agentsAgents := agents.GetAgents(dataStore)
|
||||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
@ -166,8 +167,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
agentsAgents := agents.GetAgents(dataStore)
|
agentsAgents := agents.GetAgents(dataStore)
|
||||||
externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
playlists := core.NewPlaylists(dataStore)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"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/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"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)
|
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 {
|
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
|
||||||
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em}
|
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
|
||||||
}
|
}
|
||||||
|
|
||||||
type artwork struct {
|
type artwork struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
cache cache.FileCache
|
cache cache.FileCache
|
||||||
ffmpeg ffmpeg.FFmpeg
|
ffmpeg ffmpeg.FFmpeg
|
||||||
em core.ExternalMetadata
|
provider external.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
type artworkReader interface {
|
type artworkReader interface {
|
||||||
@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s
|
|||||||
} else {
|
} else {
|
||||||
switch artID.Kind {
|
switch artID.Kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
artReader, err = newArtistReader(ctx, a, artID, a.em)
|
artReader, err = newArtistReader(ctx, a, artID, a.provider)
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em)
|
artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
artReader, err = newMediafileArtworkReader(ctx, a, artID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
|
@ -6,12 +6,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
)
|
)
|
||||||
@ -19,14 +21,14 @@ import (
|
|||||||
type albumArtworkReader struct {
|
type albumArtworkReader struct {
|
||||||
cacheKey
|
cacheKey
|
||||||
a *artwork
|
a *artwork
|
||||||
em core.ExternalMetadata
|
provider external.Provider
|
||||||
album model.Album
|
album model.Album
|
||||||
updatedAt *time.Time
|
updatedAt *time.Time
|
||||||
imgFiles []string
|
imgFiles []string
|
||||||
rootFolder 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)
|
al, err := artwork.ds.Album(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -37,7 +39,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar
|
|||||||
}
|
}
|
||||||
a := &albumArtworkReader{
|
a := &albumArtworkReader{
|
||||||
a: artwork,
|
a: artwork,
|
||||||
em: em,
|
provider: provider,
|
||||||
album: *al,
|
album: *al,
|
||||||
updatedAt: imagesUpdateAt,
|
updatedAt: imagesUpdateAt,
|
||||||
imgFiles: imgFiles,
|
imgFiles: imgFiles,
|
||||||
@ -82,7 +84,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff
|
|||||||
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath)
|
||||||
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath))
|
||||||
case pattern == "external":
|
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:
|
case len(a.imgFiles) > 0:
|
||||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern))
|
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))
|
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
|
return paths, imgFiles, &updatedAt, nil
|
||||||
}
|
}
|
||||||
|
76
core/artwork/reader_album_test.go
Normal file
76
core/artwork/reader_album_test.go
Normal 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")))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
@ -22,13 +23,13 @@ import (
|
|||||||
type artistReader struct {
|
type artistReader struct {
|
||||||
cacheKey
|
cacheKey
|
||||||
a *artwork
|
a *artwork
|
||||||
em core.ExternalMetadata
|
provider external.Provider
|
||||||
artist model.Artist
|
artist model.Artist
|
||||||
artistFolder string
|
artistFolder string
|
||||||
imgFiles []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)
|
ar, err := artwork.ds.Artist(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI
|
|||||||
}
|
}
|
||||||
a := &artistReader{
|
a := &artistReader{
|
||||||
a: artwork,
|
a: artwork,
|
||||||
em: em,
|
provider: provider,
|
||||||
artist: *ar,
|
artist: *ar,
|
||||||
artistFolder: artistFolder,
|
artistFolder: artistFolder,
|
||||||
imgFiles: imgFiles,
|
imgFiles: imgFiles,
|
||||||
@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
|||||||
pattern = strings.TrimSpace(pattern)
|
pattern = strings.TrimSpace(pattern)
|
||||||
switch {
|
switch {
|
||||||
case pattern == "external":
|
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/"):
|
case strings.HasPrefix(pattern, "album/"):
|
||||||
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/")))
|
||||||
default:
|
default:
|
||||||
|
@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/dhowden/tag"
|
"github.com/dhowden/tag"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"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/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
@ -157,9 +157,9 @@ func fromAlbumPlaceholder() sourceFunc {
|
|||||||
return r, consts.PlaceholderAlbumArt, nil
|
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) {
|
return func() (io.ReadCloser, string, error) {
|
||||||
imageUrl, err := em.ArtistImage(ctx, ar.ID)
|
imageUrl, err := provider.ArtistImage(ctx, ar.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
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) {
|
return func() (io.ReadCloser, string, error) {
|
||||||
imageUrl, err := em.AlbumImage(ctx, al.ID)
|
imageUrl, err := provider.AlbumImage(ctx, al.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
270
core/external/extdata_helper_test.go
vendored
Normal file
270
core/external/extdata_helper_test.go
vendored
Normal 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
17
core/external/extdata_suite_test.go
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExternal(t *testing.T) {
|
||||||
|
tests.Init(t, false)
|
||||||
|
log.SetLevel(log.LevelFatal)
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "External Suite")
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package core
|
package external
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -31,7 +31,7 @@ const (
|
|||||||
refreshQueueLength = 2000
|
refreshQueueLength = 2000
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExternalMetadata interface {
|
type Provider interface {
|
||||||
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
|
||||||
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, 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)
|
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)
|
AlbumImage(ctx context.Context, id string) (*url.URL, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type externalMetadata struct {
|
type provider struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
ag *agents.Agents
|
ag Agents
|
||||||
artistQueue refreshQueue[auxArtist]
|
artistQueue refreshQueue[auxArtist]
|
||||||
albumQueue refreshQueue[auxAlbum]
|
albumQueue refreshQueue[auxAlbum]
|
||||||
}
|
}
|
||||||
@ -57,14 +57,24 @@ type auxArtist struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata {
|
type Agents interface {
|
||||||
e := &externalMetadata{ds: ds, ag: agents}
|
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.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo)
|
||||||
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo)
|
||||||
return e
|
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{}
|
var entity interface{}
|
||||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -81,10 +91,11 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, e
|
|||||||
default:
|
default:
|
||||||
return auxAlbum{}, model.ErrNotFound
|
return auxAlbum{}, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return album, nil
|
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)
|
album, err := e.getAlbum(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Info(ctx, "Not found", "id", id)
|
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
|
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()
|
start := time.Now()
|
||||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
||||||
if errors.Is(err, agents.ErrNotFound) {
|
if errors.Is(err, agents.ErrNotFound) {
|
||||||
@ -155,7 +166,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum
|
|||||||
return album, nil
|
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{}
|
var entity interface{}
|
||||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -177,7 +188,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist,
|
|||||||
return artist, nil
|
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)
|
artist, err := e.refreshArtistInfo(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -187,7 +198,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi
|
|||||||
return &artist.Artist, err
|
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)
|
artist, err := e.getArtist(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auxArtist{}, err
|
return auxArtist{}, err
|
||||||
@ -211,7 +222,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (au
|
|||||||
return artist, nil
|
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()
|
start := time.Now()
|
||||||
// Get MBID first, if it is not yet available
|
// Get MBID first, if it is not yet available
|
||||||
if artist.MbzArtistID == "" {
|
if artist.MbzArtistID == "" {
|
||||||
@ -246,7 +257,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt
|
|||||||
return artist, nil
|
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)
|
artist, err := e.getArtist(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -304,7 +315,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in
|
|||||||
return similarSongs, nil
|
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)
|
artist, err := e.getArtist(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -318,24 +329,35 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL
|
|||||||
|
|
||||||
imageUrl := artist.ArtistImageUrl()
|
imageUrl := artist.ArtistImageUrl()
|
||||||
if imageUrl == "" {
|
if imageUrl == "" {
|
||||||
return nil, agents.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
return url.Parse(imageUrl)
|
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)
|
album, err := e.getAlbum(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if utils.IsCtxDone(ctx) {
|
|
||||||
log.Warn(ctx, "AlbumImage call canceled", ctx.Err())
|
if info == nil {
|
||||||
return nil, ctx.Err()
|
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
|
// Return the biggest image
|
||||||
@ -346,26 +368,37 @@ func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if img.URL == "" {
|
if img.URL == "" {
|
||||||
return nil, agents.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
return url.Parse(img.URL)
|
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)
|
artist, err := e.findArtistByName(ctx, artistName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Artist not found", "name", artistName, err)
|
log.Error(ctx, "Artist not found", "name", artistName, err)
|
||||||
return nil, nil
|
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)
|
songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
|
||||||
if errors.Is(err, agents.ErrNotFound) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -386,10 +419,11 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents
|
|||||||
} else {
|
} else {
|
||||||
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
|
||||||
}
|
}
|
||||||
|
|
||||||
return mfs, nil
|
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 != "" {
|
if mbid != "" {
|
||||||
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
@ -420,7 +454,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a
|
|||||||
return &mfs[0], nil
|
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)
|
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@ -428,7 +462,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR
|
|||||||
artist.ExternalUrl = artisURL
|
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)
|
bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@ -438,7 +472,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar
|
|||||||
artist.Biography = strings.ReplaceAll(bio, "<a ", "<a target='_blank' ")
|
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)
|
images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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) {
|
limit int, includeNotPresent bool) {
|
||||||
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
|
||||||
if len(similar) == 0 || err != nil {
|
if len(similar) == 0 || err != nil {
|
||||||
@ -471,7 +505,7 @@ func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.Arti
|
|||||||
artist.SimilarArtists = sa
|
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 result model.Artists
|
||||||
var notPresent []string
|
var notPresent []string
|
||||||
|
|
||||||
@ -515,7 +549,7 @@ func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agen
|
|||||||
return result, nil
|
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{
|
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Like{"artist.name": artistName},
|
Filters: squirrel.Like{"artist.name": artistName},
|
||||||
Max: 1,
|
Max: 1,
|
||||||
@ -533,7 +567,7 @@ func (e *externalMetadata) findArtistByName(ctx context.Context, artistName stri
|
|||||||
return artist, nil
|
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
|
var ids []string
|
||||||
for _, sa := range artist.SimilarArtists {
|
for _, sa := range artist.SimilarArtists {
|
||||||
if sa.ID == "" {
|
if sa.ID == "" {
|
303
core/external/provider_albumimage_test.go
vendored
Normal file
303
core/external/provider_albumimage_test.go
vendored
Normal 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)
|
301
core/external/provider_artistimage_test.go
vendored
Normal file
301
core/external/provider_artistimage_test.go
vendored
Normal 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)
|
198
core/external/provider_similarsongs_test.go
vendored
Normal file
198
core/external/provider_similarsongs_test.go
vendored
Normal 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
193
core/external/provider_topsongs_test.go
vendored
Normal 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())
|
||||||
|
})
|
||||||
|
})
|
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal file
170
core/external/provider_updatealbuminfo_test.go
vendored
Normal 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())
|
||||||
|
})
|
||||||
|
})
|
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
229
core/external/provider_updateartistinfo_test.go
vendored
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
package external_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log.SetLevel(log.LevelDebug)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||||
|
var (
|
||||||
|
ctx context.Context
|
||||||
|
p external.Provider
|
||||||
|
ds *tests.MockDataStore
|
||||||
|
ag *mockAgents
|
||||||
|
mockArtistRepo *tests.MockArtistRepo
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
ds = new(tests.MockDataStore)
|
||||||
|
ag = new(mockAgents)
|
||||||
|
p = external.NewProvider(ds, ag)
|
||||||
|
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error when artist is not found", func() {
|
||||||
|
artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false)
|
||||||
|
|
||||||
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
Expect(artist).To(BeNil())
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("populates info when artist exists but has no external info", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-existing",
|
||||||
|
Name: "Test Artist",
|
||||||
|
}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||||
|
|
||||||
|
expectedMBID := "mbid-artist-123"
|
||||||
|
expectedBio := "Artist Bio"
|
||||||
|
expectedURL := "http://artist.url"
|
||||||
|
expectedImages := []agents.ExternalImage{
|
||||||
|
{URL: "http://large.jpg", Size: 300},
|
||||||
|
{URL: "http://medium.jpg", Size: 200},
|
||||||
|
{URL: "http://small.jpg", Size: 100},
|
||||||
|
}
|
||||||
|
rawSimilar := []agents.Artist{
|
||||||
|
{Name: "Similar Artist 1", MBID: "mbid-similar-1"},
|
||||||
|
{Name: "Similar Artist 2", MBID: "mbid-similar-2"},
|
||||||
|
{Name: "Similar Artist 3", MBID: "mbid-similar-3"},
|
||||||
|
}
|
||||||
|
similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"}
|
||||||
|
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once()
|
||||||
|
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
Expect(updatedArtist.ID).To(Equal("ar-existing"))
|
||||||
|
Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID))
|
||||||
|
Expect(updatedArtist.Biography).To(Equal("Artist Bio"))
|
||||||
|
Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL))
|
||||||
|
Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg"))
|
||||||
|
Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg"))
|
||||||
|
Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg"))
|
||||||
|
Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil())
|
||||||
|
Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second))
|
||||||
|
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2"))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2"))
|
||||||
|
|
||||||
|
ag.AssertExpectations(GinkgoT())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns cached info when artist exists and info is not expired", func() {
|
||||||
|
now := time.Now()
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-cached",
|
||||||
|
Name: "Cached Artist",
|
||||||
|
MbzArtistID: "mbid-cached",
|
||||||
|
ExternalUrl: "http://cached.url",
|
||||||
|
Biography: "Cached Bio",
|
||||||
|
LargeImageUrl: "http://cached_large.jpg",
|
||||||
|
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||||
|
SimilarArtists: model.Artists{
|
||||||
|
{ID: "ar-similar-present", Name: "Similar Present"},
|
||||||
|
{ID: "ar-similar-absent", Name: "Similar Absent"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||||
|
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||||
|
Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID))
|
||||||
|
Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl))
|
||||||
|
Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography))
|
||||||
|
Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl))
|
||||||
|
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||||
|
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||||
|
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns cached info and triggers background refresh when info is expired", func() {
|
||||||
|
now := time.Now()
|
||||||
|
expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-expired",
|
||||||
|
Name: "Expired Artist",
|
||||||
|
ExternalInfoUpdatedAt: gg.P(expiredTime),
|
||||||
|
SimilarArtists: model.Artists{
|
||||||
|
{ID: "ar-exp-similar", Name: "Expired Similar"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
Expect(updatedArtist.ID).To(Equal(originalArtist.ID))
|
||||||
|
Expect(updatedArtist.Name).To(Equal(originalArtist.Name))
|
||||||
|
Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt))
|
||||||
|
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(1))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||||
|
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistMBID")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistImages")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistBiography")
|
||||||
|
ag.AssertNotCalled(GinkgoT(), "GetArtistURL")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("includes non-present similar artists when includeNotPresent is true", func() {
|
||||||
|
now := time.Now()
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-similar-test",
|
||||||
|
Name: "Similar Test Artist",
|
||||||
|
ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
|
||||||
|
SimilarArtists: model.Artists{
|
||||||
|
{ID: "ar-sim-present", Name: "Similar Present"},
|
||||||
|
{ID: "", Name: "Similar Absent Raw"},
|
||||||
|
{ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS})
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
|
||||||
|
Expect(updatedArtist.SimilarArtists).To(HaveLen(3))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID))
|
||||||
|
Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name))
|
||||||
|
Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty())
|
||||||
|
Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw"))
|
||||||
|
Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty())
|
||||||
|
Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("updates ArtistInfo even if an optional agent call fails", func() {
|
||||||
|
originalArtist := &model.Artist{
|
||||||
|
ID: "ar-agent-fail",
|
||||||
|
Name: "Agent Fail Artist",
|
||||||
|
}
|
||||||
|
mockArtistRepo.SetData(model.Artists{*originalArtist})
|
||||||
|
|
||||||
|
expectedErr := errors.New("agent MBID failed")
|
||||||
|
ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once()
|
||||||
|
ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe()
|
||||||
|
ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe()
|
||||||
|
ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe()
|
||||||
|
|
||||||
|
updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false)
|
||||||
|
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(updatedArtist).NotTo(BeNil())
|
||||||
|
Expect(updatedArtist.ID).To(Equal("ar-agent-fail"))
|
||||||
|
ag.AssertExpectations(GinkgoT())
|
||||||
|
})
|
||||||
|
})
|
@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
@ -13,11 +14,12 @@ var Set = wire.NewSet(
|
|||||||
NewMediaStreamer,
|
NewMediaStreamer,
|
||||||
GetTranscodingCache,
|
GetTranscodingCache,
|
||||||
NewArchiver,
|
NewArchiver,
|
||||||
NewExternalMetadata,
|
|
||||||
NewPlayers,
|
NewPlayers,
|
||||||
NewShare,
|
NewShare,
|
||||||
NewPlaylists,
|
NewPlaylists,
|
||||||
agents.GetAgents,
|
agents.GetAgents,
|
||||||
|
external.NewProvider,
|
||||||
|
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||||
ffmpeg.New,
|
ffmpeg.New,
|
||||||
scrobbler.GetPlayTracker,
|
scrobbler.GetPlayTracker,
|
||||||
playback.GetInstance,
|
playback.GetInstance,
|
||||||
|
47
go.mod
47
go.mod
@ -1,6 +1,6 @@
|
|||||||
module github.com/navidrome/navidrome
|
module github.com/navidrome/navidrome
|
||||||
|
|
||||||
go 1.24.1
|
go 1.24.2
|
||||||
|
|
||||||
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
|
// 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
|
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||||
@ -25,8 +25,8 @@ require (
|
|||||||
github.com/fatih/structs v1.1.0
|
github.com/fatih/structs v1.1.0
|
||||||
github.com/go-chi/chi/v5 v5.2.1
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/go-chi/httprate v0.14.1
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.2
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
github.com/go-viper/encoding/ini v0.1.1
|
github.com/go-viper/encoding/ini v0.1.1
|
||||||
github.com/gohugoio/hashstructure v0.5.0
|
github.com/gohugoio/hashstructure v0.5.0
|
||||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc
|
||||||
@ -38,32 +38,32 @@ require (
|
|||||||
github.com/kr/pretty v0.3.1
|
github.com/kr/pretty v0.3.1
|
||||||
github.com/lestrrat-go/jwx/v2 v2.1.4
|
github.com/lestrrat-go/jwx/v2 v2.1.4
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
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/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/onsi/ginkgo/v2 v2.23.0
|
github.com/onsi/ginkgo/v2 v2.23.4
|
||||||
github.com/onsi/gomega v1.36.2
|
github.com/onsi/gomega v1.37.0
|
||||||
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e
|
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/pocketbase/dbx v1.11.0
|
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/prometheus/client_golang v1.21.1
|
||||||
github.com/rjeczalik/notify v0.9.3
|
github.com/rjeczalik/notify v0.9.3
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/spf13/cobra v1.9.1
|
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/stretchr/testify v1.10.0
|
||||||
github.com/unrolled/secure v1.17.0
|
github.com/unrolled/secure v1.17.0
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1
|
||||||
go.uber.org/goleak v1.3.0
|
go.uber.org/goleak v1.3.0
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
|
||||||
golang.org/x/image v0.25.0
|
golang.org/x/image v0.26.0
|
||||||
golang.org/x/net v0.37.0
|
golang.org/x/net v0.38.0
|
||||||
golang.org/x/sync v0.12.0
|
golang.org/x/sync v0.13.0
|
||||||
golang.org/x/sys v0.31.0
|
golang.org/x/sys v0.32.0
|
||||||
golang.org/x/text v0.23.0
|
golang.org/x/text v0.24.0
|
||||||
golang.org/x/time v0.11.0
|
golang.org/x/time v0.11.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@ -78,19 +78,20 @@ require (
|
|||||||
github.com/creack/pty v1.1.11 // indirect
|
github.com/creack/pty v1.1.11 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/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-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // 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/google/subcommands v1.2.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap 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/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/kr/text v0.2.0 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
|
||||||
@ -105,22 +106,24 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
github.com/prometheus/common v0.62.0 // 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/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/segmentio/asm v1.2.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/sourcegraph/conc 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/cast v1.7.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // 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
|
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/mod v0.24.0 // indirect
|
||||||
golang.org/x/tools v0.31.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/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
|
||||||
)
|
)
|
||||||
|
122
go.sum
122
go.sum
@ -59,21 +59,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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
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 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
||||||
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
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 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
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.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0=
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs=
|
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
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 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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.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.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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=
|
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
|
||||||
@ -91,8 +91,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-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 h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
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 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@ -108,8 +108,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/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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc=
|
||||||
@ -120,8 +118,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/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 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
@ -152,8 +152,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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
@ -166,30 +166,32 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
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.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
|
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY=
|
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e h1:cL0lMYYEbfEUBghQd4ytnl8B8Ktdm+JremTyAagegZ0=
|
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e h1:cL0lMYYEbfEUBghQd4ytnl8B8Ktdm+JremTyAagegZ0=
|
||||||
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e/go.mod h1:tUOeYZJlwO7jSmM5ko1jTCiQaWQMvh58IENEfjwYzh8=
|
github.com/oriser/regroup v0.0.0-20240925165441-f6bb0e08289e/go.mod h1:tUOeYZJlwO7jSmM5ko1jTCiQaWQMvh58IENEfjwYzh8=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
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/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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
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/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||||
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
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 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
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 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
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 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
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.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY=
|
||||||
@ -202,8 +204,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/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 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
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.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
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 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||||
@ -217,16 +219,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/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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
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.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
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 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
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 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
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.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.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
@ -245,6 +247,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 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
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/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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@ -256,13 +264,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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
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 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/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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@ -283,8 +291,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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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.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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -292,8 +300,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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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-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-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@ -312,8 +320,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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@ -334,8 +342,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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
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 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@ -350,8 +358,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/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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/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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@ -363,17 +371,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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||||
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=
|
|
||||||
|
@ -183,6 +183,8 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||||||
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs))
|
||||||
|
|
||||||
a.Missing = true
|
a.Missing = true
|
||||||
|
embedArtPath := ""
|
||||||
|
embedArtDisc := 0
|
||||||
for _, m := range mfs {
|
for _, m := range mfs {
|
||||||
// We assume these attributes are all the same for all songs in an album
|
// We assume these attributes are all the same for all songs in an album
|
||||||
a.ID = m.AlbumID
|
a.ID = m.AlbumID
|
||||||
@ -211,15 +213,15 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||||||
comments = append(comments, m.Comment)
|
comments = append(comments, m.Comment)
|
||||||
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID)
|
||||||
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID)
|
||||||
if m.HasCoverArt && a.EmbedArtPath == "" {
|
|
||||||
a.EmbedArtPath = m.Path
|
|
||||||
}
|
|
||||||
if m.DiscNumber > 0 {
|
if m.DiscNumber > 0 {
|
||||||
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
|
a.Discs.Add(m.DiscNumber, m.DiscSubtitle)
|
||||||
}
|
}
|
||||||
tags = append(tags, m.Tags.FlattenAll()...)
|
tags = append(tags, m.Tags.FlattenAll()...)
|
||||||
a.Participants.Merge(m.Participants)
|
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" {
|
if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" {
|
||||||
a.ExplicitStatus = "c"
|
a.ExplicitStatus = "c"
|
||||||
} else if m.ExplicitStatus == "e" {
|
} else if m.ExplicitStatus == "e" {
|
||||||
@ -231,6 +233,7 @@ func (mfs MediaFiles) ToAlbum() Album {
|
|||||||
a.Missing = a.Missing && m.Missing
|
a.Missing = a.Missing && m.Missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.EmbedArtPath = embedArtPath
|
||||||
a.SetTags(tags)
|
a.SetTags(tags)
|
||||||
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID }))
|
||||||
a.Date, _ = allOrNothing(dates)
|
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 MediaFileCursor iter.Seq2[MediaFile, error]
|
||||||
|
|
||||||
type MediaFileRepository interface {
|
type MediaFileRepository interface {
|
||||||
|
@ -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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -51,20 +51,6 @@ func legacyMapAlbumName(md Metadata) string {
|
|||||||
|
|
||||||
// Keep the TaggedLikePicard logic for backwards compatibility
|
// Keep the TaggedLikePicard logic for backwards compatibility
|
||||||
func legacyReleaseDate(md Metadata) string {
|
func legacyReleaseDate(md Metadata) string {
|
||||||
// Start with defaults
|
_, _, releaseDate := md.mapDates()
|
||||||
date := md.Date(model.TagRecordingDate)
|
|
||||||
year := date.Year()
|
|
||||||
originalDate := md.Date(model.TagOriginalDate)
|
|
||||||
originalYear := originalDate.Year()
|
|
||||||
releaseDate := md.Date(model.TagReleaseDate)
|
|
||||||
releaseYear := releaseDate.Year()
|
|
||||||
|
|
||||||
// MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty
|
|
||||||
taggedLikePicard := (originalYear != 0) &&
|
|
||||||
(releaseYear == 0) &&
|
|
||||||
(year >= originalYear)
|
|
||||||
if taggedLikePicard {
|
|
||||||
return string(date)
|
|
||||||
}
|
|
||||||
return string(releaseDate)
|
return string(releaseDate)
|
||||||
}
|
}
|
||||||
|
30
model/metadata/legacy_ids_test.go
Normal file
30
model/metadata/legacy_ids_test.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package metadata
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("legacyReleaseDate", func() {
|
||||||
|
|
||||||
|
DescribeTable("legacyReleaseDate",
|
||||||
|
func(recordingDate, originalDate, releaseDate, expected string) {
|
||||||
|
md := New("", Info{
|
||||||
|
Tags: map[string][]string{
|
||||||
|
"DATE": {recordingDate},
|
||||||
|
"ORIGINALDATE": {originalDate},
|
||||||
|
"RELEASEDATE": {releaseDate},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
result := legacyReleaseDate(md)
|
||||||
|
Expect(result).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||||
|
Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
|
||||||
|
Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||||
|
Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
|
||||||
|
Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
|
||||||
|
Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
|
||||||
|
)
|
||||||
|
})
|
@ -1,6 +1,7 @@
|
|||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
@ -39,11 +40,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
origDate := md.Date(model.TagOriginalDate)
|
date, origDate, relDate := md.mapDates()
|
||||||
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
|
mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate)
|
||||||
relDate := md.Date(model.TagReleaseDate)
|
|
||||||
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
|
mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate)
|
||||||
date := md.Date(model.TagRecordingDate)
|
|
||||||
mf.Year, mf.Date = date.Year(), string(date)
|
mf.Year, mf.Date = date.Year(), string(date)
|
||||||
|
|
||||||
// MBIDs
|
// MBIDs
|
||||||
@ -51,6 +50,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
|||||||
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID)
|
||||||
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID)
|
||||||
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID)
|
||||||
|
mf.MbzAlbumType = md.String(model.TagReleaseType)
|
||||||
|
|
||||||
// ReplayGain
|
// ReplayGain
|
||||||
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
|
mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1)
|
||||||
@ -164,3 +164,22 @@ func (md Metadata) mapExplicitStatusTag() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) {
|
||||||
|
// Start with defaults
|
||||||
|
date = md.Date(model.TagRecordingDate)
|
||||||
|
originalDate = md.Date(model.TagOriginalDate)
|
||||||
|
releaseDate = md.Date(model.TagReleaseDate)
|
||||||
|
|
||||||
|
// For some historic reason, taggers have been writing the Release Date of an album to the Date tag,
|
||||||
|
// and leave the Release Date tag empty.
|
||||||
|
legacyMappings := (originalDate != "") &&
|
||||||
|
(releaseDate == "") &&
|
||||||
|
(date >= originalDate)
|
||||||
|
if legacyMappings {
|
||||||
|
return originalDate, originalDate, date
|
||||||
|
}
|
||||||
|
// when there's no Date, first fall back to Original Date, then to Release Date.
|
||||||
|
date = cmp.Or(date, originalDate, releaseDate)
|
||||||
|
return date, originalDate, releaseDate
|
||||||
|
}
|
||||||
|
@ -35,7 +35,7 @@ var _ = Describe("ToMediaFile", func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Describe("Dates", func() {
|
Describe("Dates", func() {
|
||||||
It("should parse the dates like Picard", func() {
|
It("should parse properly tagged dates ", func() {
|
||||||
mf = toMediaFile(model.RawTags{
|
mf = toMediaFile(model.RawTags{
|
||||||
"ORIGINALDATE": {"1978-09-10"},
|
"ORIGINALDATE": {"1978-09-10"},
|
||||||
"DATE": {"1977-03-04"},
|
"DATE": {"1977-03-04"},
|
||||||
@ -49,6 +49,32 @@ var _ = Describe("ToMediaFile", func() {
|
|||||||
Expect(mf.ReleaseYear).To(Equal(2002))
|
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||||
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
|
Expect(mf.ReleaseDate).To(Equal("2002-01-02"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should parse dates with only year", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{
|
||||||
|
"ORIGINALYEAR": {"1978"},
|
||||||
|
"DATE": {"1977"},
|
||||||
|
"RELEASEDATE": {"2002"},
|
||||||
|
})
|
||||||
|
|
||||||
|
Expect(mf.Year).To(Equal(1977))
|
||||||
|
Expect(mf.Date).To(Equal("1977"))
|
||||||
|
Expect(mf.OriginalYear).To(Equal(1978))
|
||||||
|
Expect(mf.OriginalDate).To(Equal("1978"))
|
||||||
|
Expect(mf.ReleaseYear).To(Equal(2002))
|
||||||
|
Expect(mf.ReleaseDate).To(Equal("2002"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should parse dates tagged the legacy way (no release date)", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{
|
||||||
|
"DATE": {"2014"},
|
||||||
|
"ORIGINALDATE": {"1966"},
|
||||||
|
})
|
||||||
|
|
||||||
|
Expect(mf.Year).To(Equal(1966))
|
||||||
|
Expect(mf.OriginalYear).To(Equal(1966))
|
||||||
|
Expect(mf.ReleaseYear).To(Equal(2014))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Lyrics", func() {
|
Describe("Lyrics", func() {
|
||||||
|
@ -90,13 +90,14 @@ var _ = Describe("Metadata", func() {
|
|||||||
md = metadata.New(filePath, props)
|
md = metadata.New(filePath, props)
|
||||||
|
|
||||||
Expect(md.All()).To(SatisfyAll(
|
Expect(md.All()).To(SatisfyAll(
|
||||||
HaveLen(5),
|
|
||||||
Not(HaveKey(unknownTag)),
|
Not(HaveKey(unknownTag)),
|
||||||
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
||||||
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
||||||
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02", "2022"}),
|
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
|
||||||
|
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
|
||||||
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
||||||
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
||||||
|
HaveLen(6),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -97,9 +97,10 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
|||||||
r.tableName = "album"
|
r.tableName = "album"
|
||||||
r.registerModel(&model.Album{}, albumFilters())
|
r.registerModel(&model.Album{}, albumFilters())
|
||||||
r.setSortMappings(map[string]string{
|
r.setSortMappings(map[string]string{
|
||||||
"name": "order_album_name, order_album_artist_name",
|
"name": "order_album_name, order_album_artist_name",
|
||||||
"artist": "compilation, order_album_artist_name, order_album_name",
|
"artist": "compilation, order_album_artist_name, order_album_name",
|
||||||
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
"album_artist": "compilation, order_album_artist_name, order_album_name",
|
||||||
|
// TODO Rename this to just year (or date)
|
||||||
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
|
||||||
"random": "random",
|
"random": "random",
|
||||||
"recently_added": recentlyAddedSort(),
|
"recently_added": recentlyAddedSort(),
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 68 KiB |
File diff suppressed because it is too large
Load Diff
@ -1,460 +1,515 @@
|
|||||||
{
|
{
|
||||||
"languageName": "Esperanto",
|
"languageName": "Esperanto",
|
||||||
"resources": {
|
"resources": {
|
||||||
"song": {
|
"song": {
|
||||||
"name": "kanto |||| kantoj",
|
"name": "Kanto |||| Kantoj",
|
||||||
"fields": {
|
"fields": {
|
||||||
"albumArtist": "Albumo artista",
|
"albumArtist": "Artisto de Albumo",
|
||||||
"duration": "Tempo",
|
"duration": "Daŭro",
|
||||||
"trackNumber": "#",
|
"trackNumber": "#",
|
||||||
"playCount": "Nombro de ŝkotoj",
|
"playCount": "Ludoj",
|
||||||
"title": "Titolo",
|
"title": "Titolo",
|
||||||
"artist": "Artisto",
|
"artist": "Artisto",
|
||||||
"album": "Albumo",
|
"album": "Albumo",
|
||||||
"path": "Dosiera vojo",
|
"path": "Dosiera vojo",
|
||||||
"genre": "Ĝenro",
|
"genre": "Ĝenro",
|
||||||
"compilation": "Kompilaĵo",
|
"compilation": "Kompilaĵo",
|
||||||
"year": "Jaro",
|
"year": "Jaro",
|
||||||
"size": "Dosiera grandeco",
|
"size": "Dosiera grandeco",
|
||||||
"updatedAt": "Ĝisdatigita je",
|
"updatedAt": "Ĝisdatigita je",
|
||||||
"bitRate": "Bitrapido",
|
"bitRate": "Bitrapido",
|
||||||
"discSubtitle": "Diska Subteksto",
|
"discSubtitle": "Diska Subteksto",
|
||||||
"starred": "Stela",
|
"starred": "Stela",
|
||||||
"comment": "Komento",
|
"comment": "Komento",
|
||||||
"rating": "",
|
"rating": "Takso",
|
||||||
"quality": "",
|
"quality": "Kvalito",
|
||||||
"bpm": "",
|
"bpm": "Pulsrapideco",
|
||||||
"playDate": "",
|
"playDate": "",
|
||||||
"channels": "",
|
"channels": "",
|
||||||
"createdAt": ""
|
"createdAt": "",
|
||||||
},
|
"grouping": "",
|
||||||
"actions": {
|
"mood": "",
|
||||||
"addToQueue": "Ludi Poste",
|
"participants": "",
|
||||||
"playNow": "Ludi nun",
|
"tags": "",
|
||||||
"addToPlaylist": "Aldoni al Ludlisto",
|
"mappedTags": "",
|
||||||
"shuffleAll": "Miksu Ĉiujn",
|
"rawTags": "",
|
||||||
"download": "Elŝuti",
|
"bitDepth": ""
|
||||||
"playNext": "Ludu Poste",
|
},
|
||||||
"info": ""
|
"actions": {
|
||||||
}
|
"addToQueue": "Ludi Poste",
|
||||||
},
|
"playNow": "Ludi nun",
|
||||||
"album": {
|
"addToPlaylist": "Aldoni al Ludlisto",
|
||||||
"name": "Albumo |||| Albumoj",
|
"shuffleAll": "Miksu Ĉiujn",
|
||||||
"fields": {
|
"download": "Elŝuti",
|
||||||
"albumArtist": "Albumo artista",
|
"playNext": "Ludu Poste",
|
||||||
"artist": "Artisto",
|
"info": ""
|
||||||
"duration": "Tempo",
|
}
|
||||||
"songCount": "Kantoj",
|
|
||||||
"playCount": "Nombro de ŝkotoj",
|
|
||||||
"name": "Nomo",
|
|
||||||
"genre": "Genro",
|
|
||||||
"compilation": "Kompilaĵo",
|
|
||||||
"year": "Jaro",
|
|
||||||
"updatedAt": "Ĝisdatigita je :",
|
|
||||||
"comment": "Komento",
|
|
||||||
"rating": "",
|
|
||||||
"createdAt": "",
|
|
||||||
"size": "",
|
|
||||||
"originalDate": "",
|
|
||||||
"releaseDate": "",
|
|
||||||
"releases": "",
|
|
||||||
"released": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"playAll": "Ludi",
|
|
||||||
"playNext": "Ludi poste",
|
|
||||||
"addToQueue": "Aldoni la dosieron de atento",
|
|
||||||
"shuffle": "Miksi",
|
|
||||||
"addToPlaylist": "Aldoni al la Ludlisto",
|
|
||||||
"download": "Elŝuti",
|
|
||||||
"info": "",
|
|
||||||
"share": ""
|
|
||||||
},
|
|
||||||
"lists": {
|
|
||||||
"all": "Ĉiuj",
|
|
||||||
"random": "Hazarda",
|
|
||||||
"recentlyAdded": "Lastatempe Aldonita",
|
|
||||||
"recentlyPlayed": "Lastatempe Ludita",
|
|
||||||
"mostPlayed": "Plej Luditaj",
|
|
||||||
"starred": "Stelplena",
|
|
||||||
"topRated": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"artist": {
|
|
||||||
"name": "Artisto |||| Artistoj",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nomo",
|
|
||||||
"albumCount": "Nombro da albumoj",
|
|
||||||
"songCount": "Kanto kalkula",
|
|
||||||
"playCount": "Teatraĵoj",
|
|
||||||
"rating": "",
|
|
||||||
"genre": "",
|
|
||||||
"size": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"name": "Uzanto |||| Uzantoj",
|
|
||||||
"fields": {
|
|
||||||
"userName": "Uzantonomo",
|
|
||||||
"isAdmin": "Estas Administranto",
|
|
||||||
"lastLoginAt": "Lasta Ensaluto Je",
|
|
||||||
"updatedAt": "Ĝisdatigita je",
|
|
||||||
"name": "Nomo",
|
|
||||||
"password": "Pasvorto",
|
|
||||||
"createdAt": "Kreita je :",
|
|
||||||
"changePassword": "",
|
|
||||||
"currentPassword": "",
|
|
||||||
"newPassword": "",
|
|
||||||
"token": ""
|
|
||||||
},
|
|
||||||
"helperTexts": {
|
|
||||||
"name": ""
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"created": "",
|
|
||||||
"updated": "",
|
|
||||||
"deleted": ""
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"listenBrainzToken": "",
|
|
||||||
"clickHereForToken": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"player": {
|
|
||||||
"name": "Legilo |||| Legilj",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nomo",
|
|
||||||
"transcodingId": "Transkodigo",
|
|
||||||
"maxBitRate": "Maksimuma Bitrapido",
|
|
||||||
"client": "Kliento",
|
|
||||||
"userName": "Uzantonomo",
|
|
||||||
"lastSeen": "Laste Vidita Je",
|
|
||||||
"reportRealPath": "Raporti vera pado",
|
|
||||||
"scrobbleEnabled": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"transcoding": {
|
|
||||||
"name": "Transkodigo |||| Transkodigoj",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nomo",
|
|
||||||
"targetFormat": "Celformato",
|
|
||||||
"defaultBitRate": "Defaŭlta Bitrapido",
|
|
||||||
"command": "Komando"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"playlist": {
|
|
||||||
"name": "Ludlisto |||| Ludlistoj",
|
|
||||||
"fields": {
|
|
||||||
"name": "Nomo",
|
|
||||||
"duration": "Daŭro",
|
|
||||||
"ownerName": "Posedanto",
|
|
||||||
"public": "Publika",
|
|
||||||
"updatedAt": "Ĝisdatigita je",
|
|
||||||
"createdAt": "Kreita je",
|
|
||||||
"songCount": "Kantoj",
|
|
||||||
"comment": "Komento",
|
|
||||||
"sync": "Aŭtomata importado",
|
|
||||||
"path": "Importi de"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"selectPlaylist": "Elektu ludliston :",
|
|
||||||
"addNewPlaylist": "Krei \"%{name}\"",
|
|
||||||
"export": "Eksporti",
|
|
||||||
"makePublic": "",
|
|
||||||
"makePrivate": ""
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"duplicate_song": "",
|
|
||||||
"song_exist": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"radio": {
|
|
||||||
"name": "",
|
|
||||||
"fields": {
|
|
||||||
"name": "",
|
|
||||||
"streamUrl": "",
|
|
||||||
"homePageUrl": "",
|
|
||||||
"updatedAt": "",
|
|
||||||
"createdAt": ""
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"playNow": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"share": {
|
|
||||||
"name": "",
|
|
||||||
"fields": {
|
|
||||||
"username": "",
|
|
||||||
"url": "",
|
|
||||||
"description": "",
|
|
||||||
"contents": "",
|
|
||||||
"expiresAt": "",
|
|
||||||
"lastVisitedAt": "",
|
|
||||||
"visitCount": "",
|
|
||||||
"format": "",
|
|
||||||
"maxBitRate": "",
|
|
||||||
"updatedAt": "",
|
|
||||||
"createdAt": "",
|
|
||||||
"downloadable": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"ra": {
|
"album": {
|
||||||
"auth": {
|
"name": "Albumo |||| Albumoj",
|
||||||
"welcome1": "Dankon pro instalado de Navidrome !",
|
"fields": {
|
||||||
"welcome2": "Por komenci, kreu administrantan uzanton",
|
"albumArtist": "Artisto de Albumo",
|
||||||
"confirmPassword": "Konfirmu pasvorton",
|
"artist": "Artisto",
|
||||||
"buttonCreateAdmin": "Krei administranto",
|
"duration": "Tempo",
|
||||||
"auth_check_error": "Bonvolu ensaluti por daŭrigi",
|
"songCount": "Kantoj",
|
||||||
"user_menu": "Profilo",
|
"playCount": "Ludoj",
|
||||||
"username": "Uzantnomo",
|
"name": "Nomo",
|
||||||
"password": "Pasvorto",
|
"genre": "Ĝenro",
|
||||||
"sign_in": "Ensaluti",
|
"compilation": "Kompilaĵo",
|
||||||
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
"year": "Jaro",
|
||||||
"logout": "Elsaluti"
|
"updatedAt": "Ĝisdatigita je :",
|
||||||
},
|
"comment": "Komento",
|
||||||
"validation": {
|
"rating": "Takso",
|
||||||
"invalidChars": "Bonvolu uzi nur literon kaj ciferojn",
|
"createdAt": "",
|
||||||
"passwordDoesNotMatch": "Pasvorto ne kongruas",
|
"size": "",
|
||||||
"required": "Necesa",
|
"originalDate": "",
|
||||||
"minLength": "Devas esti almenaŭ %{min} signoj",
|
"releaseDate": "",
|
||||||
"maxLength": "Devas esti %{max} signoj aŭ malpli",
|
"releases": "",
|
||||||
"minValue": "Devas esti almenaŭ %{min}",
|
"released": "",
|
||||||
"maxValue": "Devas esti %{max} aŭ malpli",
|
"recordLabel": "",
|
||||||
"number": "Devas esti nombro",
|
"catalogNum": "",
|
||||||
"email": "Devas esti valida retpoŝto",
|
"releaseType": "",
|
||||||
"oneOf": "Devas esti unu el: %{options}",
|
"grouping": "",
|
||||||
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
"media": "",
|
||||||
"unique": "",
|
"mood": "",
|
||||||
"url": ""
|
"date": ""
|
||||||
},
|
},
|
||||||
"action": {
|
"actions": {
|
||||||
"add_filter": "Aldoni filtrilon",
|
"playAll": "Ludi",
|
||||||
"add": "Aldoni",
|
"playNext": "Ludi Sekvante",
|
||||||
"back": "Reiri",
|
"addToQueue": "Aldoni la dosieron de atento",
|
||||||
"bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj",
|
"shuffle": "Miksi",
|
||||||
"cancel": "Nuligi",
|
"addToPlaylist": "Aldoni al la Ludlisto",
|
||||||
"clear_input_value": "Viŝi valoro",
|
"download": "Elŝuti",
|
||||||
"clone": "Kloni",
|
"info": "",
|
||||||
"confirm": "Konfirmi",
|
"share": ""
|
||||||
"create": "Krei",
|
},
|
||||||
"delete": "Forstrekis",
|
"lists": {
|
||||||
"edit": "Modifi",
|
"all": "Ĉiuj",
|
||||||
"export": "Eksporti",
|
"random": "Hazarda",
|
||||||
"list": "Listigi",
|
"recentlyAdded": "Lastatempe Aldonita",
|
||||||
"refresh": "Aktualigi",
|
"recentlyPlayed": "Lastatempe Ludita",
|
||||||
"remove_filter": "Forigu ĉi tiun filtrilon",
|
"mostPlayed": "Plej Luditaj",
|
||||||
"remove": "Forigi",
|
"starred": "Stelplena",
|
||||||
"save": "Konservi",
|
"topRated": "Plej Alte Taksite"
|
||||||
"search": "Serĉi",
|
}
|
||||||
"show": "Montri",
|
|
||||||
"sort": "Ordigi",
|
|
||||||
"undo": "Malfari",
|
|
||||||
"expand": "Etendi",
|
|
||||||
"close": "Fermi",
|
|
||||||
"open_menu": "Malfermu menuon",
|
|
||||||
"close_menu": "Fermu menuon",
|
|
||||||
"unselect": "Malelekti",
|
|
||||||
"skip": "",
|
|
||||||
"bulk_actions_mobile": "",
|
|
||||||
"share": "",
|
|
||||||
"download": ""
|
|
||||||
},
|
|
||||||
"boolean": {
|
|
||||||
"true": "Jes",
|
|
||||||
"false": "Ne"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"create": "Krei %{name}",
|
|
||||||
"dashboard": "Panelo",
|
|
||||||
"edit": "%{name} #%{id}",
|
|
||||||
"error": "Io fuŝiĝis",
|
|
||||||
"list": "${name}",
|
|
||||||
"loading": "Ŝarĝante",
|
|
||||||
"not_found": "Ne trovita",
|
|
||||||
"show": "%{name} #%{id}",
|
|
||||||
"empty": "Ankoraŭ ne %{name}",
|
|
||||||
"invite": "Ĉu vi volas aldoni unu?"
|
|
||||||
},
|
|
||||||
"input": {
|
|
||||||
"file": {
|
|
||||||
"upload_several": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti unu.",
|
|
||||||
"upload_single": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti ĝin."
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"upload_several": "Faligu iujn bildojn por alŝuti, aŭ alklaku por elekti unu.",
|
|
||||||
"upload_single": "Faligu bildon por alŝuti, aŭ alklaku por elekti ĝin."
|
|
||||||
},
|
|
||||||
"references": {
|
|
||||||
"all_missing": "Ne eblas trovi referencajn datumojn.",
|
|
||||||
"many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.",
|
|
||||||
"single_missing": "Rilata referenco ne plu ŝajnas esti disponebla."
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"toggle_visible": "kaŝi pasvorto",
|
|
||||||
"toggle_hidden": "montri pasvorto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"about": "Pri",
|
|
||||||
"are_you_sure": "Ĉu vi certas ?",
|
|
||||||
"bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name} ? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn ?",
|
|
||||||
"bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}",
|
|
||||||
"delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{smart_count} eron ?",
|
|
||||||
"delete_title": "Forigi %{name} #%{id}",
|
|
||||||
"details": "Detaleto",
|
|
||||||
"error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.",
|
|
||||||
"invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.",
|
|
||||||
"loading": "La paĝo ŝarĝas, nur momenton bonvolu.",
|
|
||||||
"no": "Ne",
|
|
||||||
"not_found": "Aŭ vi tajpis malbonan URL, aŭ vi sekvis malbonan ligon.",
|
|
||||||
"yes": "Jes",
|
|
||||||
"unsaved_changes": "Luj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi estas certa, ke vi volas ignori ilin?"
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"no_results": "Neniu rezulto troviĝis",
|
|
||||||
"no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.",
|
|
||||||
"page_out_of_boundaries": "Paĝa numero %{page} ekster limoj",
|
|
||||||
"page_out_from_end": "Ne povas iri post la lasta paĝo",
|
|
||||||
"page_out_from_begin": "Ne povas iri antaŭ paĝo 1",
|
|
||||||
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
|
||||||
"page_rows_per_page": "Eroj por paĝo:",
|
|
||||||
"next": "Poste",
|
|
||||||
"prev": "Antaŭ",
|
|
||||||
"skip_nav": "Preterlasu al enhavo"
|
|
||||||
},
|
|
||||||
"notification": {
|
|
||||||
"updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj",
|
|
||||||
"created": "\nElemento kretia",
|
|
||||||
"deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj",
|
|
||||||
"bad_item": "Elemento malkorekta",
|
|
||||||
"item_doesnt_exist": "Elemento ne ekzistas",
|
|
||||||
"http_error": "Servila komunikada eraro",
|
|
||||||
"data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.",
|
|
||||||
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
|
||||||
"canceled": "Ago nuligita",
|
|
||||||
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
|
||||||
"new_version": ""
|
|
||||||
},
|
|
||||||
"toggleFieldsMenu": {
|
|
||||||
"columnsToDisplay": "",
|
|
||||||
"layout": "",
|
|
||||||
"grid": "",
|
|
||||||
"table": ""
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"message": {
|
"artist": {
|
||||||
"note": "Noto",
|
"name": "Artisto |||| Artistoj",
|
||||||
"transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.",
|
"fields": {
|
||||||
"transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.",
|
"name": "Nomo",
|
||||||
"songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto",
|
"albumCount": "Nombro da albumoj",
|
||||||
"noPlaylistsAvailable": "Neniu disponebla",
|
"songCount": "Kanto kalkula",
|
||||||
"delete_user_title": "Forigi uzanto '%{name}'",
|
"playCount": "Teatraĵoj",
|
||||||
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
|
"rating": "Takso",
|
||||||
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
|
"genre": "",
|
||||||
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
|
"size": "",
|
||||||
"lastfmLinkSuccess": "",
|
"role": ""
|
||||||
"lastfmLinkFailure": "",
|
},
|
||||||
"lastfmUnlinkSuccess": "",
|
"roles": {
|
||||||
"lastfmUnlinkFailure": "",
|
"albumartist": "",
|
||||||
"openIn": {
|
"artist": "",
|
||||||
"lastfm": "",
|
"composer": "",
|
||||||
"musicbrainz": ""
|
"conductor": "",
|
||||||
},
|
"lyricist": "",
|
||||||
"lastfmLink": "",
|
"arranger": "",
|
||||||
"listenBrainzLinkSuccess": "",
|
"producer": "",
|
||||||
"listenBrainzLinkFailure": "",
|
"director": "",
|
||||||
"listenBrainzUnlinkSuccess": "",
|
"engineer": "",
|
||||||
"listenBrainzUnlinkFailure": "",
|
"mixer": "",
|
||||||
"downloadOriginalFormat": "",
|
"remixer": "",
|
||||||
"shareOriginalFormat": "",
|
"djmixer": "",
|
||||||
"shareDialogTitle": "",
|
"performer": ""
|
||||||
"shareBatchDialogTitle": "",
|
}
|
||||||
"shareSuccess": "",
|
|
||||||
"shareFailure": "",
|
|
||||||
"downloadDialogTitle": "",
|
|
||||||
"shareCopyToClipboard": ""
|
|
||||||
},
|
},
|
||||||
"menu": {
|
"user": {
|
||||||
"library": "Biblioteko",
|
"name": "Uzanto |||| Uzantoj",
|
||||||
"settings": "Agordoj",
|
"fields": {
|
||||||
"version": "Versio",
|
"userName": "Uzantnomo",
|
||||||
"theme": "Temo",
|
"isAdmin": "Estas Administranto",
|
||||||
"personal": {
|
"lastLoginAt": "Antaŭa Ensaluto Je",
|
||||||
"name": "Persona",
|
"updatedAt": "Ĝisdatigita je",
|
||||||
"options": {
|
"name": "Nomo",
|
||||||
"theme": "Temo",
|
"password": "Pasvorto",
|
||||||
"language": "Lingvo",
|
"createdAt": "Kreita je :",
|
||||||
"defaultView": "Defaŭlta Vido",
|
"changePassword": "Ĉu Ŝanĝi Pasvorton?",
|
||||||
"desktop_notifications": "Labortablaj sciigoj",
|
"currentPassword": "Nuna Pasvorto",
|
||||||
"lastfmScrobbling": "",
|
"newPassword": "Nova Pasvorto",
|
||||||
"listenBrainzScrobbling": "",
|
"token": "",
|
||||||
"replaygain": "",
|
"lastAccessAt": ""
|
||||||
"preAmp": "",
|
},
|
||||||
"gain": {
|
"helperTexts": {
|
||||||
"none": "",
|
"name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
|
||||||
"album": "",
|
},
|
||||||
"track": ""
|
"notifications": {
|
||||||
}
|
"created": "Uzanto farita",
|
||||||
}
|
"updated": "Uzanto ĝistadigita",
|
||||||
},
|
"deleted": "Uzanto forigita"
|
||||||
"albumList": "Albumoj",
|
},
|
||||||
"about": "Pri",
|
"message": {
|
||||||
"playlists": "",
|
"listenBrainzToken": "",
|
||||||
"sharedPlaylists": ""
|
"clickHereForToken": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"playListsText": "Ludu Atendon",
|
"name": "Ludanto |||| Ludantoj",
|
||||||
"openText": "Malfermi",
|
"fields": {
|
||||||
"closeText": "Fermi",
|
"name": "Nomo",
|
||||||
"notContentText": "Neniu Muziko",
|
"transcodingId": "Transkodigo",
|
||||||
"clickToPlayText": "Alklaku por ludi",
|
"maxBitRate": "Maksimuma Bitrapido",
|
||||||
"clickToPauseText": "Alklaku por paŭzi",
|
"client": "Kliento",
|
||||||
"nextTrackText": "Sekva muziko",
|
"userName": "Uzantnomo",
|
||||||
"previousTrackText": "Antaŭa muziko",
|
"lastSeen": "Laste Vidita Je",
|
||||||
"reloadText": "Reŝargi",
|
"reportRealPath": "Raporti vera pado",
|
||||||
"volumeText": "Laŭteco",
|
"scrobbleEnabled": ""
|
||||||
"toggleLyricText": "Baskuligi paroloj",
|
}
|
||||||
"toggleMiniModeText": "Minimumigi",
|
|
||||||
"destroyText": "Detrui",
|
|
||||||
"downloadText": "Elŝuti",
|
|
||||||
"removeAudioListsText": "Forigi sonlistojn",
|
|
||||||
"clickToDeleteText": "Alklaku por forigi %{name}",
|
|
||||||
"emptyLyricText": "Neniaj paroloj",
|
|
||||||
"playModeText": {
|
|
||||||
"order": "En ordo",
|
|
||||||
"orderLoop": "Ripeti",
|
|
||||||
"singleLoop": "Ripeti Unu",
|
|
||||||
"shufflePlay": "Miksi"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"about": {
|
"transcoding": {
|
||||||
"links": {
|
"name": "Transkodigo |||| Transkodigoj",
|
||||||
"homepage": "Hejmpaĝo",
|
"fields": {
|
||||||
"source": "Fontkodo",
|
"name": "Nomo",
|
||||||
"featureRequests": "Trajta peto"
|
"targetFormat": "Cela Formato",
|
||||||
}
|
"defaultBitRate": "Defaŭlta Bitrapido",
|
||||||
|
"command": "Komando"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"playlist": {
|
||||||
"title": "Aktiveco",
|
"name": "Ludlisto |||| Ludlistoj",
|
||||||
"totalScanned": "Entute dosierujoj skanitaj",
|
"fields": {
|
||||||
"quickScan": "Rapida Skanado",
|
"name": "Nomo",
|
||||||
"fullScan": "Plena Skanado",
|
"duration": "Daŭro",
|
||||||
"serverUptime": "Servila daŭro de funkciado",
|
"ownerName": "Posedanto",
|
||||||
"serverDown": "SENKONEKTA"
|
"public": "Publika",
|
||||||
|
"updatedAt": "Ĝisdatigita je",
|
||||||
|
"createdAt": "Kreita je",
|
||||||
|
"songCount": "Kantoj",
|
||||||
|
"comment": "Komento",
|
||||||
|
"sync": "Aŭtomata importado",
|
||||||
|
"path": "Importi de"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"selectPlaylist": "Elektu ludliston :",
|
||||||
|
"addNewPlaylist": "Krei \"%{name}\"",
|
||||||
|
"export": "Eksporti",
|
||||||
|
"makePublic": "",
|
||||||
|
"makePrivate": ""
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"duplicate_song": "Aldoni duobligitajn kantojn",
|
||||||
|
"song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"help": {
|
"radio": {
|
||||||
"title": "Navidrome klavkomando",
|
"name": "",
|
||||||
"hotkeys": {
|
"fields": {
|
||||||
"show_help": "Montru ĉi tiun helpon",
|
"name": "",
|
||||||
"toggle_menu": "Baskuli menuan flankobreton",
|
"streamUrl": "",
|
||||||
"toggle_play": "Ludi / Paŭzi",
|
"homePageUrl": "",
|
||||||
"prev_song": "Antaŭa kanto",
|
"updatedAt": "",
|
||||||
"next_song": "Sekva kanto",
|
"createdAt": ""
|
||||||
"vol_up": "Pli volumo",
|
},
|
||||||
"vol_down": "Malpli volumo",
|
"actions": {
|
||||||
"toggle_love": "Baskuli la stelon de nuna kanto",
|
"playNow": ""
|
||||||
"current_song": ""
|
}
|
||||||
}
|
},
|
||||||
|
"share": {
|
||||||
|
"name": "",
|
||||||
|
"fields": {
|
||||||
|
"username": "",
|
||||||
|
"url": "",
|
||||||
|
"description": "",
|
||||||
|
"contents": "",
|
||||||
|
"expiresAt": "",
|
||||||
|
"lastVisitedAt": "",
|
||||||
|
"visitCount": "",
|
||||||
|
"format": "",
|
||||||
|
"maxBitRate": "",
|
||||||
|
"updatedAt": "",
|
||||||
|
"createdAt": "",
|
||||||
|
"downloadable": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"missing": {
|
||||||
|
"name": "",
|
||||||
|
"fields": {
|
||||||
|
"path": "",
|
||||||
|
"size": "",
|
||||||
|
"updatedAt": ""
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"remove": ""
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"removed": ""
|
||||||
|
},
|
||||||
|
"empty": ""
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ra": {
|
||||||
|
"auth": {
|
||||||
|
"welcome1": "Dankon pro instalado de Navidrome !",
|
||||||
|
"welcome2": "Por komenci, kreu administrantan uzanton",
|
||||||
|
"confirmPassword": "Konfirmu Pasvorton",
|
||||||
|
"buttonCreateAdmin": "Krei Administranto",
|
||||||
|
"auth_check_error": "Bonvolu ensaluti por daŭrigi",
|
||||||
|
"user_menu": "Profilo",
|
||||||
|
"username": "Uzantnomo",
|
||||||
|
"password": "Pasvorto",
|
||||||
|
"sign_in": "Ensaluti",
|
||||||
|
"sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi",
|
||||||
|
"logout": "Elsaluti",
|
||||||
|
"insightsCollectionNote": ""
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"invalidChars": "Bonvolu uzi nur literojn kaj ciferojn",
|
||||||
|
"passwordDoesNotMatch": "Pasvorto ne kongruas",
|
||||||
|
"required": "Necesa",
|
||||||
|
"minLength": "Devas esti almenaŭ %{min} signoj",
|
||||||
|
"maxLength": "Devas esti %{max} signoj aŭ malpli",
|
||||||
|
"minValue": "Devas esti almenaŭ %{min}",
|
||||||
|
"maxValue": "Devas esti %{max} aŭ malpli",
|
||||||
|
"number": "Devas esti nombro",
|
||||||
|
"email": "Devas esti valida retpoŝto",
|
||||||
|
"oneOf": "Devas esti unu el: %{options}",
|
||||||
|
"regex": "Devas kongrui kun specifa formato (regexp): %{pattern}",
|
||||||
|
"unique": "Devas esti unika",
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"add_filter": "Aldoni filtrilon",
|
||||||
|
"add": "Aldoni",
|
||||||
|
"back": "Reiri",
|
||||||
|
"bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj",
|
||||||
|
"cancel": "Nuligi",
|
||||||
|
"clear_input_value": "Viŝi valoron",
|
||||||
|
"clone": "Kloni",
|
||||||
|
"confirm": "Konfirmi",
|
||||||
|
"create": "Krei",
|
||||||
|
"delete": "Forigi",
|
||||||
|
"edit": "Redakti",
|
||||||
|
"export": "Eksporti",
|
||||||
|
"list": "Listigi",
|
||||||
|
"refresh": "Aktualigi",
|
||||||
|
"remove_filter": "Forigu ĉi tiun filtrilon",
|
||||||
|
"remove": "Forigi",
|
||||||
|
"save": "Konservi",
|
||||||
|
"search": "Serĉi",
|
||||||
|
"show": "Montri",
|
||||||
|
"sort": "Ordigi",
|
||||||
|
"undo": "Malfari",
|
||||||
|
"expand": "Etendi",
|
||||||
|
"close": "Fermi",
|
||||||
|
"open_menu": "Malfermi menuon",
|
||||||
|
"close_menu": "Fermu menuon",
|
||||||
|
"unselect": "Malelekti",
|
||||||
|
"skip": "Pasigi",
|
||||||
|
"bulk_actions_mobile": "",
|
||||||
|
"share": "",
|
||||||
|
"download": ""
|
||||||
|
},
|
||||||
|
"boolean": {
|
||||||
|
"true": "Jes",
|
||||||
|
"false": "Ne"
|
||||||
|
},
|
||||||
|
"page": {
|
||||||
|
"create": "Krei %{name}",
|
||||||
|
"dashboard": "Panelo",
|
||||||
|
"edit": "%{name} #%{id}",
|
||||||
|
"error": "Io fuŝiĝis",
|
||||||
|
"list": "${name}",
|
||||||
|
"loading": "Ŝarĝante",
|
||||||
|
"not_found": "Ne Trovita",
|
||||||
|
"show": "%{name} #%{id}",
|
||||||
|
"empty": "Ankoraŭ ne %{name}",
|
||||||
|
"invite": "Ĉu vi volas aldoni unu?"
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"file": {
|
||||||
|
"upload_several": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti unu.",
|
||||||
|
"upload_single": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti ĝin."
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"upload_several": "Demetu iom da bildoj por alŝuti, aŭ alklaku por elekti unu.",
|
||||||
|
"upload_single": "Demetu bildon por alŝuti, aŭ alklaku por elekti ĝin."
|
||||||
|
},
|
||||||
|
"references": {
|
||||||
|
"all_missing": "Ne eblas trovi referencajn datumojn.",
|
||||||
|
"many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.",
|
||||||
|
"single_missing": "Rilata referenco ne plu ŝajnas esti disponebla."
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"toggle_visible": "Kaŝi pasvorton",
|
||||||
|
"toggle_hidden": "Montri pasvorton"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"about": "Pri",
|
||||||
|
"are_you_sure": "Ĉu vi certas?",
|
||||||
|
"bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name}? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn?",
|
||||||
|
"bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}",
|
||||||
|
"delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun eron?",
|
||||||
|
"delete_title": "Forigi %{name} #%{id}",
|
||||||
|
"details": "Detaloj",
|
||||||
|
"error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.",
|
||||||
|
"invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.",
|
||||||
|
"loading": "La paĝo ŝargiĝas, atendu nur momenton bonvole",
|
||||||
|
"no": "Ne",
|
||||||
|
"not_found": "Aŭ vi tajpis malĝustan ligilon, aŭ vi sekvis malbonan ligilon.",
|
||||||
|
"yes": "Jes",
|
||||||
|
"unsaved_changes": "Iuj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi certas, ke vi volas ignori ilin?"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"no_results": "Neniu rezulto troviĝis",
|
||||||
|
"no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.",
|
||||||
|
"page_out_of_boundaries": "Paĝa numero %{page} estas ekster limoj",
|
||||||
|
"page_out_from_end": "Ne povas iri post la lasta paĝo",
|
||||||
|
"page_out_from_begin": "Ne povas iri antaŭ paĝo 1",
|
||||||
|
"page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
|
||||||
|
"page_rows_per_page": "Eroj en paĝo:",
|
||||||
|
"next": "Sekvanta",
|
||||||
|
"prev": "Antaŭa",
|
||||||
|
"skip_nav": "Preterlasu al enhavo"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj",
|
||||||
|
"created": "\nElemento kretia",
|
||||||
|
"deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj",
|
||||||
|
"bad_item": "Malĝusta elemento",
|
||||||
|
"item_doesnt_exist": "Elemento ne ekzistas",
|
||||||
|
"http_error": "Servila komunikada eraro",
|
||||||
|
"data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.",
|
||||||
|
"i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo",
|
||||||
|
"canceled": "Ago nuligita",
|
||||||
|
"logged_out": "Via seanco finiĝis, bonvolu rekonekti.",
|
||||||
|
"new_version": ""
|
||||||
|
},
|
||||||
|
"toggleFieldsMenu": {
|
||||||
|
"columnsToDisplay": "",
|
||||||
|
"layout": "Aranĝo",
|
||||||
|
"grid": "Krado",
|
||||||
|
"table": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"note": "Noto",
|
||||||
|
"transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.",
|
||||||
|
"transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.",
|
||||||
|
"songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto",
|
||||||
|
"noPlaylistsAvailable": "Neniu disponebla",
|
||||||
|
"delete_user_title": "Forigi uzanto '%{name}'",
|
||||||
|
"delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?",
|
||||||
|
"notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo",
|
||||||
|
"notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https",
|
||||||
|
"lastfmLinkSuccess": "",
|
||||||
|
"lastfmLinkFailure": "",
|
||||||
|
"lastfmUnlinkSuccess": "",
|
||||||
|
"lastfmUnlinkFailure": "",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "",
|
||||||
|
"musicbrainz": ""
|
||||||
|
},
|
||||||
|
"lastfmLink": "",
|
||||||
|
"listenBrainzLinkSuccess": "",
|
||||||
|
"listenBrainzLinkFailure": "",
|
||||||
|
"listenBrainzUnlinkSuccess": "",
|
||||||
|
"listenBrainzUnlinkFailure": "",
|
||||||
|
"downloadOriginalFormat": "",
|
||||||
|
"shareOriginalFormat": "",
|
||||||
|
"shareDialogTitle": "",
|
||||||
|
"shareBatchDialogTitle": "",
|
||||||
|
"shareSuccess": "",
|
||||||
|
"shareFailure": "",
|
||||||
|
"downloadDialogTitle": "",
|
||||||
|
"shareCopyToClipboard": "",
|
||||||
|
"remove_missing_title": "",
|
||||||
|
"remove_missing_content": ""
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"library": "Biblioteko",
|
||||||
|
"settings": "Agordoj",
|
||||||
|
"version": "Versio",
|
||||||
|
"theme": "Etoso",
|
||||||
|
"personal": {
|
||||||
|
"name": "Persona",
|
||||||
|
"options": {
|
||||||
|
"theme": "Etoso",
|
||||||
|
"language": "Lingvo",
|
||||||
|
"defaultView": "Defaŭlta Vido",
|
||||||
|
"desktop_notifications": "Labortablaj sciigoj",
|
||||||
|
"lastfmScrobbling": "",
|
||||||
|
"listenBrainzScrobbling": "",
|
||||||
|
"replaygain": "",
|
||||||
|
"preAmp": "",
|
||||||
|
"gain": {
|
||||||
|
"none": "",
|
||||||
|
"album": "",
|
||||||
|
"track": ""
|
||||||
|
},
|
||||||
|
"lastfmNotConfigured": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"albumList": "Albumoj",
|
||||||
|
"about": "Pri",
|
||||||
|
"playlists": "",
|
||||||
|
"sharedPlaylists": ""
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"playListsText": "Atendovico",
|
||||||
|
"openText": "Malfermi",
|
||||||
|
"closeText": "Fermi",
|
||||||
|
"notContentText": "Neniu muziko",
|
||||||
|
"clickToPlayText": "Alklaku por ludi",
|
||||||
|
"clickToPauseText": "Alklaku por paŭzi",
|
||||||
|
"nextTrackText": "Sekvanta kanto",
|
||||||
|
"previousTrackText": "Antaŭa kanto",
|
||||||
|
"reloadText": "Reŝargi",
|
||||||
|
"volumeText": "Laŭteco",
|
||||||
|
"toggleLyricText": "Baskuligi kantotekston",
|
||||||
|
"toggleMiniModeText": "Minimumigi",
|
||||||
|
"destroyText": "Detrui",
|
||||||
|
"downloadText": "Elŝuti",
|
||||||
|
"removeAudioListsText": "Forigi sonlistojn",
|
||||||
|
"clickToDeleteText": "Alklaku por forigi %{name}",
|
||||||
|
"emptyLyricText": "Neniu kantoteksto",
|
||||||
|
"playModeText": {
|
||||||
|
"order": "Laŭorde",
|
||||||
|
"orderLoop": "Ripeti",
|
||||||
|
"singleLoop": "Ripeti Unufoje",
|
||||||
|
"shufflePlay": "Miksi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"links": {
|
||||||
|
"homepage": "Hejmpaĝo",
|
||||||
|
"source": "Fontkodo",
|
||||||
|
"featureRequests": "Trajta peto",
|
||||||
|
"lastInsightsCollection": "",
|
||||||
|
"insights": {
|
||||||
|
"disabled": "",
|
||||||
|
"waiting": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"title": "Aktiveco",
|
||||||
|
"totalScanned": "Entute dosierujoj skanitaj",
|
||||||
|
"quickScan": "Rapida Skanado",
|
||||||
|
"fullScan": "Plena Skanado",
|
||||||
|
"serverUptime": "Servila daŭro de funkciado",
|
||||||
|
"serverDown": "SENKONEKTA"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "Navidrome klavkomando",
|
||||||
|
"hotkeys": {
|
||||||
|
"show_help": "Montru ĉi tiun helpon",
|
||||||
|
"toggle_menu": "Baskuli menuan flankobreton",
|
||||||
|
"toggle_play": "Ludi / Paŭzi",
|
||||||
|
"prev_song": "Antaŭa kanto",
|
||||||
|
"next_song": "Sekva kanto",
|
||||||
|
"vol_up": "Pli volumo",
|
||||||
|
"vol_down": "Malpli volumo",
|
||||||
|
"toggle_love": "Baskuli la stelon de nuna kanto",
|
||||||
|
"current_song": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -27,13 +27,13 @@
|
|||||||
"playDate": "Ostatnio Odtwarzane",
|
"playDate": "Ostatnio Odtwarzane",
|
||||||
"channels": "Kanały",
|
"channels": "Kanały",
|
||||||
"createdAt": "Data dodania",
|
"createdAt": "Data dodania",
|
||||||
"grouping": "",
|
"grouping": "Grupowanie",
|
||||||
"mood": "",
|
"mood": "Nastrój",
|
||||||
"participants": "",
|
"participants": "Dodatkowi uczestnicy",
|
||||||
"tags": "",
|
"tags": "Dodatkowe Tagi",
|
||||||
"mappedTags": "",
|
"mappedTags": "Zmapowane tagi",
|
||||||
"rawTags": "",
|
"rawTags": "Surowe tagi",
|
||||||
"bitDepth": ""
|
"bitDepth": "Głębokość próbkowania"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Odtwarzaj Później",
|
"addToQueue": "Odtwarzaj Później",
|
||||||
@ -66,12 +66,13 @@
|
|||||||
"releaseDate": "Data Wydania",
|
"releaseDate": "Data Wydania",
|
||||||
"releases": "Wydanie |||| Wydania",
|
"releases": "Wydanie |||| Wydania",
|
||||||
"released": "Wydany",
|
"released": "Wydany",
|
||||||
"recordLabel": "",
|
"recordLabel": "Wytwórnia",
|
||||||
"catalogNum": "",
|
"catalogNum": "Numer Katalogowy",
|
||||||
"releaseType": "",
|
"releaseType": "Typ",
|
||||||
"grouping": "",
|
"grouping": "Grupowanie",
|
||||||
"media": "",
|
"media": "Media",
|
||||||
"mood": ""
|
"mood": "Nastrój",
|
||||||
|
"date": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "Odtwarzaj",
|
"playAll": "Odtwarzaj",
|
||||||
@ -103,15 +104,15 @@
|
|||||||
"rating": "Ocena",
|
"rating": "Ocena",
|
||||||
"genre": "Gatunek",
|
"genre": "Gatunek",
|
||||||
"size": "Rozmiar",
|
"size": "Rozmiar",
|
||||||
"role": ""
|
"role": "Rola"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"albumartist": "",
|
"albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu",
|
||||||
"artist": "",
|
"artist": "Wykonawca |||| Wykonawcy",
|
||||||
"composer": "",
|
"composer": "Kompozytor |||| Kompozytorzy",
|
||||||
"conductor": "",
|
"conductor": "Dyrygent |||| Dyrygenci",
|
||||||
"lyricist": "",
|
"lyricist": "Autor tekstów |||| Autorzy tekstów",
|
||||||
"arranger": "",
|
"arranger": "Aranżer |||| Aranżerzy",
|
||||||
"producer": "Producent |||| Producenci",
|
"producer": "Producent |||| Producenci",
|
||||||
"director": "Reżyser |||| Reżyserzy",
|
"director": "Reżyser |||| Reżyserzy",
|
||||||
"engineer": "Inżynier |||| Inżynierowie",
|
"engineer": "Inżynier |||| Inżynierowie",
|
||||||
@ -241,7 +242,7 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"removed": "Usunięto brakujące pliki"
|
"removed": "Usunięto brakujące pliki"
|
||||||
},
|
},
|
||||||
"empty": ""
|
"empty": "Bez Brakujących Plików"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ra": {
|
"ra": {
|
||||||
|
@ -57,6 +57,7 @@
|
|||||||
"genre": "Gênero",
|
"genre": "Gênero",
|
||||||
"compilation": "Coletânea",
|
"compilation": "Coletânea",
|
||||||
"year": "Ano",
|
"year": "Ano",
|
||||||
|
"date": "Data de Lançamento",
|
||||||
"updatedAt": "Últ. Atualização",
|
"updatedAt": "Últ. Atualização",
|
||||||
"comment": "Comentário",
|
"comment": "Comentário",
|
||||||
"rating": "Classificação",
|
"rating": "Classificação",
|
||||||
|
@ -32,7 +32,8 @@
|
|||||||
"participants": "Дополнительные участники",
|
"participants": "Дополнительные участники",
|
||||||
"tags": "Дополнительные теги",
|
"tags": "Дополнительные теги",
|
||||||
"mappedTags": "Сопоставленные теги",
|
"mappedTags": "Сопоставленные теги",
|
||||||
"rawTags": "Исходные теги"
|
"rawTags": "Исходные теги",
|
||||||
|
"bitDepth": "Битовая глубина"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "В очередь",
|
"addToQueue": "В очередь",
|
||||||
@ -70,7 +71,8 @@
|
|||||||
"releaseType": "Тип",
|
"releaseType": "Тип",
|
||||||
"grouping": "Группирование",
|
"grouping": "Группирование",
|
||||||
"media": "Медиа",
|
"media": "Медиа",
|
||||||
"mood": "Настроение"
|
"mood": "Настроение",
|
||||||
|
"date": ""
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "Играть",
|
"playAll": "Играть",
|
||||||
@ -239,7 +241,8 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"removed": "Отсутствующие файлы удалены"
|
"removed": "Отсутствующие файлы удалены"
|
||||||
}
|
},
|
||||||
|
"empty": "Нет отсутствующих файлов"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ra": {
|
"ra": {
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
"tags": "Ek Etiketler",
|
"tags": "Ek Etiketler",
|
||||||
"mappedTags": "Eşlenen etiketler",
|
"mappedTags": "Eşlenen etiketler",
|
||||||
"rawTags": "Ham etiketler",
|
"rawTags": "Ham etiketler",
|
||||||
"bitDepth": ""
|
"bitDepth": "Bit derinliği"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Oynatma Sırasına Ekle",
|
"addToQueue": "Oynatma Sırasına Ekle",
|
||||||
@ -71,7 +71,8 @@
|
|||||||
"releaseType": "Tür",
|
"releaseType": "Tür",
|
||||||
"grouping": "Gruplama",
|
"grouping": "Gruplama",
|
||||||
"media": "Medya",
|
"media": "Medya",
|
||||||
"mood": "Mod"
|
"mood": "Mod",
|
||||||
|
"date": "Kayıt Tarihi"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "Oynat",
|
"playAll": "Oynat",
|
||||||
|
@ -118,10 +118,10 @@ main:
|
|||||||
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ]
|
aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ]
|
||||||
type: date
|
type: date
|
||||||
recordingdate:
|
recordingdate:
|
||||||
aliases: [ tdrc, date, icrd, ©day, wm/year, year ]
|
aliases: [ tdrc, date, recordingdate, icrd, record date ]
|
||||||
type: date
|
type: date
|
||||||
releasedate:
|
releasedate:
|
||||||
aliases: [ tdrl, releasedate ]
|
aliases: [ tdrl, releasedate, ©day, wm/year, year ]
|
||||||
type: date
|
type: date
|
||||||
catalognumber:
|
catalognumber:
|
||||||
aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ]
|
aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ]
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 293 KiB |
@ -292,13 +292,17 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]inte
|
|||||||
user, err := userRepo.FindByUsernameWithPassword(username)
|
user, err := userRepo.FindByUsernameWithPassword(username)
|
||||||
if user == nil || err != nil {
|
if user == nil || err != nil {
|
||||||
log.Info(r, "User passed in header not found", "user", username)
|
log.Info(r, "User passed in header not found", "user", username)
|
||||||
|
// Check if this is the first user being created
|
||||||
|
count, _ := userRepo.CountAll()
|
||||||
|
isFirstUser := count == 0
|
||||||
|
|
||||||
newUser := model.User{
|
newUser := model.User{
|
||||||
ID: id.NewRandom(),
|
ID: id.NewRandom(),
|
||||||
UserName: username,
|
UserName: username,
|
||||||
Name: username,
|
Name: username,
|
||||||
Email: "",
|
Email: "",
|
||||||
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
|
NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(),
|
||||||
IsAdmin: false,
|
IsAdmin: isFirstUser, // Make the first user an admin
|
||||||
}
|
}
|
||||||
err := userRepo.Put(&newUser)
|
err := userRepo.Put(&newUser)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -292,4 +292,54 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("handleLoginFromHeaders", func() {
|
||||||
|
var ds model.DataStore
|
||||||
|
var req *http.Request
|
||||||
|
const trustedIP = "192.168.0.42"
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ds = &tests.MockDataStore{}
|
||||||
|
req = httptest.NewRequest("GET", "/", nil)
|
||||||
|
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
|
||||||
|
conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
|
||||||
|
conf.Server.ReverseProxyUserHeader = "Remote-User"
|
||||||
|
})
|
||||||
|
|
||||||
|
It("makes the first user an admin", func() {
|
||||||
|
// No existing users
|
||||||
|
req.Header.Set("Remote-User", "firstuser")
|
||||||
|
result := handleLoginFromHeaders(ds, req)
|
||||||
|
|
||||||
|
Expect(result).ToNot(BeNil())
|
||||||
|
Expect(result["isAdmin"]).To(BeTrue())
|
||||||
|
|
||||||
|
// Verify user was created as admin
|
||||||
|
u, err := ds.User(context.Background()).FindByUsername("firstuser")
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(u.IsAdmin).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not make subsequent users admins", func() {
|
||||||
|
// Create the first user
|
||||||
|
_ = ds.User(context.Background()).Put(&model.User{
|
||||||
|
ID: "existing-user-id",
|
||||||
|
UserName: "existinguser",
|
||||||
|
Name: "Existing User",
|
||||||
|
IsAdmin: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try to create a second user via proxy header
|
||||||
|
req.Header.Set("Remote-User", "seconduser")
|
||||||
|
result := handleLoginFromHeaders(ds, req)
|
||||||
|
|
||||||
|
Expect(result).ToNot(BeNil())
|
||||||
|
Expect(result["isAdmin"]).To(BeFalse())
|
||||||
|
|
||||||
|
// Verify user was created as non-admin
|
||||||
|
u, err := ds.User(context.Background()).FindByUsername("seconduser")
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(u.IsAdmin).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
@ -30,37 +31,37 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
|
|||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
artwork artwork.Artwork
|
artwork artwork.Artwork
|
||||||
streamer core.MediaStreamer
|
streamer core.MediaStreamer
|
||||||
archiver core.Archiver
|
archiver core.Archiver
|
||||||
players core.Players
|
players core.Players
|
||||||
externalMetadata core.ExternalMetadata
|
provider external.Provider
|
||||||
playlists core.Playlists
|
playlists core.Playlists
|
||||||
scanner scanner.Scanner
|
scanner scanner.Scanner
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
scrobbler scrobbler.PlayTracker
|
scrobbler scrobbler.PlayTracker
|
||||||
share core.Share
|
share core.Share
|
||||||
playback playback.PlaybackServer
|
playback playback.PlaybackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
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,
|
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||||
) *Router {
|
) *Router {
|
||||||
r := &Router{
|
r := &Router{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
artwork: artwork,
|
artwork: artwork,
|
||||||
streamer: streamer,
|
streamer: streamer,
|
||||||
archiver: archiver,
|
archiver: archiver,
|
||||||
players: players,
|
players: players,
|
||||||
externalMetadata: externalMetadata,
|
provider: provider,
|
||||||
playlists: playlists,
|
playlists: playlists,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
broker: broker,
|
broker: broker,
|
||||||
scrobbler: scrobbler,
|
scrobbler: scrobbler,
|
||||||
share: share,
|
share: share,
|
||||||
playback: playback,
|
playback: playback,
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
|
@ -210,7 +210,7 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id)
|
album, err := api.provider.UpdateAlbumInfo(ctx, id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -278,7 +278,7 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m
|
|||||||
count := p.IntOr("count", 20)
|
count := p.IntOr("count", 20)
|
||||||
includeNotPresent := p.BoolOr("includeNotPresent", false)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@ -343,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
|
|||||||
}
|
}
|
||||||
count := p.IntOr("count", 50)
|
count := p.IntOr("count", 50)
|
||||||
|
|
||||||
songs, err := api.externalMetadata.SimilarSongs(ctx, id, count)
|
songs, err := api.provider.SimilarSongs(ctx, id, count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -377,8 +377,8 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) {
|
|||||||
}
|
}
|
||||||
count := p.IntOr("count", 50)
|
count := p.IntOr("count", 50)
|
||||||
|
|
||||||
songs, err := api.externalMetadata.TopSongs(ctx, artist, count)
|
songs, err := api.provider.TopSongs(ctx, artist, count)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -62,13 +62,14 @@ func AlbumsByArtistID(artistId string) Options {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func AlbumsByYear(fromYear, toYear int) Options {
|
func AlbumsByYear(fromYear, toYear int) Options {
|
||||||
sortOption := "max_year, name"
|
orderOption := ""
|
||||||
if fromYear > toYear {
|
if fromYear > toYear {
|
||||||
fromYear, toYear = toYear, fromYear
|
fromYear, toYear = toYear, fromYear
|
||||||
sortOption = "max_year desc, name"
|
orderOption = "desc"
|
||||||
}
|
}
|
||||||
return addDefaultFilters(Options{
|
return addDefaultFilters(Options{
|
||||||
Sort: sortOption,
|
Sort: "max_year",
|
||||||
|
Order: orderOption,
|
||||||
Filters: Or{
|
Filters: Or{
|
||||||
And{
|
And{
|
||||||
GtOrEq{"min_year": fromYear},
|
GtOrEq{"min_year": fromYear},
|
||||||
@ -118,7 +119,7 @@ func SongWithLyrics(artist, title string) Options {
|
|||||||
|
|
||||||
func ByGenre(genre string) Options {
|
func ByGenre(genre string) Options {
|
||||||
return addDefaultFilters(Options{
|
return addDefaultFilters(Options{
|
||||||
Sort: "name asc",
|
Sort: "name",
|
||||||
Filters: filterByGenre(genre),
|
Filters: filterByGenre(genre),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -296,7 +296,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
|
|||||||
child.Name = al.Name
|
child.Name = al.Name
|
||||||
child.Album = al.Name
|
child.Album = al.Name
|
||||||
child.Artist = al.AlbumArtist
|
child.Artist = al.AlbumArtist
|
||||||
child.Year = int32(al.MaxYear)
|
child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear))
|
||||||
child.Genre = al.Genre
|
child.Genre = al.Genre
|
||||||
child.CoverArt = al.CoverArtID().String()
|
child.CoverArt = al.CoverArtID().String()
|
||||||
child.Created = &al.CreatedAt
|
child.Created = &al.CreatedAt
|
||||||
@ -380,7 +380,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 {
|
|||||||
dir.SongCount = int32(album.SongCount)
|
dir.SongCount = int32(album.SongCount)
|
||||||
dir.Duration = int32(album.Duration)
|
dir.Duration = int32(album.Duration)
|
||||||
dir.PlayCount = album.PlayCount
|
dir.PlayCount = album.PlayCount
|
||||||
dir.Year = int32(album.MaxYear)
|
dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear))
|
||||||
dir.Genre = album.Genre
|
dir.Genre = album.Genre
|
||||||
if !album.CreatedAt.IsZero() {
|
if !album.CreatedAt.IsZero() {
|
||||||
dir.Created = &album.CreatedAt
|
dir.Created = &album.CreatedAt
|
||||||
|
@ -10,56 +10,56 @@ import (
|
|||||||
|
|
||||||
func CreateMockAlbumRepo() *MockAlbumRepo {
|
func CreateMockAlbumRepo() *MockAlbumRepo {
|
||||||
return &MockAlbumRepo{
|
return &MockAlbumRepo{
|
||||||
data: make(map[string]*model.Album),
|
Data: make(map[string]*model.Album),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockAlbumRepo struct {
|
type MockAlbumRepo struct {
|
||||||
model.AlbumRepository
|
model.AlbumRepository
|
||||||
data map[string]*model.Album
|
Data map[string]*model.Album
|
||||||
all model.Albums
|
All model.Albums
|
||||||
err bool
|
Err bool
|
||||||
Options model.QueryOptions
|
Options model.QueryOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) SetError(err bool) {
|
func (m *MockAlbumRepo) SetError(err bool) {
|
||||||
m.err = err
|
m.Err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) SetData(albums model.Albums) {
|
func (m *MockAlbumRepo) SetData(albums model.Albums) {
|
||||||
m.data = make(map[string]*model.Album, len(albums))
|
m.Data = make(map[string]*model.Album, len(albums))
|
||||||
m.all = albums
|
m.All = albums
|
||||||
for i, a := range m.all {
|
for i, a := range m.All {
|
||||||
m.data[a.ID] = &m.all[i]
|
m.Data[a.ID] = &m.All[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) Exists(id string) (bool, error) {
|
func (m *MockAlbumRepo) Exists(id string) (bool, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return false, errors.New("unexpected error")
|
return false, errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
_, found := m.data[id]
|
_, found := m.Data[id]
|
||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) Get(id string) (*model.Album, error) {
|
func (m *MockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("unexpected error")
|
return nil, errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) Put(al *model.Album) error {
|
func (m *MockAlbumRepo) Put(al *model.Album) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("unexpected error")
|
return errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
if al.ID == "" {
|
if al.ID == "" {
|
||||||
al.ID = id.NewRandom()
|
al.ID = id.NewRandom()
|
||||||
}
|
}
|
||||||
m.data[al.ID] = al
|
m.Data[al.ID] = al
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,17 +67,17 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) {
|
|||||||
if len(qo) > 0 {
|
if len(qo) > 0 {
|
||||||
m.Options = qo[0]
|
m.Options = qo[0]
|
||||||
}
|
}
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("unexpected error")
|
return nil, errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
return m.all, nil
|
return m.All, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
|
func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("unexpected error")
|
return errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
d.PlayCount++
|
d.PlayCount++
|
||||||
d.PlayDate = ×tamp
|
d.PlayDate = ×tamp
|
||||||
return nil
|
return nil
|
||||||
@ -85,15 +85,15 @@ func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error {
|
|||||||
return model.ErrNotFound
|
return model.ErrNotFound
|
||||||
}
|
}
|
||||||
func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) {
|
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) {
|
func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("unexpected error")
|
return nil, errors.New("unexpected error")
|
||||||
}
|
}
|
||||||
return func(yield func(model.Album, error) bool) {
|
return func(yield func(model.Album, error) bool) {
|
||||||
for _, a := range m.data {
|
for _, a := range m.Data {
|
||||||
if a.ID == "error" {
|
if a.ID == "error" {
|
||||||
if !yield(*a, errors.New("error")) {
|
if !yield(*a, errors.New("error")) {
|
||||||
break
|
break
|
||||||
@ -110,4 +110,11 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error {
|
||||||
|
if m.Err {
|
||||||
|
return errors.New("unexpected error")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)
|
var _ model.AlbumRepository = (*MockAlbumRepo)(nil)
|
||||||
|
@ -10,61 +10,61 @@ import (
|
|||||||
|
|
||||||
func CreateMockArtistRepo() *MockArtistRepo {
|
func CreateMockArtistRepo() *MockArtistRepo {
|
||||||
return &MockArtistRepo{
|
return &MockArtistRepo{
|
||||||
data: make(map[string]*model.Artist),
|
Data: make(map[string]*model.Artist),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockArtistRepo struct {
|
type MockArtistRepo struct {
|
||||||
model.ArtistRepository
|
model.ArtistRepository
|
||||||
data map[string]*model.Artist
|
Data map[string]*model.Artist
|
||||||
err bool
|
Err bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockArtistRepo) SetError(err bool) {
|
func (m *MockArtistRepo) SetError(err bool) {
|
||||||
m.err = err
|
m.Err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockArtistRepo) SetData(artists model.Artists) {
|
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 {
|
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) {
|
func (m *MockArtistRepo) Exists(id string) (bool, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return false, errors.New("Error!")
|
return false, errors.New("Error!")
|
||||||
}
|
}
|
||||||
_, found := m.data[id]
|
_, found := m.Data[id]
|
||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockArtistRepo) Get(id string) (*model.Artist, error) {
|
func (m *MockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("Error!")
|
return nil, errors.New("Error!")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error {
|
func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if ar.ID == "" {
|
if ar.ID == "" {
|
||||||
ar.ID = id.NewRandom()
|
ar.ID = id.NewRandom()
|
||||||
}
|
}
|
||||||
m.data[ar.ID] = ar
|
m.Data[ar.ID] = ar
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
|
func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
d.PlayCount++
|
d.PlayCount++
|
||||||
d.PlayDate = ×tamp
|
d.PlayDate = ×tamp
|
||||||
return nil
|
return nil
|
||||||
@ -72,4 +72,26 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error {
|
|||||||
return model.ErrNotFound
|
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)
|
var _ model.ArtistRepository = (*MockArtistRepo)(nil)
|
||||||
|
@ -6,12 +6,12 @@ import (
|
|||||||
|
|
||||||
type MockedGenreRepo struct {
|
type MockedGenreRepo struct {
|
||||||
Error error
|
Error error
|
||||||
data map[string]model.Genre
|
Data map[string]model.Genre
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MockedGenreRepo) init() {
|
func (r *MockedGenreRepo) init() {
|
||||||
if r.data == nil {
|
if r.Data == nil {
|
||||||
r.data = make(map[string]model.Genre)
|
r.Data = make(map[string]model.Genre)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) {
|
|||||||
r.init()
|
r.init()
|
||||||
|
|
||||||
var all model.Genres
|
var all model.Genres
|
||||||
for _, g := range r.data {
|
for _, g := range r.Data {
|
||||||
all = append(all, g)
|
all = append(all, g)
|
||||||
}
|
}
|
||||||
return all, nil
|
return all, nil
|
||||||
@ -33,6 +33,6 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error {
|
|||||||
return r.Error
|
return r.Error
|
||||||
}
|
}
|
||||||
r.init()
|
r.init()
|
||||||
r.data[g.ID] = *g
|
r.Data[g.ID] = *g
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -7,14 +7,14 @@ import (
|
|||||||
|
|
||||||
type MockLibraryRepo struct {
|
type MockLibraryRepo struct {
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
data map[int]model.Library
|
Data map[int]model.Library
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockLibraryRepo) SetData(data model.Libraries) {
|
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 {
|
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 {
|
if m.Err != nil {
|
||||||
return nil, m.Err
|
return nil, m.Err
|
||||||
}
|
}
|
||||||
return maps.Values(m.data), nil
|
return maps.Values(m.Data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
|
func (m *MockLibraryRepo) GetPath(id int) (string, error) {
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return "", m.Err
|
return "", m.Err
|
||||||
}
|
}
|
||||||
if lib, ok := m.data[id]; ok {
|
if lib, ok := m.Data[id]; ok {
|
||||||
return lib.Path, nil
|
return lib.Path, nil
|
||||||
}
|
}
|
||||||
return "", model.ErrNotFound
|
return "", model.ErrNotFound
|
||||||
|
@ -14,40 +14,40 @@ import (
|
|||||||
|
|
||||||
func CreateMockMediaFileRepo() *MockMediaFileRepo {
|
func CreateMockMediaFileRepo() *MockMediaFileRepo {
|
||||||
return &MockMediaFileRepo{
|
return &MockMediaFileRepo{
|
||||||
data: make(map[string]*model.MediaFile),
|
Data: make(map[string]*model.MediaFile),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockMediaFileRepo struct {
|
type MockMediaFileRepo struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
data map[string]*model.MediaFile
|
Data map[string]*model.MediaFile
|
||||||
err bool
|
Err bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) SetError(err bool) {
|
func (m *MockMediaFileRepo) SetError(err bool) {
|
||||||
m.err = err
|
m.Err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) {
|
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 {
|
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) {
|
func (m *MockMediaFileRepo) Exists(id string) (bool, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return false, errors.New("error")
|
return false, errors.New("error")
|
||||||
}
|
}
|
||||||
_, found := m.data[id]
|
_, found := m.Data[id]
|
||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
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
|
// Intentionally clone the file and remove participants. This should
|
||||||
// catch any caller that actually means to call GetWithParticipants
|
// catch any caller that actually means to call GetWithParticipants
|
||||||
res := *d
|
res := *d
|
||||||
@ -58,52 +58,52 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) GetWithParticipants(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")
|
return nil, errors.New("error")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
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 slice.Map(values, func(p *model.MediaFile) model.MediaFile {
|
||||||
return *p
|
return *p
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
|
func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if mf.ID == "" {
|
if mf.ID == "" {
|
||||||
mf.ID = id.NewRandom()
|
mf.ID = id.NewRandom()
|
||||||
}
|
}
|
||||||
m.data[mf.ID] = mf
|
m.Data[mf.ID] = mf
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) Delete(id string) error {
|
func (m *MockMediaFileRepo) Delete(id string) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if _, ok := m.data[id]; !ok {
|
if _, ok := m.Data[id]; !ok {
|
||||||
return model.ErrNotFound
|
return model.ErrNotFound
|
||||||
}
|
}
|
||||||
delete(m.data, id)
|
delete(m.Data, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
|
func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
d.PlayCount++
|
d.PlayCount++
|
||||||
d.PlayDate = ×tamp
|
d.PlayDate = ×tamp
|
||||||
return nil
|
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) {
|
func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
return nil, errors.New("error")
|
||||||
}
|
}
|
||||||
var res = make(model.MediaFiles, len(m.data))
|
var res = make(model.MediaFiles, len(m.Data))
|
||||||
i := 0
|
i := 0
|
||||||
for _, a := range m.data {
|
for _, a := range m.Data {
|
||||||
if a.AlbumID == artistId {
|
if a.AlbumID == artistId {
|
||||||
res[i] = *a
|
res[i] = *a
|
||||||
i++
|
i++
|
||||||
@ -128,17 +128,17 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("error")
|
return nil, errors.New("error")
|
||||||
}
|
}
|
||||||
var res model.MediaFiles
|
var res model.MediaFiles
|
||||||
for _, a := range m.data {
|
for _, a := range m.Data {
|
||||||
if a.LibraryID == libId && a.Missing {
|
if a.LibraryID == libId && a.Missing {
|
||||||
res = append(res, *a)
|
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 {
|
if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool {
|
||||||
return mediaFile.PID == a.PID
|
return mediaFile.PID == a.PID
|
||||||
}) != -1 {
|
}) != -1 {
|
||||||
|
@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
|
|||||||
type MockedPropertyRepo struct {
|
type MockedPropertyRepo struct {
|
||||||
model.PropertyRepository
|
model.PropertyRepository
|
||||||
Error error
|
Error error
|
||||||
data map[string]string
|
Data map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MockedPropertyRepo) init() {
|
func (p *MockedPropertyRepo) init() {
|
||||||
if p.data == nil {
|
if p.Data == nil {
|
||||||
p.data = make(map[string]string)
|
p.Data = make(map[string]string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ func (p *MockedPropertyRepo) Put(id string, value string) error {
|
|||||||
return p.Error
|
return p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
p.data[id] = value
|
p.Data[id] = value
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) {
|
|||||||
return "", p.Error
|
return "", p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
if v, ok := p.data[id]; ok {
|
if v, ok := p.Data[id]; ok {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
return "", model.ErrNotFound
|
return "", model.ErrNotFound
|
||||||
@ -39,8 +39,8 @@ func (p *MockedPropertyRepo) Delete(id string) error {
|
|||||||
return p.Error
|
return p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
if _, ok := p.data[id]; ok {
|
if _, ok := p.Data[id]; ok {
|
||||||
delete(p.data, id)
|
delete(p.Data, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return model.ErrNotFound
|
return model.ErrNotFound
|
||||||
|
@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
type MockedRadioRepo struct {
|
type MockedRadioRepo struct {
|
||||||
model.RadioRepository
|
model.RadioRepository
|
||||||
data map[string]*model.Radio
|
Data map[string]*model.Radio
|
||||||
all model.Radios
|
All model.Radios
|
||||||
err bool
|
Err bool
|
||||||
Options model.QueryOptions
|
Options model.QueryOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,44 +20,44 @@ func CreateMockedRadioRepo() *MockedRadioRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) SetError(err bool) {
|
func (m *MockedRadioRepo) SetError(err bool) {
|
||||||
m.err = err
|
m.Err = err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) {
|
func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return 0, errors.New("error")
|
return 0, errors.New("error")
|
||||||
}
|
}
|
||||||
return int64(len(m.data)), nil
|
return int64(len(m.Data)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) Delete(id string) error {
|
func (m *MockedRadioRepo) Delete(id string) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("Error!")
|
return errors.New("Error!")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, found := m.data[id]
|
_, found := m.Data[id]
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return errors.New("not found")
|
return errors.New("not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(m.data, id)
|
delete(m.Data, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) Exists(id string) (bool, error) {
|
func (m *MockedRadioRepo) Exists(id string) (bool, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return false, errors.New("Error!")
|
return false, errors.New("Error!")
|
||||||
}
|
}
|
||||||
_, found := m.data[id]
|
_, found := m.Data[id]
|
||||||
return found, nil
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) {
|
func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("Error!")
|
return nil, errors.New("Error!")
|
||||||
}
|
}
|
||||||
if d, ok := m.data[id]; ok {
|
if d, ok := m.Data[id]; ok {
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
@ -67,19 +67,19 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error)
|
|||||||
if len(qo) > 0 {
|
if len(qo) > 0 {
|
||||||
m.Options = qo[0]
|
m.Options = qo[0]
|
||||||
}
|
}
|
||||||
if m.err {
|
if m.Err {
|
||||||
return nil, errors.New("Error!")
|
return nil, errors.New("Error!")
|
||||||
}
|
}
|
||||||
return m.all, nil
|
return m.All, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
|
func (m *MockedRadioRepo) Put(radio *model.Radio) error {
|
||||||
if m.err {
|
if m.Err {
|
||||||
return errors.New("error")
|
return errors.New("error")
|
||||||
}
|
}
|
||||||
if radio.ID == "" {
|
if radio.ID == "" {
|
||||||
radio.ID = id.NewRandom()
|
radio.ID = id.NewRandom()
|
||||||
}
|
}
|
||||||
m.data[radio.ID] = radio
|
m.Data[radio.ID] = radio
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
type MockedScrobbleBufferRepo struct {
|
type MockedScrobbleBufferRepo struct {
|
||||||
Error error
|
Error error
|
||||||
data model.ScrobbleEntries
|
Data model.ScrobbleEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo {
|
func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo {
|
||||||
@ -20,7 +20,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) {
|
|||||||
return nil, m.Error
|
return nil, m.Error
|
||||||
}
|
}
|
||||||
userIds := make(map[string]struct{})
|
userIds := make(map[string]struct{})
|
||||||
for _, e := range m.data {
|
for _, e := range m.Data {
|
||||||
if e.Service == service {
|
if e.Service == service {
|
||||||
userIds[e.UserID] = struct{}{}
|
userIds[e.UserID] = struct{}{}
|
||||||
}
|
}
|
||||||
@ -36,7 +36,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string,
|
|||||||
if m.Error != nil {
|
if m.Error != nil {
|
||||||
return m.Error
|
return m.Error
|
||||||
}
|
}
|
||||||
m.data = append(m.data, model.ScrobbleEntry{
|
m.Data = append(m.Data, model.ScrobbleEntry{
|
||||||
MediaFile: model.MediaFile{ID: mediaFileId},
|
MediaFile: model.MediaFile{ID: mediaFileId},
|
||||||
Service: service,
|
Service: service,
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
@ -50,7 +50,7 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble
|
|||||||
if m.Error != nil {
|
if m.Error != nil {
|
||||||
return nil, m.Error
|
return nil, m.Error
|
||||||
}
|
}
|
||||||
for _, e := range m.data {
|
for _, e := range m.Data {
|
||||||
if e.Service == service && e.UserID == userId {
|
if e.Service == service && e.UserID == userId {
|
||||||
return &e, nil
|
return &e, nil
|
||||||
}
|
}
|
||||||
@ -63,13 +63,13 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error {
|
|||||||
return m.Error
|
return m.Error
|
||||||
}
|
}
|
||||||
newData := model.ScrobbleEntries{}
|
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 {
|
if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newData = append(newData, e)
|
newData = append(newData, e)
|
||||||
}
|
}
|
||||||
m.data = newData
|
m.Data = newData
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,5 +77,5 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) {
|
|||||||
if m.Error != nil {
|
if m.Error != nil {
|
||||||
return 0, m.Error
|
return 0, m.Error
|
||||||
}
|
}
|
||||||
return int64(len(m.data)), nil
|
return int64(len(m.Data)), nil
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model"
|
|||||||
type MockedUserPropsRepo struct {
|
type MockedUserPropsRepo struct {
|
||||||
model.UserPropsRepository
|
model.UserPropsRepository
|
||||||
Error error
|
Error error
|
||||||
data map[string]string
|
Data map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MockedUserPropsRepo) init() {
|
func (p *MockedUserPropsRepo) init() {
|
||||||
if p.data == nil {
|
if p.Data == nil {
|
||||||
p.data = make(map[string]string)
|
p.Data = make(map[string]string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ func (p *MockedUserPropsRepo) Put(userId, key string, value string) error {
|
|||||||
return p.Error
|
return p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
p.data[userId+key] = value
|
p.Data[userId+key] = value
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +28,7 @@ func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) {
|
|||||||
return "", p.Error
|
return "", p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
if v, ok := p.data[userId+key]; ok {
|
if v, ok := p.Data[userId+key]; ok {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
return "", model.ErrNotFound
|
return "", model.ErrNotFound
|
||||||
@ -39,8 +39,8 @@ func (p *MockedUserPropsRepo) Delete(userId, key string) error {
|
|||||||
return p.Error
|
return p.Error
|
||||||
}
|
}
|
||||||
p.init()
|
p.init()
|
||||||
if _, ok := p.data[userId+key]; ok {
|
if _, ok := p.Data[userId+key]; ok {
|
||||||
delete(p.data, userId+key)
|
delete(p.Data, userId+key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return model.ErrNotFound
|
return model.ErrNotFound
|
||||||
|
19
ui/src/album/AlbumDatesField.jsx
Normal file
19
ui/src/album/AlbumDatesField.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useRecordContext } from 'react-admin'
|
||||||
|
import { formatRange } from '../common/index.js'
|
||||||
|
|
||||||
|
const originalYearSymbol = '♫'
|
||||||
|
const releaseYearSymbol = '○'
|
||||||
|
|
||||||
|
export const AlbumDatesField = ({ className, ...rest }) => {
|
||||||
|
const record = useRecordContext(rest)
|
||||||
|
const releaseDate = record.releaseDate
|
||||||
|
const releaseYear = releaseDate?.toString().substring(0, 4)
|
||||||
|
const yearRange =
|
||||||
|
formatRange(record, 'originalYear') || record['maxYear']?.toString()
|
||||||
|
let label = yearRange
|
||||||
|
|
||||||
|
if (releaseYear !== undefined && yearRange !== releaseYear) {
|
||||||
|
label = `${originalYearSymbol} ${yearRange} · ${releaseYearSymbol} ${releaseYear}`
|
||||||
|
}
|
||||||
|
return <span className={className}>{label}</span>
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -10,25 +10,25 @@ import {
|
|||||||
withWidth,
|
withWidth,
|
||||||
} from '@material-ui/core'
|
} from '@material-ui/core'
|
||||||
import {
|
import {
|
||||||
useRecordContext,
|
|
||||||
useTranslate,
|
|
||||||
ArrayField,
|
ArrayField,
|
||||||
SingleFieldList,
|
|
||||||
ChipField,
|
ChipField,
|
||||||
Link,
|
Link,
|
||||||
|
SingleFieldList,
|
||||||
|
useRecordContext,
|
||||||
|
useTranslate,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import Lightbox from 'react-image-lightbox'
|
import Lightbox from 'react-image-lightbox'
|
||||||
import 'react-image-lightbox/style.css'
|
import 'react-image-lightbox/style.css'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import {
|
import {
|
||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
|
CollapsibleComment,
|
||||||
DurationField,
|
DurationField,
|
||||||
formatRange,
|
formatRange,
|
||||||
SizeField,
|
|
||||||
LoveButton,
|
LoveButton,
|
||||||
RatingField,
|
RatingField,
|
||||||
|
SizeField,
|
||||||
useAlbumsPerPage,
|
useAlbumsPerPage,
|
||||||
CollapsibleComment,
|
|
||||||
} from '../common'
|
} from '../common'
|
||||||
import config from '../config'
|
import config from '../config'
|
||||||
import { formatFullDate, intersperse } from '../utils'
|
import { formatFullDate, intersperse } from '../utils'
|
||||||
@ -140,69 +140,55 @@ const GenreList = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Details = (props) => {
|
export const Details = (props) => {
|
||||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const record = useRecordContext(props)
|
const record = useRecordContext(props)
|
||||||
|
|
||||||
|
// Create an array of detail elements
|
||||||
let details = []
|
let details = []
|
||||||
const addDetail = (obj) => {
|
const addDetail = (obj) => {
|
||||||
const id = details.length
|
const id = details.length
|
||||||
details.push(<span key={`detail-${record.id}-${id}`}>{obj}</span>)
|
details.push(<span key={`detail-${record.id}-${id}`}>{obj}</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalYearRange = formatRange(record, 'originalYear')
|
// Calculate date related fields
|
||||||
const originalDate = record.originalDate
|
|
||||||
? formatFullDate(record.originalDate)
|
|
||||||
: originalYearRange
|
|
||||||
const yearRange = formatRange(record, 'year')
|
const yearRange = formatRange(record, 'year')
|
||||||
const date = record.date ? formatFullDate(record.date) : yearRange
|
const date = record.date ? formatFullDate(record.date) : yearRange
|
||||||
const releaseDate = record.releaseDate
|
|
||||||
? formatFullDate(record.releaseDate)
|
|
||||||
: date
|
|
||||||
|
|
||||||
const showReleaseDate = date !== releaseDate && releaseDate.length > 3
|
const originalDate = record.originalDate
|
||||||
const showOriginalDate =
|
? formatFullDate(record.originalDate)
|
||||||
date !== originalDate &&
|
: formatRange(record, 'originalYear')
|
||||||
originalDate !== releaseDate &&
|
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
|
||||||
originalDate.length > 3
|
|
||||||
|
|
||||||
showOriginalDate &&
|
const dateToUse = originalDate || date
|
||||||
!isXsmall &&
|
const isOriginalDate = originalDate && dateToUse !== date
|
||||||
|
const showDate = dateToUse && dateToUse !== releaseDate
|
||||||
|
|
||||||
|
// Get label for the main date display
|
||||||
|
const getDateLabel = () => {
|
||||||
|
if (isXsmall) return '♫'
|
||||||
|
if (isOriginalDate) return translate('resources.album.fields.originalDate')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get label for release date display
|
||||||
|
const getReleaseDateLabel = () => {
|
||||||
|
if (!isXsmall) return translate('resources.album.fields.releaseDate')
|
||||||
|
if (showDate) return '○'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display dates with appropriate labels
|
||||||
|
if (showDate) {
|
||||||
|
addDetail(<>{[getDateLabel(), dateToUse].filter(Boolean).join(' ')}</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseDate) {
|
||||||
addDetail(
|
addDetail(
|
||||||
<>
|
<>{[getReleaseDateLabel(), releaseDate].filter(Boolean).join(' ')}</>,
|
||||||
{[translate('resources.album.fields.originalDate'), originalDate].join(
|
|
||||||
' ',
|
|
||||||
)}
|
|
||||||
</>,
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
yearRange && addDetail(<>{['♫', !isXsmall ? date : yearRange].join(' ')}</>)
|
|
||||||
|
|
||||||
showReleaseDate &&
|
|
||||||
addDetail(
|
|
||||||
<>
|
|
||||||
{(!isXsmall
|
|
||||||
? [translate('resources.album.fields.releaseDate'), releaseDate]
|
|
||||||
: ['○', record.releaseDate.substring(0, 4)]
|
|
||||||
).join(' ')}
|
|
||||||
</>,
|
|
||||||
)
|
|
||||||
|
|
||||||
const showReleases = record.releases > 1
|
|
||||||
showReleases &&
|
|
||||||
addDetail(
|
|
||||||
<>
|
|
||||||
{!isXsmall
|
|
||||||
? [
|
|
||||||
record.releases,
|
|
||||||
translate('resources.album.fields.releases', {
|
|
||||||
smart_count: record.releases,
|
|
||||||
}),
|
|
||||||
].join(' ')
|
|
||||||
: ['(', record.releases, ')))'].join(' ')}
|
|
||||||
</>,
|
|
||||||
)
|
|
||||||
|
|
||||||
addDetail(
|
addDetail(
|
||||||
<>
|
<>
|
||||||
{record.songCount +
|
{record.songCount +
|
||||||
@ -215,6 +201,7 @@ const Details = (props) => {
|
|||||||
!isXsmall && addDetail(<DurationField source={'duration'} />)
|
!isXsmall && addDetail(<DurationField source={'duration'} />)
|
||||||
!isXsmall && addDetail(<SizeField source="size" />)
|
!isXsmall && addDetail(<SizeField source="size" />)
|
||||||
|
|
||||||
|
// Return the details rendered with separators
|
||||||
return <>{intersperse(details, ' · ')}</>
|
return <>{intersperse(details, ' · ')}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
327
ui/src/album/AlbumDetails.test.jsx
Normal file
327
ui/src/album/AlbumDetails.test.jsx
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
// ui/src/album/__tests__/AlbumDetails.test.jsx
|
||||||
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { RecordContextProvider } from 'react-admin'
|
||||||
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
|
import { Details } from './AlbumDetails'
|
||||||
|
|
||||||
|
// Mock useMediaQuery
|
||||||
|
vi.mock('@material-ui/core', async () => {
|
||||||
|
const actual = await import('@material-ui/core')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useMediaQuery: vi.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Details component', () => {
|
||||||
|
describe('Desktop view', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set desktop view (isXsmall = false)
|
||||||
|
vi.mocked(useMediaQuery).mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with just year range', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
year: 2020,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with date', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with originalDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with date and originalDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with releaseDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
releaseDate: '2020-06-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with all date fields', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
releaseDate: '2020-06-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Mobile view', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set mobile view (isXsmall = true)
|
||||||
|
vi.mocked(useMediaQuery).mockReturnValue(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with just year range', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
year: 2020,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with date', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with originalDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with date and originalDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with releaseDate', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
releaseDate: '2020-06-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with all date fields', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
date: '2020-05-01',
|
||||||
|
originalDate: '2018-03-15',
|
||||||
|
releaseDate: '2020-06-15',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with no date fields', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with year range (start and end years)', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
year: 2018,
|
||||||
|
yearEnd: 2020,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('renders correctly with originalYear range', () => {
|
||||||
|
const record = {
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Album',
|
||||||
|
songCount: 12,
|
||||||
|
duration: 3600,
|
||||||
|
size: 102400,
|
||||||
|
originalYear: 2015,
|
||||||
|
originalYearEnd: 2016,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<RecordContextProvider value={record}>
|
||||||
|
<Details />
|
||||||
|
</RecordContextProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -13,14 +13,10 @@ import { linkToRecord, useListContext, Loading } from 'react-admin'
|
|||||||
import { withContentRect } from 'react-measure'
|
import { withContentRect } from 'react-measure'
|
||||||
import { useDrag } from 'react-dnd'
|
import { useDrag } from 'react-dnd'
|
||||||
import subsonic from '../subsonic'
|
import subsonic from '../subsonic'
|
||||||
import {
|
import { AlbumContextMenu, PlayButton, ArtistLinkField } from '../common'
|
||||||
AlbumContextMenu,
|
|
||||||
PlayButton,
|
|
||||||
ArtistLinkField,
|
|
||||||
RangeDoubleField,
|
|
||||||
} from '../common'
|
|
||||||
import { DraggableTypes } from '../consts'
|
import { DraggableTypes } from '../consts'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import { AlbumDatesField } from './AlbumDatesField.jsx'
|
||||||
|
|
||||||
const useStyles = makeStyles(
|
const useStyles = makeStyles(
|
||||||
(theme) => ({
|
(theme) => ({
|
||||||
@ -187,16 +183,7 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => {
|
|||||||
{showArtist ? (
|
{showArtist ? (
|
||||||
<ArtistLinkField record={record} className={classes.albumSubtitle} />
|
<ArtistLinkField record={record} className={classes.albumSubtitle} />
|
||||||
) : (
|
) : (
|
||||||
<RangeDoubleField
|
<AlbumDatesField record={record} className={classes.albumSubtitle} />
|
||||||
record={record}
|
|
||||||
source={'year'}
|
|
||||||
symbol1={'♫'}
|
|
||||||
symbol2={'○'}
|
|
||||||
separator={' · '}
|
|
||||||
sortBy={'max_year'}
|
|
||||||
sortByOrder={'DESC'}
|
|
||||||
className={classes.albumSubtitle}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
ArtistLinkField,
|
ArtistLinkField,
|
||||||
MultiLineTextField,
|
MultiLineTextField,
|
||||||
ParticipantsInfo,
|
ParticipantsInfo,
|
||||||
|
RangeField,
|
||||||
} from '../common'
|
} from '../common'
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
@ -47,6 +48,20 @@ const AlbumInfo = (props) => {
|
|||||||
</SingleFieldList>
|
</SingleFieldList>
|
||||||
</ArrayField>
|
</ArrayField>
|
||||||
),
|
),
|
||||||
|
date:
|
||||||
|
record?.maxYear && record.maxYear === record.minYear ? (
|
||||||
|
<TextField source={'date'} />
|
||||||
|
) : (
|
||||||
|
<RangeField source={'year'} />
|
||||||
|
),
|
||||||
|
originalDate:
|
||||||
|
record?.maxOriginalYear &&
|
||||||
|
record.maxOriginalYear === record.minOriginalYear ? (
|
||||||
|
<TextField source={'originalDate'} />
|
||||||
|
) : (
|
||||||
|
<RangeField source={'originalYear'} />
|
||||||
|
),
|
||||||
|
releaseDate: <TextField source={'releaseDate'} />,
|
||||||
recordLabel: (
|
recordLabel: (
|
||||||
<FunctionField
|
<FunctionField
|
||||||
source={'recordLabel'}
|
source={'recordLabel'}
|
||||||
|
@ -196,6 +196,7 @@ const AlbumList = (props) => {
|
|||||||
'songCount',
|
'songCount',
|
||||||
'playCount',
|
'playCount',
|
||||||
'year',
|
'year',
|
||||||
|
'mood',
|
||||||
'duration',
|
'duration',
|
||||||
'rating',
|
'rating',
|
||||||
'size',
|
'size',
|
||||||
|
@ -124,6 +124,14 @@ const AlbumSongs = (props) => {
|
|||||||
size: isDesktop && <SizeField source="size" sortable={false} />,
|
size: isDesktop && <SizeField source="size" sortable={false} />,
|
||||||
channels: isDesktop && <NumberField source="channels" sortable={false} />,
|
channels: isDesktop && <NumberField source="channels" sortable={false} />,
|
||||||
bpm: isDesktop && <NumberField source="bpm" 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 && (
|
rating: isDesktop && config.enableStarRating && (
|
||||||
<RatingField
|
<RatingField
|
||||||
resource={'song'}
|
resource={'song'}
|
||||||
@ -139,7 +147,16 @@ const AlbumSongs = (props) => {
|
|||||||
resource: 'albumSong',
|
resource: 'albumSong',
|
||||||
columns: toggleableFields,
|
columns: toggleableFields,
|
||||||
omittedColumns: ['title'],
|
omittedColumns: ['title'],
|
||||||
defaultOff: ['channels', 'bpm', 'year', 'playCount', 'playDate', 'size'],
|
defaultOff: [
|
||||||
|
'channels',
|
||||||
|
'bpm',
|
||||||
|
'year',
|
||||||
|
'playCount',
|
||||||
|
'playDate',
|
||||||
|
'size',
|
||||||
|
'mood',
|
||||||
|
'genre',
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const bulkActionsLabel = isDesktop
|
const bulkActionsLabel = isDesktop
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
DateField,
|
DateField,
|
||||||
NumberField,
|
NumberField,
|
||||||
TextField,
|
TextField,
|
||||||
|
FunctionField,
|
||||||
} from 'react-admin'
|
} from 'react-admin'
|
||||||
import { useMediaQuery } from '@material-ui/core'
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
|
||||||
@ -107,6 +108,13 @@ const AlbumTableView = ({
|
|||||||
year: (
|
year: (
|
||||||
<RangeField source={'year'} sortBy={'max_year'} sortByOrder={'DESC'} />
|
<RangeField source={'year'} sortBy={'max_year'} sortByOrder={'DESC'} />
|
||||||
),
|
),
|
||||||
|
mood: isDesktop && (
|
||||||
|
<FunctionField
|
||||||
|
source="mood"
|
||||||
|
render={(r) => r.tags?.mood?.[0] || ''}
|
||||||
|
sortable={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
duration: isDesktop && <DurationField source="duration" />,
|
duration: isDesktop && <DurationField source="duration" />,
|
||||||
size: isDesktop && <SizeField source="size" />,
|
size: isDesktop && <SizeField source="size" />,
|
||||||
rating: config.enableStarRating && (
|
rating: config.enableStarRating && (
|
||||||
@ -124,7 +132,7 @@ const AlbumTableView = ({
|
|||||||
const columns = useSelectedFields({
|
const columns = useSelectedFields({
|
||||||
resource: 'album',
|
resource: 'album',
|
||||||
columns: toggleableFields,
|
columns: toggleableFields,
|
||||||
defaultOff: ['createdAt'],
|
defaultOff: ['createdAt', 'size', 'mood'],
|
||||||
})
|
})
|
||||||
|
|
||||||
return isXsmall ? (
|
return isXsmall ? (
|
||||||
|
425
ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap
Normal file
425
ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with all date fields 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
resources.album.fields.releaseDate Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-6"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with date 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
May 1, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-2"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with date and originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-4"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with just year range 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-1"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-3"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Desktop view > renders correctly with releaseDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.releaseDate Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-5"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with all date fields 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
♫ Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
○ Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with date 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
♫ May 1, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with date and originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
♫ Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with just year range 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with no date fields 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
♫ Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with originalYear range 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with releaseDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > Mobile view > renders correctly with year range (start and end years) 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly in mobile view 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
♫ Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
○ Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with all date fields 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
resources.album.fields.releaseDate Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-6"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with date 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
May 1, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-2"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with date and originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-4"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with just year range 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-1"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with originalDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.originalDate Mar 15, 2018
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-3"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Details component > renders correctly with releaseDate 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
resources.album.fields.releaseDate Jun 15, 2020
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
12 resources.song.name
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span>
|
||||||
|
01:00:00
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
·
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="makeStyles-root-5"
|
||||||
|
>
|
||||||
|
100 KB
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
@ -50,7 +50,7 @@ const ArtistDetails = (props) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AlbumShowLayout = (props) => {
|
const ArtistShowLayout = (props) => {
|
||||||
const showContext = useShowContext(props)
|
const showContext = useShowContext(props)
|
||||||
const record = useRecordContext()
|
const record = useRecordContext()
|
||||||
const { width } = props
|
const { width } = props
|
||||||
@ -98,7 +98,7 @@ const ArtistShow = withWidth()((props) => {
|
|||||||
const controllerProps = useShowController(props)
|
const controllerProps = useShowController(props)
|
||||||
return (
|
return (
|
||||||
<ShowContextProvider value={controllerProps}>
|
<ShowContextProvider value={controllerProps}>
|
||||||
<AlbumShowLayout {...controllerProps} />
|
<ArtistShowLayout {...controllerProps} />
|
||||||
</ShowContextProvider>
|
</ShowContextProvider>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import PropTypes from 'prop-types'
|
|
||||||
import { useRecordContext } from 'react-admin'
|
|
||||||
import { formatRange } from '../common'
|
|
||||||
|
|
||||||
export const RangeDoubleField = ({
|
|
||||||
className,
|
|
||||||
source,
|
|
||||||
symbol1,
|
|
||||||
symbol2,
|
|
||||||
separator,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
const record = useRecordContext(rest)
|
|
||||||
const yearRange = formatRange(record, source).toString()
|
|
||||||
const releases = [record.releases]
|
|
||||||
const releaseDate = [record.releaseDate]
|
|
||||||
const releaseYear = releaseDate.toString().substring(0, 4)
|
|
||||||
let subtitle = yearRange
|
|
||||||
|
|
||||||
if (releases > 1) {
|
|
||||||
subtitle = [
|
|
||||||
[yearRange && symbol1, yearRange].join(' '),
|
|
||||||
['(', releases, ')))'].join(' '),
|
|
||||||
].join(separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
yearRange !== releaseYear &&
|
|
||||||
yearRange.length > 0 &&
|
|
||||||
releaseYear.length > 0
|
|
||||||
) {
|
|
||||||
subtitle = [
|
|
||||||
[yearRange && symbol1, yearRange].join(' '),
|
|
||||||
[symbol2, releaseYear].join(' '),
|
|
||||||
].join(separator)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span className={className}>{subtitle}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
RangeDoubleField.propTypes = {
|
|
||||||
label: PropTypes.string,
|
|
||||||
record: PropTypes.object,
|
|
||||||
source: PropTypes.string.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
RangeDoubleField.defaultProps = {
|
|
||||||
addLabel: true,
|
|
||||||
}
|
|
@ -75,6 +75,7 @@ export const SongInfo = (props) => {
|
|||||||
compilation: <BooleanField source="compilation" />,
|
compilation: <BooleanField source="compilation" />,
|
||||||
bitRate: <BitrateField source="bitRate" />,
|
bitRate: <BitrateField source="bitRate" />,
|
||||||
bitDepth: <NumberField source="bitDepth" />,
|
bitDepth: <NumberField source="bitDepth" />,
|
||||||
|
sampleRate: <NumberField source="sampleRate" />,
|
||||||
channels: <NumberField source="channels" />,
|
channels: <NumberField source="channels" />,
|
||||||
size: <SizeField source="size" />,
|
size: <SizeField source="size" />,
|
||||||
updatedAt: <DateField source="updatedAt" showTime />,
|
updatedAt: <DateField source="updatedAt" showTime />,
|
||||||
@ -92,7 +93,14 @@ export const SongInfo = (props) => {
|
|||||||
roles.push([name, record.participants[name].length])
|
roles.push([name, record.participants[name].length])
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre', 'bitDepth']
|
const optionalFields = [
|
||||||
|
'discSubtitle',
|
||||||
|
'comment',
|
||||||
|
'bpm',
|
||||||
|
'genre',
|
||||||
|
'bitDepth',
|
||||||
|
'sampleRate',
|
||||||
|
]
|
||||||
optionalFields.forEach((field) => {
|
optionalFields.forEach((field) => {
|
||||||
!record[field] && delete data[field]
|
!record[field] && delete data[field]
|
||||||
})
|
})
|
||||||
|
@ -13,7 +13,6 @@ export * from './Pagination'
|
|||||||
export * from './PlayButton'
|
export * from './PlayButton'
|
||||||
export * from './QuickFilter'
|
export * from './QuickFilter'
|
||||||
export * from './RangeField'
|
export * from './RangeField'
|
||||||
export * from './RangeDoubleField'
|
|
||||||
export * from './ShuffleAllButton'
|
export * from './ShuffleAllButton'
|
||||||
export * from './SimpleList'
|
export * from './SimpleList'
|
||||||
export * from './SizeField'
|
export * from './SizeField'
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
"updatedAt": "Updated at",
|
"updatedAt": "Updated at",
|
||||||
"bitRate": "Bit rate",
|
"bitRate": "Bit rate",
|
||||||
"bitDepth": "Bit depth",
|
"bitDepth": "Bit depth",
|
||||||
|
"sampleRate": "Sample rate",
|
||||||
"channels": "Channels",
|
"channels": "Channels",
|
||||||
"discSubtitle": "Disc Subtitle",
|
"discSubtitle": "Disc Subtitle",
|
||||||
"starred": "Favourite",
|
"starred": "Favourite",
|
||||||
@ -58,6 +59,7 @@
|
|||||||
"genre": "Genre",
|
"genre": "Genre",
|
||||||
"compilation": "Compilation",
|
"compilation": "Compilation",
|
||||||
"year": "Year",
|
"year": "Year",
|
||||||
|
"date": "Recording Date",
|
||||||
"originalDate": "Original",
|
"originalDate": "Original",
|
||||||
"releaseDate": "Released",
|
"releaseDate": "Released",
|
||||||
"releases": "Release |||| Releases",
|
"releases": "Release |||| Releases",
|
||||||
|
@ -168,6 +168,13 @@ const SongList = (props) => {
|
|||||||
),
|
),
|
||||||
bpm: isDesktop && <NumberField source="bpm" />,
|
bpm: isDesktop && <NumberField source="bpm" />,
|
||||||
genre: <TextField source="genre" />,
|
genre: <TextField source="genre" />,
|
||||||
|
mood: isDesktop && (
|
||||||
|
<FunctionField
|
||||||
|
source="mood"
|
||||||
|
render={(r) => r.tags?.mood?.[0] || ''}
|
||||||
|
sortable={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
comment: <TextField source="comment" />,
|
comment: <TextField source="comment" />,
|
||||||
path: <PathField source="path" />,
|
path: <PathField source="path" />,
|
||||||
createdAt: <DateField source="createdAt" showTime />,
|
createdAt: <DateField source="createdAt" showTime />,
|
||||||
@ -183,6 +190,7 @@ const SongList = (props) => {
|
|||||||
'playDate',
|
'playDate',
|
||||||
'albumArtist',
|
'albumArtist',
|
||||||
'genre',
|
'genre',
|
||||||
|
'mood',
|
||||||
'comment',
|
'comment',
|
||||||
'path',
|
'path',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user