diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 0ead4dd44..c20e8e0cd 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -71,7 +71,7 @@ jobs: version: ${{ env.CROSS_TAGLIB_VERSION }} - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v7 with: version: latest problem-matchers: true diff --git a/.golangci.yml b/.golangci.yml index 5aaa3abf1..996dafccb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,7 +1,7 @@ +version: "2" run: build-tags: - netgo - linters: enable: - asasalint @@ -11,42 +11,48 @@ linters: - copyloopvar - dogsled - durationcheck - - errcheck - errorlint - - gocyclo - gocritic + - gocyclo - goprintffuncname - gosec - - gosimple - - govet - - ineffassign - misspell - nakedret - nilerr - rowserrcheck - - staticcheck - - typecheck - unconvert - - unused - whitespace - -issues: - exclude-rules: - - path: scanner2 - linters: - - unused - -linters-settings: - gocritic: - disable-all: true - enabled-checks: - - deprecatedComment - govet: - enable: - - nilness - gosec: - excludes: - - G501 - - G401 - - G505 - - G115 # Can't check context, where the warning is clearly a false positive. See discussion in https://github.com/securego/gosec/pull/1149 + disable: + - staticcheck + settings: + gocritic: + disable-all: true + enabled-checks: + - deprecatedComment + gosec: + excludes: + - G501 + - G401 + - G505 + - G115 + govet: + enable: + - nilness + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/Makefile b/Makefile index 6c4232623..adc4279ee 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ testall: testrace ##@Development Run Go and JS tests .PHONY: testall lint: ##@Development Lint Go code - go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run -v --timeout 5m .PHONY: lint lintall: lint ##@Development Lint Go and JS code diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4f6b1e52d..3ccd9b67a 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/agents/lastfm" "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" @@ -82,8 +83,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore) - externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := core.GetTranscodingCache() mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) @@ -96,7 +97,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker) playbackServer := playback.GetInstance(dataStore) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scannerScanner, broker, playlists, playTracker, share, playbackServer) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer) return router } @@ -106,8 +107,8 @@ func CreatePublicRouter() *public.Router { fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore) - externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) transcodingCache := core.GetTranscodingCache() mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) @@ -150,8 +151,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner { fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore) - externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) @@ -166,8 +167,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() agentsAgents := agents.GetAgents(dataStore) - externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() playlists := core.NewPlaylists(dataStore) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 3570dd7b4..f1f571e0d 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -8,7 +8,7 @@ import ( "time" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,15 +24,15 @@ type Artwork interface { GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) } -func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork { - return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em} +func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} } type artwork struct { - ds model.DataStore - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - em core.ExternalMetadata + ds model.DataStore + cache cache.FileCache + ffmpeg ffmpeg.FFmpeg + provider external.Provider } type artworkReader interface { @@ -115,9 +115,9 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s } else { switch artID.Kind { case model.KindArtistArtwork: - artReader, err = newArtistReader(ctx, a, artID, a.em) + artReader, err = newArtistReader(ctx, a, artID, a.provider) case model.KindAlbumArtwork: - artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em) + artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) case model.KindMediaFileArtwork: artReader, err = newMediafileArtworkReader(ctx, a, artID) case model.KindPlaylistArtwork: diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index f1ed9b63c..55d8b4352 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -6,12 +6,14 @@ import ( "fmt" "io" "path/filepath" + "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/model" ) @@ -19,14 +21,14 @@ import ( type albumArtworkReader struct { cacheKey a *artwork - em core.ExternalMetadata + provider external.Provider album model.Album updatedAt *time.Time imgFiles []string rootFolder string } -func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) { +func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { al, err := artwork.ds.Album(ctx).Get(artID.ID) if err != nil { return nil, err @@ -37,7 +39,7 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar } a := &albumArtworkReader{ a: artwork, - em: em, + provider: provider, album: *al, updatedAt: imagesUpdateAt, imgFiles: imgFiles, @@ -82,7 +84,7 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) case pattern == "external": - ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em)) + ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) case len(a.imgFiles) > 0: ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) } @@ -112,5 +114,10 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo imgFiles = append(imgFiles, filepath.Join(path, img)) } } + + // Sort image files to ensure consistent selection of cover art + // This prioritizes files from lower-numbered disc folders by sorting the paths + slices.Sort(imgFiles) + return paths, imgFiles, &updatedAt, nil } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go new file mode 100644 index 000000000..2665632b9 --- /dev/null +++ b/core/artwork/reader_album_test.go @@ -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"))) + }) + }) +}) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index e910ef93e..217044b7a 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -14,6 +14,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/str" @@ -22,13 +23,13 @@ import ( type artistReader struct { cacheKey a *artwork - em core.ExternalMetadata + provider external.Provider artist model.Artist artistFolder string imgFiles []string } -func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) { +func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { ar, err := artwork.ds.Artist(ctx).Get(artID.ID) if err != nil { return nil, err @@ -53,7 +54,7 @@ func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkI } a := &artistReader{ a: artwork, - em: em, + provider: provider, artist: *ar, artistFolder: artistFolder, imgFiles: imgFiles, @@ -95,7 +96,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin pattern = strings.TrimSpace(pattern) switch { case pattern == "external": - ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em)) + ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) case strings.HasPrefix(pattern, "album/"): ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) default: diff --git a/core/artwork/sources.go b/core/artwork/sources.go index f89708255..121e6c38b 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -17,7 +17,7 @@ import ( "github.com/dhowden/tag" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -157,9 +157,9 @@ func fromAlbumPlaceholder() sourceFunc { return r, consts.PlaceholderAlbumArt, nil } } -func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc { +func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { return func() (io.ReadCloser, string, error) { - imageUrl, err := em.ArtistImage(ctx, ar.ID) + imageUrl, err := provider.ArtistImage(ctx, ar.ID) if err != nil { return nil, "", err } @@ -168,9 +168,9 @@ func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.Exte } } -func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc { +func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { return func() (io.ReadCloser, string, error) { - imageUrl, err := em.AlbumImage(ctx, al.ID) + imageUrl, err := provider.AlbumImage(ctx, al.ID) if err != nil { return nil, "", err } diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go new file mode 100644 index 000000000..367437815 --- /dev/null +++ b/core/external/extdata_helper_test.go @@ -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) +} diff --git a/core/external/extdata_suite_test.go b/core/external/extdata_suite_test.go new file mode 100644 index 000000000..f059e76b3 --- /dev/null +++ b/core/external/extdata_suite_test.go @@ -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") +} diff --git a/core/external_metadata.go b/core/external/provider.go similarity index 79% rename from core/external_metadata.go rename to core/external/provider.go index d402c3a36..f27ded11b 100644 --- a/core/external_metadata.go +++ b/core/external/provider.go @@ -1,4 +1,4 @@ -package core +package external import ( "context" @@ -31,7 +31,7 @@ const ( refreshQueueLength = 2000 ) -type ExternalMetadata interface { +type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) @@ -40,9 +40,9 @@ type ExternalMetadata interface { AlbumImage(ctx context.Context, id string) (*url.URL, error) } -type externalMetadata struct { +type provider struct { ds model.DataStore - ag *agents.Agents + ag Agents artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -57,14 +57,24 @@ type auxArtist struct { Name string } -func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata { - e := &externalMetadata{ds: ds, ag: agents} +type Agents interface { + agents.AlbumInfoRetriever + agents.ArtistBiographyRetriever + agents.ArtistMBIDRetriever + agents.ArtistImageRetriever + agents.ArtistSimilarRetriever + agents.ArtistTopSongsRetriever + agents.ArtistURLRetriever +} + +func NewProvider(ds model.DataStore, agents Agents) Provider { + e := &provider{ds: ds, ag: agents} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e } -func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, error) { +func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { var entity interface{} entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { @@ -81,10 +91,11 @@ func (e *externalMetadata) getAlbum(ctx context.Context, id string) (auxAlbum, e default: return auxAlbum{}, model.ErrNotFound } + return album, nil } -func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) { +func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) { album, err := e.getAlbum(ctx, id) if err != nil { log.Info(ctx, "Not found", "id", id) @@ -109,7 +120,7 @@ func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*mod return &album.Album, nil } -func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) { +func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) { start := time.Now() info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) if errors.Is(err, agents.ErrNotFound) { @@ -155,7 +166,7 @@ func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album auxAlbum return album, nil } -func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, error) { +func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) { var entity interface{} entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { @@ -177,7 +188,7 @@ func (e *externalMetadata) getArtist(ctx context.Context, id string) (auxArtist, return artist, nil } -func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) { +func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) { artist, err := e.refreshArtistInfo(ctx, id) if err != nil { return nil, err @@ -187,7 +198,7 @@ func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, simi return &artist.Artist, err } -func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) { +func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) { artist, err := e.getArtist(ctx, id) if err != nil { return auxArtist{}, err @@ -211,7 +222,7 @@ func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (au return artist, nil } -func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) { +func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) { start := time.Now() // Get MBID first, if it is not yet available if artist.MbzArtistID == "" { @@ -246,7 +257,7 @@ func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist auxArt return artist, nil } -func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { +func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { artist, err := e.getArtist(ctx, id) if err != nil { return nil, err @@ -304,7 +315,7 @@ func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count in return similarSongs, nil } -func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) { +func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { artist, err := e.getArtist(ctx, id) if err != nil { return nil, err @@ -318,24 +329,35 @@ func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL imageUrl := artist.ArtistImageUrl() if imageUrl == "" { - return nil, agents.ErrNotFound + return nil, model.ErrNotFound } return url.Parse(imageUrl) } -func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) { +func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { album, err := e.getAlbum(ctx, id) if err != nil { return nil, err } info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) - if errors.Is(err, agents.ErrNotFound) { + if err != nil { + switch { + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) + return nil, model.ErrNotFound + case errors.Is(err, context.Canceled): + log.Debug(ctx, "GetAlbumInfo call canceled", err) + default: + log.Warn(ctx, "Error getting album info from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err) + } + return nil, err } - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "AlbumImage call canceled", ctx.Err()) - return nil, ctx.Err() + + if info == nil { + log.Warn(ctx, "Agent returned nil info without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist) + return nil, model.ErrNotFound } // Return the biggest image @@ -346,26 +368,37 @@ func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, } } if img.URL == "" { - return nil, agents.ErrNotFound + return nil, model.ErrNotFound } return url.Parse(img.URL) } -func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { +func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { artist, err := e.findArtistByName(ctx, artistName) if err != nil { log.Error(ctx, "Artist not found", "name", artistName, err) return nil, nil } - return e.getMatchingTopSongs(ctx, e.ag, artist, count) + songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) + if err != nil { + switch { + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "TopSongs not found", "name", artistName) + return nil, model.ErrNotFound + case errors.Is(err, context.Canceled): + log.Debug(ctx, "TopSongs call canceled", err) + default: + log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err) + } + + return nil, err + } + return songs, nil } -func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { +func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count) - if errors.Is(err, agents.ErrNotFound) { - return nil, nil - } if err != nil { return nil, err } @@ -386,10 +419,11 @@ func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents } else { log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs)) } + return mfs, nil } -func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) { +func (e *provider) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) { if mbid != "" { mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ @@ -420,7 +454,7 @@ func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, a return &mfs[0], nil } -func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID) if err != nil { return @@ -428,7 +462,7 @@ func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistUR artist.ExternalUrl = artisURL } -func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID) if err != nil { return @@ -438,7 +472,7 @@ func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.Ar artist.Biography = strings.ReplaceAll(bio, " github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d @@ -25,8 +25,8 @@ require ( github.com/fatih/structs v1.1.0 github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 - github.com/go-chi/httprate v0.14.1 - github.com/go-chi/jwtauth/v5 v5.3.2 + github.com/go-chi/httprate v0.15.0 + github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-viper/encoding/ini v0.1.1 github.com/gohugoio/hashstructure v0.5.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc @@ -38,32 +38,32 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.4 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.24 + github.com/mattn/go-sqlite3 v1.14.27 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.23.0 - github.com/onsi/gomega v1.36.2 + github.com/onsi/ginkgo/v2 v2.23.4 + github.com/onsi/gomega v1.37.0 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/pressly/goose/v3 v3.24.1 + github.com/pressly/goose/v3 v3.24.2 github.com/prometheus/client_golang v1.21.1 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.0 + github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 go.uber.org/goleak v1.3.0 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 - golang.org/x/image v0.25.0 - golang.org/x/net v0.37.0 - golang.org/x/sync v0.12.0 - golang.org/x/sys v0.31.0 - golang.org/x/text v0.23.0 + golang.org/x/image v0.26.0 + golang.org/x/net v0.38.0 + golang.org/x/sync v0.13.0 + golang.org/x/sys v0.32.0 + golang.org/x/text v0.24.0 golang.org/x/time v0.11.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -78,19 +78,20 @@ require ( github.com/creack/pty v1.1.11 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -105,22 +106,24 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/prometheus/procfs v0.16.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/tools v0.31.0 // indirect - google.golang.org/protobuf v1.36.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 7bf8a4926..1e408c68a 100644 --- a/go.sum +++ b/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= -github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= -github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= -github.com/go-chi/jwtauth/v5 v5.3.2 h1:s+ON3ATyyMs3Me0kqyuua6Rwu+2zqIIkL0GCaMarwvs= -github.com/go-chi/jwtauth/v5 v5.3.2/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= +github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= +github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= @@ -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-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro= -github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -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/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= @@ -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/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= +github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -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/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ= -github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/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/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= -github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU= +github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k= github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk= github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM= +github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -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/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= -github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= -github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= +github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= @@ -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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= -github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= -github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -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/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= -golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= +golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= +golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= -modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8= +modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA= +modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws= diff --git a/model/mediafile.go b/model/mediafile.go index 896442436..354c5ea47 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -183,6 +183,8 @@ func (mfs MediaFiles) ToAlbum() Album { tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true + embedArtPath := "" + embedArtDisc := 0 for _, m := range mfs { // We assume these attributes are all the same for all songs in an album a.ID = m.AlbumID @@ -211,15 +213,15 @@ func (mfs MediaFiles) ToAlbum() Album { comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) - if m.HasCoverArt && a.EmbedArtPath == "" { - a.EmbedArtPath = m.Path - } if m.DiscNumber > 0 { a.Discs.Add(m.DiscNumber, m.DiscSubtitle) } tags = append(tags, m.Tags.FlattenAll()...) a.Participants.Merge(m.Participants) + // Find the MediaFile with cover art and the lowest disc number to use for album cover + embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m) + if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" { a.ExplicitStatus = "c" } else if m.ExplicitStatus == "e" { @@ -231,6 +233,7 @@ func (mfs MediaFiles) ToAlbum() Album { a.Missing = a.Missing && m.Missing } + a.EmbedArtPath = embedArtPath a.SetTags(tags) a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID })) a.Date, _ = allOrNothing(dates) @@ -305,6 +308,28 @@ func fixAlbumArtist(a *Album) { } } +// firstArtPath determines which media file path should be used for album artwork +// based on disc number (preferring lower disc numbers) and path (for consistency) +func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) { + if !m.HasCoverArt { + return currentPath, currentDisc + } + + // If current has no disc number (currentDisc == 0) or new file has lower disc number + if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) { + return m.Path, m.DiscNumber + } + + // If disc numbers are equal, use path for ordering + if m.DiscNumber == currentDisc { + if m.Path < currentPath || currentPath == "" { + return m.Path, m.DiscNumber + } + } + + return currentPath, currentDisc +} + type MediaFileCursor iter.Seq2[MediaFile, error] type MediaFileRepository interface { diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 74f5e5264..7f583cf75 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -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")) + }) + }) + }) }) }) }) diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go index 91ae44b89..25025ea19 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -51,20 +51,6 @@ func legacyMapAlbumName(md Metadata) string { // Keep the TaggedLikePicard logic for backwards compatibility func legacyReleaseDate(md Metadata) string { - // Start with defaults - 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) - } + _, _, releaseDate := md.mapDates() return string(releaseDate) } diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go new file mode 100644 index 000000000..b6d096763 --- /dev/null +++ b/model/metadata/legacy_ids_test.go @@ -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"), + ) +}) diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index 47d2578ec..b4857df85 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -1,6 +1,7 @@ package metadata import ( + "cmp" "encoding/json" "maps" "math" @@ -39,11 +40,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.ExplicitStatus = md.mapExplicitStatusTag() // Dates - origDate := md.Date(model.TagOriginalDate) + date, origDate, relDate := md.mapDates() mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate) - relDate := md.Date(model.TagReleaseDate) mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate) - date := md.Date(model.TagRecordingDate) mf.Year, mf.Date = date.Year(), string(date) // MBIDs @@ -51,6 +50,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID) mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID) mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID) + mf.MbzAlbumType = md.String(model.TagReleaseType) // ReplayGain mf.RGAlbumPeak = md.Float(model.TagReplayGainAlbumPeak, 1) @@ -164,3 +164,22 @@ func (md Metadata) mapExplicitStatusTag() string { 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 +} diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index 7e11b1541..ddda39bc2 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -35,7 +35,7 @@ var _ = Describe("ToMediaFile", func() { } Describe("Dates", func() { - It("should parse the dates like Picard", func() { + It("should parse properly tagged dates ", func() { mf = toMediaFile(model.RawTags{ "ORIGINALDATE": {"1978-09-10"}, "DATE": {"1977-03-04"}, @@ -49,6 +49,32 @@ var _ = Describe("ToMediaFile", func() { Expect(mf.ReleaseYear).To(Equal(2002)) 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() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 5d9c4a3ed..d7473afa7 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -90,13 +90,14 @@ var _ = Describe("Metadata", func() { md = metadata.New(filePath, props) Expect(md.All()).To(SatisfyAll( - HaveLen(5), Not(HaveKey(unknownTag)), HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}), 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.TagTrackNumber, []string{"1/10"}), + HaveLen(6), )) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 0f2a46dec..be2af3ee3 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -97,9 +97,10 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito r.tableName = "album" r.registerModel(&model.Album{}, albumFilters()) r.setSortMappings(map[string]string{ - "name": "order_album_name, order_album_artist_name", - "artist": "compilation, order_album_artist_name, order_album_name", - "album_artist": "compilation, order_album_artist_name, order_album_name", + "name": "order_album_name, order_album_artist_name", + "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", "random": "random", "recently_added": recentlyAddedSort(), diff --git a/resources/album-placeholder.webp b/resources/album-placeholder.webp index 864f35f67..ced0ade23 100644 Binary files a/resources/album-placeholder.webp and b/resources/album-placeholder.webp differ diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 86ccf7c06..d574821d4 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -1,514 +1,515 @@ { - "languageName": "Ελληνικά", - "resources": { - "song": { - "name": "Τραγούδι |||| Τραγούδια", - "fields": { - "albumArtist": "Καλλιτεχνης Αλμπουμ", - "duration": "Διαρκεια", - "trackNumber": "#", - "playCount": "Αναπαραγωγες", - "title": "Τιτλος", - "artist": "Καλλιτεχνης", - "album": "Αλμπουμ", - "path": "Διαδρομη αρχειου", - "genre": "Ειδος", - "compilation": "Συλλογή", - "year": "Ετος", - "size": "Μεγεθος αρχειου", - "updatedAt": "Ενημερωθηκε", - "bitRate": "Ρυθμός Bit", - "discSubtitle": "Υπότιτλοι Δίσκου", - "starred": "Αγαπημένο", - "comment": "Σχόλιο", - "rating": "Βαθμολογια", - "quality": "Ποιοτητα", - "bpm": "BPM", - "playDate": "Παίχτηκε Τελευταία", - "channels": "Κανάλια", - "createdAt": "Ημερομηνία προσθήκης", - "grouping": "Ομαδοποίηση", - "mood": "Διάθεση", - "participants": "Πρόσθετοι συμμετέχοντες", - "tags": "Πρόσθετες Ετικέτες", - "mappedTags": "Χαρτογραφημένες ετικέτες", - "rawTags": "Ακατέργαστες ετικέτες", - "bitDepth": "Λίγο βάθος" - }, - "actions": { - "addToQueue": "Αναπαραγωγη Μετα", - "playNow": "Αναπαραγωγή Τώρα", - "addToPlaylist": "Προσθήκη στη λίστα αναπαραγωγής", - "shuffleAll": "Ανακατεμα ολων", - "download": "Ληψη", - "playNext": "Επόμενη Αναπαραγωγή", - "info": "Εμφάνιση Πληροφοριών" - } - }, - "album": { - "name": "Άλμπουμ |||| Άλμπουμ", - "fields": { - "albumArtist": "Καλλιτεχνης Αλμπουμ", - "artist": "Καλλιτεχνης", - "duration": "Διαρκεια", - "songCount": "Τραγουδια", - "playCount": "Αναπαραγωγες", - "name": "Ονομα", - "genre": "Ειδος", - "compilation": "Συλλογη", - "year": "Ετος", - "updatedAt": "Ενημερωθηκε", - "comment": "Σχόλιο", - "rating": "Βαθμολογια", - "createdAt": "Ημερομηνία προσθήκης", - "size": "Μέγεθος", - "originalDate": "Πρωτότυπο", - "releaseDate": "Κυκλοφόρησε", - "releases": "Έκδοση |||| Εκδόσεις", - "released": "Κυκλοφόρησε", - "recordLabel": "Επιγραφή", - "catalogNum": "Αριθμός καταλόγου", - "releaseType": "Τύπος", - "grouping": "Ομαδοποίηση", - "media": "Μέσα", - "mood": "Διάθεση" - }, - "actions": { - "playAll": "Αναπαραγωγή", - "playNext": "Αναπαραγωγη Μετα", - "addToQueue": "Αναπαραγωγη Αργοτερα", - "shuffle": "Ανακατεμα", - "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", - "download": "Ληψη", - "info": "Εμφάνιση Πληροφοριών", - "share": "Μερίδιο" - }, - "lists": { - "all": "Όλα", - "random": "Τυχαία", - "recentlyAdded": "Νέες Προσθήκες", - "recentlyPlayed": "Παίχτηκαν Πρόσφατα", - "mostPlayed": "Παίζονται Συχνά", - "starred": "Αγαπημένα", - "topRated": "Κορυφαία" - } - }, - "artist": { - "name": "Καλλιτέχνης |||| Καλλιτέχνες", - "fields": { - "name": "Ονομα", - "albumCount": "Αναπαραγωγές Αλμπουμ", - "songCount": "Αναπαραγωγες Τραγουδιου", - "playCount": "Αναπαραγωγες", - "rating": "Βαθμολογια", - "genre": "Είδος", - "size": "Μέγεθος", - "role": "Ρόλος" - }, - "roles": { - "albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ", - "artist": "Καλλιτέχνης |||| Καλλιτέχνες", - "composer": "Συνθέτης |||| Συνθέτες", - "conductor": "Μαέστρος |||| Μαέστροι", - "lyricist": "Στιχουργός |||| Στιχουργοί", - "arranger": "Τακτοποιητής |||| Τακτοποιητές", - "producer": "Παραγωγός |||| Παραγωγοί", - "director": "Διευθυντής |||| Διευθυντές", - "engineer": "Μηχανικός |||| Μηχανικοί", - "mixer": "Μίξερ |||| Μίξερ", - "remixer": "Ρεμίξερ |||| Ρεμίξερ", - "djmixer": "Dj Μίξερ |||| Dj Μίξερ", - "performer": "Εκτελεστής |||| Ερμηνευτές" - } - }, - "user": { - "name": "Χρήστης |||| Χρήστες", - "fields": { - "userName": "Ονομα Χρηστη", - "isAdmin": "Ειναι Διαχειριστης", - "lastLoginAt": "Τελευταια συνδεση στις", - "updatedAt": "Ενημερωθηκε", - "name": "Όνομα", - "password": "Κωδικός Πρόσβασης", - "createdAt": "Δημιουργήθηκε στις", - "changePassword": "Αλλαγή Κωδικού Πρόσβασης;", - "currentPassword": "Υπάρχων Κωδικός Πρόσβασης", - "newPassword": "Νέος Κωδικός Πρόσβασης", - "token": "Token", - "lastAccessAt": "Τελευταία Πρόσβαση" - }, - "helperTexts": { - "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση" - }, - "notifications": { - "created": "Ο χρήστης δημιουργήθηκε", - "updated": "Ο χρήστης ενημερώθηκε", - "deleted": "Ο χρήστης διαγράφηκε" - }, - "message": { - "listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.", - "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας" - } - }, - "player": { - "name": "Συσκευή Αναπαραγωγής |||| Συσκευές Αναπαραγωγής", - "fields": { - "name": "Όνομα", - "transcodingId": "Διακωδικοποίηση", - "maxBitRate": "Μεγ. Ρυθμός Bit", - "client": "Πελάτης", - "userName": "Ονομα Χρηστη", - "lastSeen": "Τελευταια προβολη στις", - "reportRealPath": "Αναφορά Πραγματικής Διαδρομής", - "scrobbleEnabled": "Αποστολή scrobbles σε εξωτερικές συσκευές" - } - }, - "transcoding": { - "name": "Διακωδικοποίηση |||| Διακωδικοποιήσεις", - "fields": { - "name": "Όνομα", - "targetFormat": "Μορφη Προορισμου", - "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", - "command": "Εντολή" - } - }, - "playlist": { - "name": "Λίστα αναπαραγωγής |||| Λίστες αναπαραγωγής", - "fields": { - "name": "Όνομα", - "duration": "Διάρκεια", - "ownerName": "Ιδιοκτήτης", - "public": "Δημόσιο", - "updatedAt": "Ενημερωθηκε", - "createdAt": "Δημιουργήθηκε στις", - "songCount": "Τραγούδια", - "comment": "Σχόλιο", - "sync": "Αυτόματη εισαγωγή", - "path": "Εισαγωγή από" - }, - "actions": { - "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", - "addNewPlaylist": "Δημιουργία \"%{name}\"", - "export": "Εξαγωγη", - "makePublic": "Να γίνει δημόσιο", - "makePrivate": "Να γίνει ιδιωτικό" - }, - "message": { - "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", - "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;" - } - }, - "radio": { - "name": "Ραδιόφωνο ||| Ραδιόφωνο", - "fields": { - "name": "Όνομα", - "streamUrl": "Ρεύμα URL", - "homePageUrl": "Αρχική σελίδα URL", - "updatedAt": "Ενημερώθηκε στις", - "createdAt": "Δημιουργήθηκε στις" - }, - "actions": { - "playNow": "Αναπαραγωγή" - } - }, - "share": { - "name": "Μοιραστείτε |||| Μερίδια", - "fields": { - "username": "Κοινή χρήση από", - "url": "URL", - "description": "Περιγραφή", - "contents": "Περιεχόμενα", - "expiresAt": "Λήγει", - "lastVisitedAt": "Τελευταία Επίσκεψη", - "visitCount": "Επισκέψεις", - "format": "Μορφή", - "maxBitRate": "Μέγ. Ρυθμός Bit", - "updatedAt": "Ενημερώθηκε στις", - "createdAt": "Δημιουργήθηκε στις", - "downloadable": "Επιτρέπονται οι λήψεις?" - } - }, - "missing": { - "name": "Λείπει αρχείο |||| Λείπουν αρχεία", - "fields": { - "path": "Διαδρομή", - "size": "Μέγεθος", - "updatedAt": "Εξαφανίστηκε" - }, - "actions": { - "remove": "Αφαίρεση" - }, - "notifications": { - "removed": "Λείπει αρχείο(α) αφαιρέθηκε" - }, - "empty": "Δεν λείπουν αρχεία" - } + "languageName": "Ελληνικά", + "resources": { + "song": { + "name": "Τραγούδι |||| Τραγούδια", + "fields": { + "albumArtist": "Καλλιτεχνης Αλμπουμ", + "duration": "Διαρκεια", + "trackNumber": "#", + "playCount": "Αναπαραγωγες", + "title": "Τιτλος", + "artist": "Καλλιτεχνης", + "album": "Αλμπουμ", + "path": "Διαδρομη αρχειου", + "genre": "Ειδος", + "compilation": "Συλλογή", + "year": "Ετος", + "size": "Μεγεθος αρχειου", + "updatedAt": "Ενημερωθηκε", + "bitRate": "Ρυθμός Bit", + "discSubtitle": "Υπότιτλοι Δίσκου", + "starred": "Αγαπημένο", + "comment": "Σχόλιο", + "rating": "Βαθμολογια", + "quality": "Ποιοτητα", + "bpm": "BPM", + "playDate": "Παίχτηκε Τελευταία", + "channels": "Κανάλια", + "createdAt": "Ημερομηνία προσθήκης", + "grouping": "Ομαδοποίηση", + "mood": "Διάθεση", + "participants": "Πρόσθετοι συμμετέχοντες", + "tags": "Πρόσθετες Ετικέτες", + "mappedTags": "Χαρτογραφημένες ετικέτες", + "rawTags": "Ακατέργαστες ετικέτες", + "bitDepth": "Λίγο βάθος" + }, + "actions": { + "addToQueue": "Αναπαραγωγη Μετα", + "playNow": "Αναπαραγωγή Τώρα", + "addToPlaylist": "Προσθήκη στη λίστα αναπαραγωγής", + "shuffleAll": "Ανακατεμα ολων", + "download": "Ληψη", + "playNext": "Επόμενη Αναπαραγωγή", + "info": "Εμφάνιση Πληροφοριών" + } }, - "ra": { - "auth": { - "welcome1": "Σας ευχαριστούμε που εγκαταστήσατε το Navidrome!", - "welcome2": "Για να ξεκινήσετε, δημιουργήστε έναν χρήστη ως διαχειριστή", - "confirmPassword": "Επιβεβαίωση κωδικού πρόσβασης", - "buttonCreateAdmin": "Δημιουργία Διαχειριστή", - "auth_check_error": "Παρακαλούμε συνδεθείτε για να συννεχίσετε", - "user_menu": "Προφίλ", - "username": "Ονομα Χρηστη", - "password": "Κωδικός Πρόσβασης", - "sign_in": "Σύνδεση", - "sign_in_error": "Η αυθεντικοποίηση απέτυχε, παρακαλούμε προσπαθήστε ξανά", - "logout": "Αποσύνδεση", - "insightsCollectionNote": "Το Navidrome συλλέγει ανώνυμα δεδομένα χρήσης σε\nβοηθήσουν στη βελτίωση του έργου. Κάντε κλικ [εδώ] για να μάθετε\nπερισσότερα και να εξαιρεθείτε αν θέλετε" - }, - "validation": { - "invalidChars": "Παρακαλούμε χρησημοποιήστε μόνο γράμματα και αριθμούς", - "passwordDoesNotMatch": "Ο κωδικός πρόσβασης δεν ταιριάζει", - "required": "Υποχρεωτικό", - "minLength": "Πρέπει να είναι %{min} χαρακτήρες τουλάχιστον", - "maxLength": "Πρέπει να είναι %{max} χαρακτήρες ή λιγότερο", - "minValue": "Πρέπει να είναι τουλάχιστον %{min}", - "maxValue": "Πρέπει να είναι %{max} ή λιγότερο", - "number": "Πρέπει να είναι αριθμός", - "email": "Πρέπει να είναι ένα έγκυρο email", - "oneOf": "Πρέπει να είναι ένα από τα ακόλουθα: %{options}", - "regex": "Πρέπει να ταιριάζει με ένα συγκεκριμένο τύπο (κανονική έκφραση): %{pattern}", - "unique": "Πρέπει να είναι μοναδικό", - "url": "Πρέπει να είναι έγκυρη διεύθυνση URL" - }, - "action": { - "add_filter": "Προσθηκη φιλτρου", - "add": "Προσθήκη", - "back": "Πίσω", - "bulk_actions": "1 αντικείμενο επιλέχθηκε |||| %{smart_count} αντικείμενα επιλέχθηκαν", - "cancel": "Ακύρωση", - "clear_input_value": "Καθαρισμός τιμής", - "clone": "Κλωνοποίηση", - "confirm": "Επιβεβαίωση", - "create": "Δημιουργία", - "delete": "Διαγραφή", - "edit": "Επεξεργασία", - "export": "Εξαγωγη", - "list": "Λίστα", - "refresh": "Ανανέωση", - "remove_filter": "Αφαίρεση αυτού του φίλτρου", - "remove": "Αφαίρεση", - "save": "Αποθηκευση", - "search": "Αναζήτηση", - "show": "Προβολή", - "sort": "Ταξινόμιση", - "undo": "Αναίρεση", - "expand": "Επέκταση", - "close": "Κλείσιμο", - "open_menu": "Άνοιγμα μενού", - "close_menu": "Κλείσιμο μενού", - "unselect": "Αποεπιλογή", - "skip": "Παράβλεψη", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Κοινοποίηση", - "download": "Λήψη " - }, - "boolean": { - "true": "Ναι", - "false": "Όχι" - }, - "page": { - "create": "Δημιουργία %{name}", - "dashboard": "Πίνακας Ελέγχου", - "edit": "%{name} #%{id}", - "error": "Κάτι πήγε στραβά", - "list": "%{name}", - "loading": "Φόρτωση", - "not_found": "Δεν βρέθηκε", - "show": "%{name} #%{id}", - "empty": "Δεν υπάρχει %{name} ακόμη.", - "invite": "Θέλετε να προσθέσετε ένα?" - }, - "input": { - "file": { - "upload_several": "Ρίξτε μερικά αρχεία για να τα ανεβάσετε, ή κάντε κλικ για να επιλέξετε ένα.", - "upload_single": "Ρίξτε ένα αρχείο για να τα ανεβάσετε, ή κάντε κλικ για να το επιλέξετε." - }, - "image": { - "upload_several": "Ρίξτε μερικές φωτογραφίες για να τις ανεβάσετε, ή κάντε κλικ για να επιλέξετε μια.", - "upload_single": "Ρίξτε μια φωτογραφία για να την ανεβάσετε, ή κάντε κλικ για να την επιλέξετε." - }, - "references": { - "all_missing": "Αδυναμία εύρεσης δεδομένων αναφοράς.", - "many_missing": "Τουλάχιστον μια από τις συσχετιζόμενες αναφορές φαίνεται δεν είναι διαθέσιμη.", - "single_missing": "Η συσχετιζόμενη αναφορά φαίνεται δεν είναι διαθέσιμη." - }, - "password": { - "toggle_visible": "Απόκρυψη κωδικού πρόσβασης", - "toggle_hidden": "Εμφάνιση κωδικού πρόσβασης" - } - }, - "message": { - "about": "Σχετικά", - "are_you_sure": "Είστε σίγουροι;", - "bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};", - "bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}", - "delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;", - "delete_title": "Διαγραφή του %{name} #%{id}", - "details": "Λεπτομέρειες", - "error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.", - "invalid_form": "Η φόρμα δεν είναι έγκυρη. Ελέγξτε για σφάλματα", - "loading": "Η σελίδα φορτώνει, περιμένετε λίγο", - "no": "Όχι", - "not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.", - "yes": "Ναι", - "unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;" - }, - "navigation": { - "no_results": "Δεν βρέθηκαν αποτελέσματα", - "no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.", - "page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων", - "page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας", - "page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}", - "page_rows_per_page": "Αντικείμενα ανά σελίδα:", - "next": "Επόμενο", - "prev": "Προηγούμενο", - "skip_nav": "Παράβλεψη στο περιεχόμενο" - }, - "notification": { - "updated": "Το στοιχείο ενημερώθηκε |||| %{smart_count} στοιχεία ενημερώθηκαν", - "created": "Το στοιχείο δημιουργήθηκε", - "deleted": "Το στοιχείο διαγράφηκε |||| %{smart_count} στοιχεία διαγράφηκαν", - "bad_item": "Λανθασμένο στοιχείο", - "item_doesnt_exist": "Το παρόν στοιχείο δεν υπάρχει", - "http_error": "Σφάλμα κατά την επικοινωνία με το διακομιστή", - "data_provider_error": "Σφάλμα παρόχου δεδομένων. Παρακαλούμε συμβουλευτείτε την κονσόλα για περισσότερες πληροφορίες.", - "i18n_error": "Αδυναμία ανάκτησης των μεταφράσεων για την συγκεκριμένη γλώσσα", - "canceled": "Η συγκεκριμένη δράση ακυρώθηκε", - "logged_out": "Η συνεδρία σας έχει λήξει, παρακαλούμε ξανασυνδεθείτε.", - "new_version": "Υπάρχει νέα έκδοση διαθέσιμη! Παρακαλούμε ανανεώστε το παράθυρο." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Στήλες προς εμφάνιση", - "layout": "Διάταξη", - "grid": "Πλεγμα", - "table": "Πινακας" - } + "album": { + "name": "Άλμπουμ |||| Άλμπουμ", + "fields": { + "albumArtist": "Καλλιτεχνης Αλμπουμ", + "artist": "Καλλιτεχνης", + "duration": "Διαρκεια", + "songCount": "Τραγουδια", + "playCount": "Αναπαραγωγες", + "name": "Ονομα", + "genre": "Ειδος", + "compilation": "Συλλογη", + "year": "Ετος", + "updatedAt": "Ενημερωθηκε", + "comment": "Σχόλιο", + "rating": "Βαθμολογια", + "createdAt": "Ημερομηνία προσθήκης", + "size": "Μέγεθος", + "originalDate": "Πρωτότυπο", + "releaseDate": "Κυκλοφόρησε", + "releases": "Έκδοση |||| Εκδόσεις", + "released": "Κυκλοφόρησε", + "recordLabel": "Επιγραφή", + "catalogNum": "Αριθμός καταλόγου", + "releaseType": "Τύπος", + "grouping": "Ομαδοποίηση", + "media": "Μέσα", + "mood": "Διάθεση", + "date": "Ημερομηνία Ηχογράφησης" + }, + "actions": { + "playAll": "Αναπαραγωγή", + "playNext": "Αναπαραγωγη Μετα", + "addToQueue": "Αναπαραγωγη Αργοτερα", + "shuffle": "Ανακατεμα", + "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", + "download": "Ληψη", + "info": "Εμφάνιση Πληροφοριών", + "share": "Μερίδιο" + }, + "lists": { + "all": "Όλα", + "random": "Τυχαία", + "recentlyAdded": "Νέες Προσθήκες", + "recentlyPlayed": "Παίχτηκαν Πρόσφατα", + "mostPlayed": "Παίζονται Συχνά", + "starred": "Αγαπημένα", + "topRated": "Κορυφαία" + } }, - "message": { - "note": "ΣΗΜΕΙΩΣΗ", - "transcodingDisabled": "Η αλλαγή της διαμόρφωσης της διακωδικοποίησης μέσω της διεπαφής του περιηγητή ιστού είναι απενεργοποιημένη για λόγους ασφαλείας. Εαν επιθυμείτε να αλλάξετε (τροποποίηση ή δημιουργία) των επιλογών διακωδικοποίησης, επανεκκινήστε το διακομιστή με την επιλογή %{config}.", - "transcodingEnabled": "Το Navidrome λειτουργεί με %{config}, καθιστόντας δυνατή την εκτέλεση εντολών συστήματος μέσω των ρυθμίσεων διακωδικοποίησης χρησιμοποιώντας την διεπαφή ιστού. Προτείνουμε να το απενεργοποιήσετε για λόγους ασφαλείας και να το ενεργοποιήσετε μόνο όταν παραμετροποιείτε τις επιλογές διακωδικοποίησης.", - "songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής", - "noPlaylistsAvailable": "Κανένα διαθέσιμο", - "delete_user_title": "Διαγραφή του χρήστη '%{name}'", - "delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);", - "notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας", - "notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https", - "lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε", - "lastfmLinkFailure": "Δεν μπορεί να πραγματοποιηθεί διασύνδεση με το Last.fm", - "lastfmUnlinkSuccess": "Το Last.fm αποσυνδέθηκε και η λειτουργία scrobbling έχει απενεργοποιηθεί", - "lastfmUnlinkFailure": "Το Last.fm δεν μπορεί να αποσυνδεθεί", - "openIn": { - "lastfm": "Άνοιγμα στο Last.fm", - "musicbrainz": "Άνοιγμα στο MusicBrainz" - }, - "lastfmLink": "Διαβάστε περισσότερα...", - "listenBrainzLinkSuccess": "Το ListenBrainz έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling έχει ενεργοποιηθεί για το χρήστη: %{user}", - "listenBrainzLinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί: %{error}", - "listenBrainzUnlinkSuccess": "Το ListenBrainz έχει αποσυνδεθεί και το scrobbling έχει απενεργοποιηθεί", - "listenBrainzUnlinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί", - "downloadOriginalFormat": "Λήψη σε αρχική μορφή", - "shareOriginalFormat": "Κοινή χρήση σε αρχική μορφή", - "shareDialogTitle": "Κοινή χρήση %{resource} '%{name}'", - "shareBatchDialogTitle": "Κοινή χρήση 1 %{resource} |||| Κοινή χρήση %{smart_count} %{resource}", - "shareSuccess": "Το URL αντιγράφτηκε στο πρόχειρο: %{url}", - "shareFailure": "Σφάλμα κατά την αντιγραφή της διεύθυνσης URL %{url} στο πρόχειρο", - "downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})", - "shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter", - "remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν", - "remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους." + "artist": { + "name": "Καλλιτέχνης |||| Καλλιτέχνες", + "fields": { + "name": "Ονομα", + "albumCount": "Αναπαραγωγές Αλμπουμ", + "songCount": "Αναπαραγωγες Τραγουδιου", + "playCount": "Αναπαραγωγες", + "rating": "Βαθμολογια", + "genre": "Είδος", + "size": "Μέγεθος", + "role": "Ρόλος" + }, + "roles": { + "albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ", + "artist": "Καλλιτέχνης |||| Καλλιτέχνες", + "composer": "Συνθέτης |||| Συνθέτες", + "conductor": "Μαέστρος |||| Μαέστροι", + "lyricist": "Στιχουργός |||| Στιχουργοί", + "arranger": "Τακτοποιητής |||| Τακτοποιητές", + "producer": "Παραγωγός |||| Παραγωγοί", + "director": "Διευθυντής |||| Διευθυντές", + "engineer": "Μηχανικός |||| Μηχανικοί", + "mixer": "Μίξερ |||| Μίξερ", + "remixer": "Ρεμίξερ |||| Ρεμίξερ", + "djmixer": "Dj Μίξερ |||| Dj Μίξερ", + "performer": "Εκτελεστής |||| Ερμηνευτές" + } }, - "menu": { - "library": "Βιβλιοθήκη", - "settings": "Ρυθμίσεις", - "version": "Έκδοση", - "theme": "Θέμα", - "personal": { - "name": "Προσωπικές", - "options": { - "theme": "Θέμα", - "language": "Γλώσσα", - "defaultView": "Προκαθορισμένη προβολή", - "desktop_notifications": "Ειδοποιήσεις στην Επιφάνεια Εργασίας", - "lastfmScrobbling": "Λειτουργία Scrobble στο Last.fm", - "listenBrainzScrobbling": "Λειτουργία scrobble με το ListenBrainz", - "replaygain": "Λειτουργία ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Ανενεργό", - "album": "Χρησιμοποιήστε το Album Gain", - "track": "Χρησιμοποιήστε το Track Gain" - }, - "lastfmNotConfigured": "Το Last.fm API-Key δεν έχει ρυθμιστεί" - } - }, - "albumList": "Άλμπουμ", - "about": "Σχετικά", - "playlists": "Λίστες Αναπαραγωγής", - "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής" + "user": { + "name": "Χρήστης |||| Χρήστες", + "fields": { + "userName": "Ονομα Χρηστη", + "isAdmin": "Ειναι Διαχειριστης", + "lastLoginAt": "Τελευταια συνδεση στις", + "updatedAt": "Ενημερωθηκε", + "name": "Όνομα", + "password": "Κωδικός Πρόσβασης", + "createdAt": "Δημιουργήθηκε στις", + "changePassword": "Αλλαγή Κωδικού Πρόσβασης;", + "currentPassword": "Υπάρχων Κωδικός Πρόσβασης", + "newPassword": "Νέος Κωδικός Πρόσβασης", + "token": "Token", + "lastAccessAt": "Τελευταία Πρόσβαση" + }, + "helperTexts": { + "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση" + }, + "notifications": { + "created": "Ο χρήστης δημιουργήθηκε", + "updated": "Ο χρήστης ενημερώθηκε", + "deleted": "Ο χρήστης διαγράφηκε" + }, + "message": { + "listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.", + "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας" + } }, "player": { - "playListsText": "Ουρά Αναπαραγωγής", - "openText": "Άνοιγμα", - "closeText": "Κλείσιμο", - "notContentText": "Δεν υπάρχει μουσική", - "clickToPlayText": "Κλίκ για αναπαραγωγή", - "clickToPauseText": "Κλίκ για παύση", - "nextTrackText": "Επόμενο κομμάτι", - "previousTrackText": "Προηγούμενο κομμάτι", - "reloadText": "Επαναφόρτωση", - "volumeText": "Ένταση", - "toggleLyricText": "Εναλλαγή στίχων", - "toggleMiniModeText": "Ελαχιστοποίηση", - "destroyText": "Κλέισιμο", - "downloadText": "Ληψη", - "removeAudioListsText": "Διαγραφή λιστών ήχου", - "clickToDeleteText": "Κάντε κλικ για να διαγράψετε %{name}", - "emptyLyricText": "Δεν υπάρχουν στίχοι", - "playModeText": { - "order": "Στη σειρά", - "orderLoop": "Επανάληψη", - "singleLoop": "Επανάληψη μια φορά", - "shufflePlay": "Ανακατεμα" - } + "name": "Συσκευή Αναπαραγωγής |||| Συσκευές Αναπαραγωγής", + "fields": { + "name": "Όνομα", + "transcodingId": "Διακωδικοποίηση", + "maxBitRate": "Μεγ. Ρυθμός Bit", + "client": "Πελάτης", + "userName": "Ονομα Χρηστη", + "lastSeen": "Τελευταια προβολη στις", + "reportRealPath": "Αναφορά Πραγματικής Διαδρομής", + "scrobbleEnabled": "Αποστολή Scrobbles σε εξωτερικές συσκευές" + } }, - "about": { - "links": { - "homepage": "Αρχική σελίδα", - "source": "Πηγαίος κώδικας", - "featureRequests": "Αιτήματα χαρακτηριστικών", - "lastInsightsCollection": "Τελευταία συλλογή πληροφοριών", - "insights": { - "disabled": "Απενεργοποιημένο", - "waiting": "Αναμονή" - } - } + "transcoding": { + "name": "Διακωδικοποίηση |||| Διακωδικοποιήσεις", + "fields": { + "name": "Όνομα", + "targetFormat": "Μορφη Προορισμου", + "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", + "command": "Εντολή" + } }, - "activity": { - "title": "Δραστηριότητα", - "totalScanned": "Σαρώμένοι Φάκελοι", - "quickScan": "Γρήγορη Σάρωση", - "fullScan": "Πλήρης Σάρωση", - "serverUptime": "Λειτουργία Διακομιστή", - "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ" + "playlist": { + "name": "Λίστα αναπαραγωγής |||| Λίστες αναπαραγωγής", + "fields": { + "name": "Όνομα", + "duration": "Διάρκεια", + "ownerName": "Ιδιοκτήτης", + "public": "Δημόσιο", + "updatedAt": "Ενημερωθηκε", + "createdAt": "Δημιουργήθηκε στις", + "songCount": "Τραγούδια", + "comment": "Σχόλιο", + "sync": "Αυτόματη εισαγωγή", + "path": "Εισαγωγή από" + }, + "actions": { + "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", + "addNewPlaylist": "Δημιουργία \"%{name}\"", + "export": "Εξαγωγη", + "makePublic": "Να γίνει δημόσιο", + "makePrivate": "Να γίνει ιδιωτικό" + }, + "message": { + "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", + "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε;" + } }, - "help": { - "title": "Συντομεύσεις του Navidrome", - "hotkeys": { - "show_help": "Προβολή αυτής της Βοήθειας", - "toggle_menu": "Εναλλαγή Μπάρας Μενού", - "toggle_play": "Αναπαραγωγή / Παύση", - "prev_song": "Προηγούμενο Τραγούδι", - "next_song": "Επόμενο Τραγούδι", - "vol_up": "Αύξηση Έντασης", - "vol_down": "Μείωση Έντασης", - "toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα", - "current_song": "Μεταβείτε στο Τρέχον τραγούδι" - } + "radio": { + "name": "Ραδιόφωνο |||| Ραδιόφωνα", + "fields": { + "name": "Όνομα", + "streamUrl": "Ρεύμα URL", + "homePageUrl": "Αρχική σελίδα URL", + "updatedAt": "Ενημερώθηκε στις", + "createdAt": "Δημιουργήθηκε στις" + }, + "actions": { + "playNow": "Αναπαραγωγή" + } + }, + "share": { + "name": "Μοιραστείτε |||| Μερίδια", + "fields": { + "username": "Κοινή χρήση από", + "url": "URL", + "description": "Περιγραφή", + "contents": "Περιεχόμενα", + "expiresAt": "Λήγει", + "lastVisitedAt": "Τελευταία Επίσκεψη", + "visitCount": "Επισκέψεις", + "format": "Μορφή", + "maxBitRate": "Μέγ. Ρυθμός Bit", + "updatedAt": "Ενημερώθηκε στις", + "createdAt": "Δημιουργήθηκε στις", + "downloadable": "Επιτρέπονται οι λήψεις?" + } + }, + "missing": { + "name": "Λείπει αρχείο |||| Λείπουν αρχεία", + "fields": { + "path": "Διαδρομή", + "size": "Μέγεθος", + "updatedAt": "Εξαφανίστηκε" + }, + "actions": { + "remove": "Αφαίρεση" + }, + "notifications": { + "removed": "Λείπει αρχείο(α) αφαιρέθηκε" + }, + "empty": "Δεν λείπουν αρχεία" } + }, + "ra": { + "auth": { + "welcome1": "Σας ευχαριστούμε που εγκαταστήσατε το Navidrome!", + "welcome2": "Για να ξεκινήσετε, δημιουργήστε έναν χρήστη ως διαχειριστή", + "confirmPassword": "Επιβεβαίωση κωδικού πρόσβασης", + "buttonCreateAdmin": "Δημιουργία Διαχειριστή", + "auth_check_error": "Παρακαλούμε συνδεθείτε για να συννεχίσετε", + "user_menu": "Προφίλ", + "username": "Ονομα Χρηστη", + "password": "Κωδικός Πρόσβασης", + "sign_in": "Σύνδεση", + "sign_in_error": "Η αυθεντικοποίηση απέτυχε, παρακαλούμε προσπαθήστε ξανά", + "logout": "Αποσύνδεση", + "insightsCollectionNote": "Το Navidrome συλλέγει ανώνυμα δεδομένα χρήσης σε\nβοηθήσουν στη βελτίωση του έργου. Κάντε κλικ [εδώ] για να μάθετε\nπερισσότερα και να εξαιρεθείτε αν θέλετε" + }, + "validation": { + "invalidChars": "Παρακαλούμε χρησημοποιήστε μόνο γράμματα και αριθμούς", + "passwordDoesNotMatch": "Ο κωδικός πρόσβασης δεν ταιριάζει", + "required": "Υποχρεωτικό", + "minLength": "Πρέπει να είναι %{min} χαρακτήρες τουλάχιστον", + "maxLength": "Πρέπει να είναι %{max} χαρακτήρες ή λιγότερο", + "minValue": "Πρέπει να είναι τουλάχιστον %{min}", + "maxValue": "Πρέπει να είναι %{max} ή λιγότερο", + "number": "Πρέπει να είναι αριθμός", + "email": "Πρέπει να είναι ένα έγκυρο email", + "oneOf": "Πρέπει να είναι ένα από τα ακόλουθα: %{options}", + "regex": "Πρέπει να ταιριάζει με ένα συγκεκριμένο τύπο (κανονική έκφραση): %{pattern}", + "unique": "Πρέπει να είναι μοναδικό", + "url": "Πρέπει να είναι έγκυρη διεύθυνση URL" + }, + "action": { + "add_filter": "Προσθηκη φιλτρου", + "add": "Προσθήκη", + "back": "Πίσω", + "bulk_actions": "1 αντικείμενο επιλέχθηκε |||| %{smart_count} αντικείμενα επιλέχθηκαν", + "cancel": "Ακύρωση", + "clear_input_value": "Καθαρισμός τιμής", + "clone": "Κλωνοποίηση", + "confirm": "Επιβεβαίωση", + "create": "Δημιουργία", + "delete": "Διαγραφή", + "edit": "Επεξεργασία", + "export": "Εξαγωγη", + "list": "Λίστα", + "refresh": "Ανανέωση", + "remove_filter": "Αφαίρεση αυτού του φίλτρου", + "remove": "Αφαίρεση", + "save": "Αποθηκευση", + "search": "Αναζήτηση", + "show": "Προβολή", + "sort": "Ταξινόμιση", + "undo": "Αναίρεση", + "expand": "Επέκταση", + "close": "Κλείσιμο", + "open_menu": "Άνοιγμα μενού", + "close_menu": "Κλείσιμο μενού", + "unselect": "Αποεπιλογή", + "skip": "Παράβλεψη", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Κοινοποίηση", + "download": "Λήψη " + }, + "boolean": { + "true": "Ναι", + "false": "Όχι" + }, + "page": { + "create": "Δημιουργία %{name}", + "dashboard": "Πίνακας Ελέγχου", + "edit": "%{name} #%{id}", + "error": "Κάτι πήγε στραβά", + "list": "%{name}", + "loading": "Φόρτωση", + "not_found": "Δεν βρέθηκε", + "show": "%{name} #%{id}", + "empty": "Δεν υπάρχει %{name} ακόμη.", + "invite": "Θέλετε να προσθέσετε ένα?" + }, + "input": { + "file": { + "upload_several": "Ρίξτε μερικά αρχεία για να τα ανεβάσετε, ή κάντε κλικ για να επιλέξετε ένα.", + "upload_single": "Ρίξτε ένα αρχείο για να τα ανεβάσετε, ή κάντε κλικ για να το επιλέξετε." + }, + "image": { + "upload_several": "Ρίξτε μερικές φωτογραφίες για να τις ανεβάσετε, ή κάντε κλικ για να επιλέξετε μια.", + "upload_single": "Ρίξτε μια φωτογραφία για να την ανεβάσετε, ή κάντε κλικ για να την επιλέξετε." + }, + "references": { + "all_missing": "Αδυναμία εύρεσης δεδομένων αναφοράς.", + "many_missing": "Τουλάχιστον μια από τις συσχετιζόμενες αναφορές φαίνεται δεν είναι διαθέσιμη.", + "single_missing": "Η συσχετιζόμενη αναφορά φαίνεται δεν είναι διαθέσιμη." + }, + "password": { + "toggle_visible": "Απόκρυψη κωδικού πρόσβασης", + "toggle_hidden": "Εμφάνιση κωδικού πρόσβασης" + } + }, + "message": { + "about": "Σχετικά", + "are_you_sure": "Είστε σίγουροι;", + "bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}; |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count};", + "bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}", + "delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο;", + "delete_title": "Διαγραφή του %{name} #%{id}", + "details": "Λεπτομέρειες", + "error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.", + "invalid_form": "Η φόρμα δεν είναι έγκυρη. Ελέγξτε για σφάλματα", + "loading": "Η σελίδα φορτώνει, περιμένετε λίγο", + "no": "Όχι", + "not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.", + "yes": "Ναι", + "unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε;" + }, + "navigation": { + "no_results": "Δεν βρέθηκαν αποτελέσματα", + "no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.", + "page_out_of_boundaries": "Η σελίδα {page} είναι εκτός ορίων", + "page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας", + "page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}", + "page_rows_per_page": "Αντικείμενα ανά σελίδα:", + "next": "Επόμενο", + "prev": "Προηγούμενο", + "skip_nav": "Παράβλεψη στο περιεχόμενο" + }, + "notification": { + "updated": "Το στοιχείο ενημερώθηκε |||| %{smart_count} στοιχεία ενημερώθηκαν", + "created": "Το στοιχείο δημιουργήθηκε", + "deleted": "Το στοιχείο διαγράφηκε |||| %{smart_count} στοιχεία διαγράφηκαν", + "bad_item": "Λανθασμένο στοιχείο", + "item_doesnt_exist": "Το παρόν στοιχείο δεν υπάρχει", + "http_error": "Σφάλμα κατά την επικοινωνία με το διακομιστή", + "data_provider_error": "Σφάλμα παρόχου δεδομένων. Παρακαλούμε συμβουλευτείτε την κονσόλα για περισσότερες πληροφορίες.", + "i18n_error": "Αδυναμία ανάκτησης των μεταφράσεων για την συγκεκριμένη γλώσσα", + "canceled": "Η συγκεκριμένη δράση ακυρώθηκε", + "logged_out": "Η συνεδρία σας έχει λήξει, παρακαλούμε ξανασυνδεθείτε.", + "new_version": "Υπάρχει νέα έκδοση διαθέσιμη! Παρακαλούμε ανανεώστε το παράθυρο." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Στήλες προς εμφάνιση", + "layout": "Διάταξη", + "grid": "Πλεγμα", + "table": "Πινακας" + } + }, + "message": { + "note": "ΣΗΜΕΙΩΣΗ", + "transcodingDisabled": "Η αλλαγή της διαμόρφωσης της διακωδικοποίησης μέσω της διεπαφής του περιηγητή ιστού είναι απενεργοποιημένη για λόγους ασφαλείας. Εαν επιθυμείτε να αλλάξετε (τροποποίηση ή δημιουργία) των επιλογών διακωδικοποίησης, επανεκκινήστε το διακομιστή με την επιλογή %{config}.", + "transcodingEnabled": "Το Navidrome λειτουργεί με %{config}, καθιστόντας δυνατή την εκτέλεση εντολών συστήματος μέσω των ρυθμίσεων διακωδικοποίησης χρησιμοποιώντας την διεπαφή ιστού. Προτείνουμε να το απενεργοποιήσετε για λόγους ασφαλείας και να το ενεργοποιήσετε μόνο όταν παραμετροποιείτε τις επιλογές διακωδικοποίησης.", + "songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής", + "noPlaylistsAvailable": "Κανένα διαθέσιμο", + "delete_user_title": "Διαγραφή του χρήστη '%{name}'", + "delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων);", + "notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας", + "notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https", + "lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε", + "lastfmLinkFailure": "Δεν μπορεί να πραγματοποιηθεί διασύνδεση με το Last.fm", + "lastfmUnlinkSuccess": "Το Last.fm αποσυνδέθηκε και η λειτουργία scrobbling έχει απενεργοποιηθεί", + "lastfmUnlinkFailure": "Το Last.fm δεν μπορεί να αποσυνδεθεί", + "openIn": { + "lastfm": "Άνοιγμα στο Last.fm", + "musicbrainz": "Άνοιγμα στο MusicBrainz" + }, + "lastfmLink": "Διαβάστε περισσότερα...", + "listenBrainzLinkSuccess": "Το ListenBrainz έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling έχει ενεργοποιηθεί για το χρήστη: %{user}", + "listenBrainzLinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί: %{error}", + "listenBrainzUnlinkSuccess": "Το ListenBrainz έχει αποσυνδεθεί και το scrobbling έχει απενεργοποιηθεί", + "listenBrainzUnlinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί", + "downloadOriginalFormat": "Λήψη σε αρχική μορφή", + "shareOriginalFormat": "Κοινή χρήση σε αρχική μορφή", + "shareDialogTitle": "Κοινή χρήση %{resource} '%{name}'", + "shareBatchDialogTitle": "Κοινή χρήση 1 %{resource} |||| Κοινή χρήση %{smart_count} %{resource}", + "shareSuccess": "Το URL αντιγράφτηκε στο πρόχειρο: %{url}", + "shareFailure": "Σφάλμα κατά την αντιγραφή της διεύθυνσης URL %{url} στο πρόχειρο", + "downloadDialogTitle": "Λήψη %{resource} '%{name}'(%{size})", + "shareCopyToClipboard": "Αντιγραφή στο πρόχειρο: Ctrl+C, Enter", + "remove_missing_title": "Αφαιρέστε τα αρχεία που λείπουν", + "remove_missing_content": "Είστε βέβαιοι ότι θέλετε να αφαιρέσετε τα επιλεγμένα αρχεία που λείπουν από τη βάση δεδομένων; Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένων των αριθμών παιχνιδιών και των αξιολογήσεών τους." + }, + "menu": { + "library": "Βιβλιοθήκη", + "settings": "Ρυθμίσεις", + "version": "Έκδοση", + "theme": "Θέμα", + "personal": { + "name": "Προσωπικές", + "options": { + "theme": "Θέμα", + "language": "Γλώσσα", + "defaultView": "Προκαθορισμένη προβολή", + "desktop_notifications": "Ειδοποιήσεις στην Επιφάνεια Εργασίας", + "lastfmScrobbling": "Λειτουργία Scrobble στο Last.fm", + "listenBrainzScrobbling": "Λειτουργία Scrobble στο ListenBrainz", + "replaygain": "Λειτουργία ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Ανενεργό", + "album": "Χρησιμοποιήστε το Album Gain", + "track": "Χρησιμοποιήστε το Track Gain" + }, + "lastfmNotConfigured": "Το Last.fm API-Key δεν έχει ρυθμιστεί" + } + }, + "albumList": "Άλμπουμ", + "about": "Σχετικά", + "playlists": "Λίστες Αναπαραγωγής", + "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής" + }, + "player": { + "playListsText": "Ουρά Αναπαραγωγής", + "openText": "Άνοιγμα", + "closeText": "Κλείσιμο", + "notContentText": "Δεν υπάρχει μουσική", + "clickToPlayText": "Κλίκ για αναπαραγωγή", + "clickToPauseText": "Κλίκ για παύση", + "nextTrackText": "Επόμενο κομμάτι", + "previousTrackText": "Προηγούμενο κομμάτι", + "reloadText": "Επαναφόρτωση", + "volumeText": "Ένταση", + "toggleLyricText": "Εναλλαγή στίχων", + "toggleMiniModeText": "Ελαχιστοποίηση", + "destroyText": "Κλέισιμο", + "downloadText": "Ληψη", + "removeAudioListsText": "Διαγραφή λιστών ήχου", + "clickToDeleteText": "Κάντε κλικ για να διαγράψετε %{name}", + "emptyLyricText": "Δεν υπάρχουν στίχοι", + "playModeText": { + "order": "Στη σειρά", + "orderLoop": "Επανάληψη", + "singleLoop": "Επανάληψη μια φορά", + "shufflePlay": "Ανακατεμα" + } + }, + "about": { + "links": { + "homepage": "Αρχική σελίδα", + "source": "Πηγαίος κώδικας", + "featureRequests": "Αιτήματα χαρακτηριστικών", + "lastInsightsCollection": "Τελευταία συλλογή πληροφοριών", + "insights": { + "disabled": "Απενεργοποιημένο", + "waiting": "Αναμονή" + } + } + }, + "activity": { + "title": "Δραστηριότητα", + "totalScanned": "Σαρώμένοι Φάκελοι", + "quickScan": "Γρήγορη Σάρωση", + "fullScan": "Πλήρης Σάρωση", + "serverUptime": "Λειτουργία Διακομιστή", + "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ" + }, + "help": { + "title": "Συντομεύσεις του Navidrome", + "hotkeys": { + "show_help": "Προβολή αυτής της Βοήθειας", + "toggle_menu": "Εναλλαγή Μπάρας Μενού", + "toggle_play": "Αναπαραγωγή / Παύση", + "prev_song": "Προηγούμενο Τραγούδι", + "next_song": "Επόμενο Τραγούδι", + "vol_up": "Αύξηση Έντασης", + "vol_down": "Μείωση Έντασης", + "toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα", + "current_song": "Μεταβείτε στο Τρέχον τραγούδι" + } + } } \ No newline at end of file diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json index f4cf4c980..570943a1d 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -1,460 +1,515 @@ { - "languageName": "Esperanto", - "resources": { - "song": { - "name": "kanto |||| kantoj", - "fields": { - "albumArtist": "Albumo artista", - "duration": "Tempo", - "trackNumber": "#", - "playCount": "Nombro de ŝkotoj", - "title": "Titolo", - "artist": "Artisto", - "album": "Albumo", - "path": "Dosiera vojo", - "genre": "Ĝenro", - "compilation": "Kompilaĵo", - "year": "Jaro", - "size": "Dosiera grandeco", - "updatedAt": "Ĝisdatigita je", - "bitRate": "Bitrapido", - "discSubtitle": "Diska Subteksto", - "starred": "Stela", - "comment": "Komento", - "rating": "", - "quality": "", - "bpm": "", - "playDate": "", - "channels": "", - "createdAt": "" - }, - "actions": { - "addToQueue": "Ludi Poste", - "playNow": "Ludi nun", - "addToPlaylist": "Aldoni al Ludlisto", - "shuffleAll": "Miksu Ĉiujn", - "download": "Elŝuti", - "playNext": "Ludu Poste", - "info": "" - } - }, - "album": { - "name": "Albumo |||| Albumoj", - "fields": { - "albumArtist": "Albumo artista", - "artist": "Artisto", - "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": "" - } - } + "languageName": "Esperanto", + "resources": { + "song": { + "name": "Kanto |||| Kantoj", + "fields": { + "albumArtist": "Artisto de Albumo", + "duration": "Daŭro", + "trackNumber": "#", + "playCount": "Ludoj", + "title": "Titolo", + "artist": "Artisto", + "album": "Albumo", + "path": "Dosiera vojo", + "genre": "Ĝenro", + "compilation": "Kompilaĵo", + "year": "Jaro", + "size": "Dosiera grandeco", + "updatedAt": "Ĝisdatigita je", + "bitRate": "Bitrapido", + "discSubtitle": "Diska Subteksto", + "starred": "Stela", + "comment": "Komento", + "rating": "Takso", + "quality": "Kvalito", + "bpm": "Pulsrapideco", + "playDate": "", + "channels": "", + "createdAt": "", + "grouping": "", + "mood": "", + "participants": "", + "tags": "", + "mappedTags": "", + "rawTags": "", + "bitDepth": "" + }, + "actions": { + "addToQueue": "Ludi Poste", + "playNow": "Ludi nun", + "addToPlaylist": "Aldoni al Ludlisto", + "shuffleAll": "Miksu Ĉiujn", + "download": "Elŝuti", + "playNext": "Ludu Poste", + "info": "" + } }, - "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" - }, - "validation": { - "invalidChars": "Bonvolu uzi nur literon 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": "", - "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 valoro", - "clone": "Kloni", - "confirm": "Konfirmi", - "create": "Krei", - "delete": "Forstrekis", - "edit": "Modifi", - "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": "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": "" - } + "album": { + "name": "Albumo |||| Albumoj", + "fields": { + "albumArtist": "Artisto de Albumo", + "artist": "Artisto", + "duration": "Tempo", + "songCount": "Kantoj", + "playCount": "Ludoj", + "name": "Nomo", + "genre": "Ĝenro", + "compilation": "Kompilaĵo", + "year": "Jaro", + "updatedAt": "Ĝisdatigita je :", + "comment": "Komento", + "rating": "Takso", + "createdAt": "", + "size": "", + "originalDate": "", + "releaseDate": "", + "releases": "", + "released": "", + "recordLabel": "", + "catalogNum": "", + "releaseType": "", + "grouping": "", + "media": "", + "mood": "", + "date": "" + }, + "actions": { + "playAll": "Ludi", + "playNext": "Ludi Sekvante", + "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": "Plej Alte Taksite" + } }, - "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": "" + "artist": { + "name": "Artisto |||| Artistoj", + "fields": { + "name": "Nomo", + "albumCount": "Nombro da albumoj", + "songCount": "Kanto kalkula", + "playCount": "Teatraĵoj", + "rating": "Takso", + "genre": "", + "size": "", + "role": "" + }, + "roles": { + "albumartist": "", + "artist": "", + "composer": "", + "conductor": "", + "lyricist": "", + "arranger": "", + "producer": "", + "director": "", + "engineer": "", + "mixer": "", + "remixer": "", + "djmixer": "", + "performer": "" + } }, - "menu": { - "library": "Biblioteko", - "settings": "Agordoj", - "version": "Versio", - "theme": "Temo", - "personal": { - "name": "Persona", - "options": { - "theme": "Temo", - "language": "Lingvo", - "defaultView": "Defaŭlta Vido", - "desktop_notifications": "Labortablaj sciigoj", - "lastfmScrobbling": "", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", - "gain": { - "none": "", - "album": "", - "track": "" - } - } - }, - "albumList": "Albumoj", - "about": "Pri", - "playlists": "", - "sharedPlaylists": "" + "user": { + "name": "Uzanto |||| Uzantoj", + "fields": { + "userName": "Uzantnomo", + "isAdmin": "Estas Administranto", + "lastLoginAt": "Antaŭa Ensaluto Je", + "updatedAt": "Ĝisdatigita je", + "name": "Nomo", + "password": "Pasvorto", + "createdAt": "Kreita je :", + "changePassword": "Ĉu Ŝanĝi Pasvorton?", + "currentPassword": "Nuna Pasvorto", + "newPassword": "Nova Pasvorto", + "token": "", + "lastAccessAt": "" + }, + "helperTexts": { + "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto" + }, + "notifications": { + "created": "Uzanto farita", + "updated": "Uzanto ĝistadigita", + "deleted": "Uzanto forigita" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } }, "player": { - "playListsText": "Ludu Atendon", - "openText": "Malfermi", - "closeText": "Fermi", - "notContentText": "Neniu Muziko", - "clickToPlayText": "Alklaku por ludi", - "clickToPauseText": "Alklaku por paŭzi", - "nextTrackText": "Sekva muziko", - "previousTrackText": "Antaŭa muziko", - "reloadText": "Reŝargi", - "volumeText": "Laŭteco", - "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" - } + "name": "Ludanto |||| Ludantoj", + "fields": { + "name": "Nomo", + "transcodingId": "Transkodigo", + "maxBitRate": "Maksimuma Bitrapido", + "client": "Kliento", + "userName": "Uzantnomo", + "lastSeen": "Laste Vidita Je", + "reportRealPath": "Raporti vera pado", + "scrobbleEnabled": "" + } }, - "about": { - "links": { - "homepage": "Hejmpaĝo", - "source": "Fontkodo", - "featureRequests": "Trajta peto" - } + "transcoding": { + "name": "Transkodigo |||| Transkodigoj", + "fields": { + "name": "Nomo", + "targetFormat": "Cela Formato", + "defaultBitRate": "Defaŭlta Bitrapido", + "command": "Komando" + } }, - "activity": { - "title": "Aktiveco", - "totalScanned": "Entute dosierujoj skanitaj", - "quickScan": "Rapida Skanado", - "fullScan": "Plena Skanado", - "serverUptime": "Servila daŭro de funkciado", - "serverDown": "SENKONEKTA" + "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": "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": { - "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": "" - } + "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": "" + } + }, + "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": "" + } + } } \ No newline at end of file diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index a9a128abc..e75a9f404 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -27,13 +27,13 @@ "playDate": "Ostatnio Odtwarzane", "channels": "Kanały", "createdAt": "Data dodania", - "grouping": "", - "mood": "", - "participants": "", - "tags": "", - "mappedTags": "", - "rawTags": "", - "bitDepth": "" + "grouping": "Grupowanie", + "mood": "Nastrój", + "participants": "Dodatkowi uczestnicy", + "tags": "Dodatkowe Tagi", + "mappedTags": "Zmapowane tagi", + "rawTags": "Surowe tagi", + "bitDepth": "Głębokość próbkowania" }, "actions": { "addToQueue": "Odtwarzaj Później", @@ -66,12 +66,13 @@ "releaseDate": "Data Wydania", "releases": "Wydanie |||| Wydania", "released": "Wydany", - "recordLabel": "", - "catalogNum": "", - "releaseType": "", - "grouping": "", - "media": "", - "mood": "" + "recordLabel": "Wytwórnia", + "catalogNum": "Numer Katalogowy", + "releaseType": "Typ", + "grouping": "Grupowanie", + "media": "Media", + "mood": "Nastrój", + "date": "" }, "actions": { "playAll": "Odtwarzaj", @@ -103,15 +104,15 @@ "rating": "Ocena", "genre": "Gatunek", "size": "Rozmiar", - "role": "" + "role": "Rola" }, "roles": { - "albumartist": "", - "artist": "", - "composer": "", - "conductor": "", - "lyricist": "", - "arranger": "", + "albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu", + "artist": "Wykonawca |||| Wykonawcy", + "composer": "Kompozytor |||| Kompozytorzy", + "conductor": "Dyrygent |||| Dyrygenci", + "lyricist": "Autor tekstów |||| Autorzy tekstów", + "arranger": "Aranżer |||| Aranżerzy", "producer": "Producent |||| Producenci", "director": "Reżyser |||| Reżyserzy", "engineer": "Inżynier |||| Inżynierowie", @@ -241,7 +242,7 @@ "notifications": { "removed": "Usunięto brakujące pliki" }, - "empty": "" + "empty": "Bez Brakujących Plików" } }, "ra": { diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json index d856391ff..59e7a775d 100644 --- a/resources/i18n/pt.json +++ b/resources/i18n/pt.json @@ -57,6 +57,7 @@ "genre": "Gênero", "compilation": "Coletânea", "year": "Ano", + "date": "Data de Lançamento", "updatedAt": "Últ. Atualização", "comment": "Comentário", "rating": "Classificação", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 1b79c8e49..c6b7fcd16 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -32,7 +32,8 @@ "participants": "Дополнительные участники", "tags": "Дополнительные теги", "mappedTags": "Сопоставленные теги", - "rawTags": "Исходные теги" + "rawTags": "Исходные теги", + "bitDepth": "Битовая глубина" }, "actions": { "addToQueue": "В очередь", @@ -70,7 +71,8 @@ "releaseType": "Тип", "grouping": "Группирование", "media": "Медиа", - "mood": "Настроение" + "mood": "Настроение", + "date": "" }, "actions": { "playAll": "Играть", @@ -239,7 +241,8 @@ }, "notifications": { "removed": "Отсутствующие файлы удалены" - } + }, + "empty": "Нет отсутствующих файлов" } }, "ra": { diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index 2ae07b614..8c3ac1759 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -33,7 +33,7 @@ "tags": "Ek Etiketler", "mappedTags": "Eşlenen etiketler", "rawTags": "Ham etiketler", - "bitDepth": "" + "bitDepth": "Bit derinliği" }, "actions": { "addToQueue": "Oynatma Sırasına Ekle", @@ -71,7 +71,8 @@ "releaseType": "Tür", "grouping": "Gruplama", "media": "Medya", - "mood": "Mod" + "mood": "Mod", + "date": "Kayıt Tarihi" }, "actions": { "playAll": "Oynat", diff --git a/resources/mappings.yaml b/resources/mappings.yaml index f4de96a74..66056fd57 100644 --- a/resources/mappings.yaml +++ b/resources/mappings.yaml @@ -118,10 +118,10 @@ main: aliases: [ tdor, originaldate, ----:com.apple.itunes:originaldate, wm/originalreleasetime, tory, originalyear, ----:com.apple.itunes:originalyear, wm/originalreleaseyear ] type: date recordingdate: - aliases: [ tdrc, date, icrd, ©day, wm/year, year ] + aliases: [ tdrc, date, recordingdate, icrd, record date ] type: date releasedate: - aliases: [ tdrl, releasedate ] + aliases: [ tdrl, releasedate, ©day, wm/year, year ] type: date catalognumber: aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ] diff --git a/resources/placeholder.png b/resources/placeholder.png deleted file mode 100644 index 428d5c088..000000000 Binary files a/resources/placeholder.png and /dev/null differ diff --git a/server/auth.go b/server/auth.go index fb2ccd967..5b35f72ed 100644 --- a/server/auth.go +++ b/server/auth.go @@ -292,13 +292,17 @@ func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]inte user, err := userRepo.FindByUsernameWithPassword(username) if user == nil || err != nil { 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{ ID: id.NewRandom(), UserName: username, Name: username, Email: "", NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(), - IsAdmin: false, + IsAdmin: isFirstUser, // Make the first user an admin } err := userRepo.Put(&newUser) if err != nil { diff --git a/server/auth_test.go b/server/auth_test.go index 0d4236d53..06ca2ea39 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -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()) + }) + }) }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index dfa945086..fd8c3af28 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" @@ -30,37 +31,37 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer core.MediaStreamer - archiver core.Archiver - players core.Players - externalMetadata core.ExternalMetadata - playlists core.Playlists - scanner scanner.Scanner - broker events.Broker - scrobbler scrobbler.PlayTracker - share core.Share - playback playback.PlaybackServer + ds model.DataStore + artwork artwork.Artwork + streamer core.MediaStreamer + archiver core.Archiver + players core.Players + provider external.Provider + playlists core.Playlists + scanner scanner.Scanner + broker events.Broker + scrobbler scrobbler.PlayTracker + share core.Share + playback playback.PlaybackServer } func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, - players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker, + players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker, playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, ) *Router { r := &Router{ - ds: ds, - artwork: artwork, - streamer: streamer, - archiver: archiver, - players: players, - externalMetadata: externalMetadata, - playlists: playlists, - scanner: scanner, - broker: broker, - scrobbler: scrobbler, - share: share, - playback: playback, + ds: ds, + artwork: artwork, + streamer: streamer, + archiver: archiver, + players: players, + provider: provider, + playlists: playlists, + scanner: scanner, + broker: broker, + scrobbler: scrobbler, + share: share, + playback: playback, } r.Handler = r.routes() return r diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index edc45a7c7..c00a9f1ab 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -210,7 +210,7 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { return nil, err } - album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id) + album, err := api.provider.UpdateAlbumInfo(ctx, id) if err != nil { return nil, err @@ -278,7 +278,7 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m count := p.IntOr("count", 20) includeNotPresent := p.BoolOr("includeNotPresent", false) - artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent) + artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent) if err != nil { return nil, nil, err } @@ -343,7 +343,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) } count := p.IntOr("count", 50) - songs, err := api.externalMetadata.SimilarSongs(ctx, id, count) + songs, err := api.provider.SimilarSongs(ctx, id, count) if err != nil { return nil, err } @@ -377,8 +377,8 @@ func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { } count := p.IntOr("count", 50) - songs, err := api.externalMetadata.TopSongs(ctx, artist, count) - if err != nil { + songs, err := api.provider.TopSongs(ctx, artist, count) + if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 1b5416695..f8b42d312 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -62,13 +62,14 @@ func AlbumsByArtistID(artistId string) Options { } func AlbumsByYear(fromYear, toYear int) Options { - sortOption := "max_year, name" + orderOption := "" if fromYear > toYear { fromYear, toYear = toYear, fromYear - sortOption = "max_year desc, name" + orderOption = "desc" } return addDefaultFilters(Options{ - Sort: sortOption, + Sort: "max_year", + Order: orderOption, Filters: Or{ And{ GtOrEq{"min_year": fromYear}, @@ -118,7 +119,7 @@ func SongWithLyrics(artist, title string) Options { func ByGenre(genre string) Options { return addDefaultFilters(Options{ - Sort: "name asc", + Sort: "name", Filters: filterByGenre(genre), }) } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 56b65f894..4faec158f 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -296,7 +296,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Name = al.Name child.Album = al.Name child.Artist = al.AlbumArtist - child.Year = int32(al.MaxYear) + child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() child.Created = &al.CreatedAt @@ -380,7 +380,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount - dir.Year = int32(album.MaxYear) + dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre if !album.CreatedAt.IsZero() { dir.Created = &album.CreatedAt diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index a4e0d1289..58c33c97f 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -10,56 +10,56 @@ import ( func CreateMockAlbumRepo() *MockAlbumRepo { return &MockAlbumRepo{ - data: make(map[string]*model.Album), + Data: make(map[string]*model.Album), } } type MockAlbumRepo struct { model.AlbumRepository - data map[string]*model.Album - all model.Albums - err bool + Data map[string]*model.Album + All model.Albums + Err bool Options model.QueryOptions } func (m *MockAlbumRepo) SetError(err bool) { - m.err = err + m.Err = err } func (m *MockAlbumRepo) SetData(albums model.Albums) { - m.data = make(map[string]*model.Album, len(albums)) - m.all = albums - for i, a := range m.all { - m.data[a.ID] = &m.all[i] + m.Data = make(map[string]*model.Album, len(albums)) + m.All = albums + for i, a := range m.All { + m.Data[a.ID] = &m.All[i] } } func (m *MockAlbumRepo) Exists(id string) (bool, error) { - if m.err { + if m.Err { return false, errors.New("unexpected error") } - _, found := m.data[id] + _, found := m.Data[id] return found, nil } func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { - if m.err { + if m.Err { return nil, errors.New("unexpected error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { return d, nil } return nil, model.ErrNotFound } func (m *MockAlbumRepo) Put(al *model.Album) error { - if m.err { + if m.Err { return errors.New("unexpected error") } if al.ID == "" { al.ID = id.NewRandom() } - m.data[al.ID] = al + m.Data[al.ID] = al return nil } @@ -67,17 +67,17 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { m.Options = qo[0] } - if m.err { + if m.Err { return nil, errors.New("unexpected error") } - return m.all, nil + return m.All, nil } func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { - if m.err { + if m.Err { return errors.New("unexpected error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { d.PlayCount++ d.PlayDate = ×tamp return nil @@ -85,15 +85,15 @@ func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) { - return int64(len(m.all)), nil + return int64(len(m.All)), nil } func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { - if m.err { + if m.Err { return nil, errors.New("unexpected error") } return func(yield func(model.Album, error) bool) { - for _, a := range m.data { + for _, a := range m.Data { if a.ID == "error" { if !yield(*a, errors.New("error")) { break @@ -110,4 +110,11 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { }, nil } +func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index fad7c78d3..7058cead0 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -10,61 +10,61 @@ import ( func CreateMockArtistRepo() *MockArtistRepo { return &MockArtistRepo{ - data: make(map[string]*model.Artist), + Data: make(map[string]*model.Artist), } } type MockArtistRepo struct { model.ArtistRepository - data map[string]*model.Artist - err bool + Data map[string]*model.Artist + Err bool } func (m *MockArtistRepo) SetError(err bool) { - m.err = err + m.Err = err } func (m *MockArtistRepo) SetData(artists model.Artists) { - m.data = make(map[string]*model.Artist) + m.Data = make(map[string]*model.Artist) for i, a := range artists { - m.data[a.ID] = &artists[i] + m.Data[a.ID] = &artists[i] } } func (m *MockArtistRepo) Exists(id string) (bool, error) { - if m.err { + if m.Err { return false, errors.New("Error!") } - _, found := m.data[id] + _, found := m.Data[id] return found, nil } func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { - if m.err { + if m.Err { return nil, errors.New("Error!") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { return d, nil } return nil, model.ErrNotFound } func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { - if m.err { + if m.Err { return errors.New("error") } if ar.ID == "" { ar.ID = id.NewRandom() } - m.data[ar.ID] = ar + m.Data[ar.ID] = ar return nil } func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { - if m.err { + if m.Err { return errors.New("error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { d.PlayCount++ d.PlayDate = ×tamp return nil @@ -72,4 +72,26 @@ func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { return model.ErrNotFound } +func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) { + if m.Err { + return nil, errors.New("mock repo error") + } + var allArtists model.Artists + for _, artist := range m.Data { + allArtists = append(allArtists, *artist) + } + // Apply Max=1 if present (simple simulation for findArtistByName) + if len(options) > 0 && options[0].Max == 1 && len(allArtists) > 0 { + return allArtists[:1], nil + } + return allArtists, nil +} + +func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { + if m.Err { + return errors.New("mock repo error") + } + return nil +} + var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index a24f2b0c8..122ccc278 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -6,12 +6,12 @@ import ( type MockedGenreRepo struct { Error error - data map[string]model.Genre + Data map[string]model.Genre } func (r *MockedGenreRepo) init() { - if r.data == nil { - r.data = make(map[string]model.Genre) + if r.Data == nil { + r.Data = make(map[string]model.Genre) } } @@ -22,7 +22,7 @@ func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { r.init() var all model.Genres - for _, g := range r.data { + for _, g := range r.Data { all = append(all, g) } return all, nil @@ -33,6 +33,6 @@ func (r *MockedGenreRepo) Put(g *model.Genre) error { return r.Error } r.init() - r.data[g.ID] = *g + r.Data[g.ID] = *g return nil } diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 264dbe24c..907a9d487 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -7,14 +7,14 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - data map[int]model.Library + Data map[int]model.Library Err error } func (m *MockLibraryRepo) SetData(data model.Libraries) { - m.data = make(map[int]model.Library) + m.Data = make(map[int]model.Library) for _, d := range data { - m.data[d.ID] = d + m.Data[d.ID] = d } } @@ -22,14 +22,14 @@ func (m *MockLibraryRepo) GetAll(...model.QueryOptions) (model.Libraries, error) if m.Err != nil { return nil, m.Err } - return maps.Values(m.data), nil + return maps.Values(m.Data), nil } func (m *MockLibraryRepo) GetPath(id int) (string, error) { if m.Err != nil { return "", m.Err } - if lib, ok := m.data[id]; ok { + if lib, ok := m.Data[id]; ok { return lib.Path, nil } return "", model.ErrNotFound diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 4978e88bb..01d82e03b 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -14,40 +14,40 @@ import ( func CreateMockMediaFileRepo() *MockMediaFileRepo { return &MockMediaFileRepo{ - data: make(map[string]*model.MediaFile), + Data: make(map[string]*model.MediaFile), } } type MockMediaFileRepo struct { model.MediaFileRepository - data map[string]*model.MediaFile - err bool + Data map[string]*model.MediaFile + Err bool } func (m *MockMediaFileRepo) SetError(err bool) { - m.err = err + m.Err = err } func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { - m.data = make(map[string]*model.MediaFile) + m.Data = make(map[string]*model.MediaFile) for i, mf := range mfs { - m.data[mf.ID] = &mfs[i] + m.Data[mf.ID] = &mfs[i] } } func (m *MockMediaFileRepo) Exists(id string) (bool, error) { - if m.err { + if m.Err { return false, errors.New("error") } - _, found := m.data[id] + _, found := m.Data[id] return found, nil } func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { - if m.err { + if m.Err { return nil, errors.New("error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { // Intentionally clone the file and remove participants. This should // catch any caller that actually means to call GetWithParticipants res := *d @@ -58,52 +58,52 @@ func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { } func (m *MockMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { - if m.err { + if m.Err { return nil, errors.New("error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { return d, nil } return nil, model.ErrNotFound } func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) { - if m.err { + if m.Err { return nil, errors.New("error") } - values := slices.Collect(maps.Values(m.data)) + values := slices.Collect(maps.Values(m.Data)) return slice.Map(values, func(p *model.MediaFile) model.MediaFile { return *p }), nil } func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { - if m.err { + if m.Err { return errors.New("error") } if mf.ID == "" { mf.ID = id.NewRandom() } - m.data[mf.ID] = mf + m.Data[mf.ID] = mf return nil } func (m *MockMediaFileRepo) Delete(id string) error { - if m.err { + if m.Err { return errors.New("error") } - if _, ok := m.data[id]; !ok { + if _, ok := m.Data[id]; !ok { return model.ErrNotFound } - delete(m.data, id) + delete(m.Data, id) return nil } func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { - if m.err { + if m.Err { return errors.New("error") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { d.PlayCount++ d.PlayDate = ×tamp return nil @@ -112,12 +112,12 @@ func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { } func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { - if m.err { + if m.Err { return nil, errors.New("error") } - var res = make(model.MediaFiles, len(m.data)) + var res = make(model.MediaFiles, len(m.Data)) i := 0 - for _, a := range m.data { + for _, a := range m.Data { if a.AlbumID == artistId { res[i] = *a i++ @@ -128,17 +128,17 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro } func (m *MockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { - if m.err { + if m.Err { return nil, errors.New("error") } var res model.MediaFiles - for _, a := range m.data { + for _, a := range m.Data { if a.LibraryID == libId && a.Missing { res = append(res, *a) } } - for _, a := range m.data { + for _, a := range m.Data { if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool { return mediaFile.PID == a.PID }) != -1 { diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index 39dec17b3..9adc66e6d 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model" type MockedPropertyRepo struct { model.PropertyRepository Error error - data map[string]string + Data map[string]string } func (p *MockedPropertyRepo) init() { - if p.data == nil { - p.data = make(map[string]string) + if p.Data == nil { + p.Data = make(map[string]string) } } @@ -19,7 +19,7 @@ func (p *MockedPropertyRepo) Put(id string, value string) error { return p.Error } p.init() - p.data[id] = value + p.Data[id] = value return nil } @@ -28,7 +28,7 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) { return "", p.Error } p.init() - if v, ok := p.data[id]; ok { + if v, ok := p.Data[id]; ok { return v, nil } return "", model.ErrNotFound @@ -39,8 +39,8 @@ func (p *MockedPropertyRepo) Delete(id string) error { return p.Error } p.init() - if _, ok := p.data[id]; ok { - delete(p.data, id) + if _, ok := p.Data[id]; ok { + delete(p.Data, id) return nil } return model.ErrNotFound diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index a1a584320..279b735db 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -9,9 +9,9 @@ import ( type MockedRadioRepo struct { model.RadioRepository - data map[string]*model.Radio - all model.Radios - err bool + Data map[string]*model.Radio + All model.Radios + Err bool Options model.QueryOptions } @@ -20,44 +20,44 @@ func CreateMockedRadioRepo() *MockedRadioRepo { } func (m *MockedRadioRepo) SetError(err bool) { - m.err = err + m.Err = err } func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { - if m.err { + if m.Err { return 0, errors.New("error") } - return int64(len(m.data)), nil + return int64(len(m.Data)), nil } func (m *MockedRadioRepo) Delete(id string) error { - if m.err { + if m.Err { return errors.New("Error!") } - _, found := m.data[id] + _, found := m.Data[id] if !found { return errors.New("not found") } - delete(m.data, id) + delete(m.Data, id) return nil } func (m *MockedRadioRepo) Exists(id string) (bool, error) { - if m.err { + if m.Err { return false, errors.New("Error!") } - _, found := m.data[id] + _, found := m.Data[id] return found, nil } func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { - if m.err { + if m.Err { return nil, errors.New("Error!") } - if d, ok := m.data[id]; ok { + if d, ok := m.Data[id]; ok { return d, nil } return nil, model.ErrNotFound @@ -67,19 +67,19 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) if len(qo) > 0 { m.Options = qo[0] } - if m.err { + if m.Err { return nil, errors.New("Error!") } - return m.all, nil + return m.All, nil } func (m *MockedRadioRepo) Put(radio *model.Radio) error { - if m.err { + if m.Err { return errors.New("error") } if radio.ID == "" { radio.ID = id.NewRandom() } - m.data[radio.ID] = radio + m.Data[radio.ID] = radio return nil } diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 06b28af75..407c673eb 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -8,7 +8,7 @@ import ( type MockedScrobbleBufferRepo struct { Error error - data model.ScrobbleEntries + Data model.ScrobbleEntries } func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { @@ -20,7 +20,7 @@ func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { return nil, m.Error } userIds := make(map[string]struct{}) - for _, e := range m.data { + for _, e := range m.Data { if e.Service == service { userIds[e.UserID] = struct{}{} } @@ -36,7 +36,7 @@ func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, if m.Error != nil { return m.Error } - m.data = append(m.data, model.ScrobbleEntry{ + m.Data = append(m.Data, model.ScrobbleEntry{ MediaFile: model.MediaFile{ID: mediaFileId}, Service: service, UserID: userId, @@ -50,7 +50,7 @@ func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.Scrobble if m.Error != nil { return nil, m.Error } - for _, e := range m.data { + for _, e := range m.Data { if e.Service == service && e.UserID == userId { return &e, nil } @@ -63,13 +63,13 @@ func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { return m.Error } newData := model.ScrobbleEntries{} - for _, e := range m.data { + for _, e := range m.Data { if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID { continue } newData = append(newData, e) } - m.data = newData + m.Data = newData return nil } @@ -77,5 +77,5 @@ func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error } - return int64(len(m.data)), nil + return int64(len(m.Data)), nil } diff --git a/tests/mock_user_props_repo.go b/tests/mock_user_props_repo.go index b1880c999..1b1e17650 100644 --- a/tests/mock_user_props_repo.go +++ b/tests/mock_user_props_repo.go @@ -5,12 +5,12 @@ import "github.com/navidrome/navidrome/model" type MockedUserPropsRepo struct { model.UserPropsRepository Error error - data map[string]string + Data map[string]string } func (p *MockedUserPropsRepo) init() { - if p.data == nil { - p.data = make(map[string]string) + if p.Data == nil { + p.Data = make(map[string]string) } } @@ -19,7 +19,7 @@ func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { return p.Error } p.init() - p.data[userId+key] = value + p.Data[userId+key] = value return nil } @@ -28,7 +28,7 @@ func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) { return "", p.Error } p.init() - if v, ok := p.data[userId+key]; ok { + if v, ok := p.Data[userId+key]; ok { return v, nil } return "", model.ErrNotFound @@ -39,8 +39,8 @@ func (p *MockedUserPropsRepo) Delete(userId, key string) error { return p.Error } p.init() - if _, ok := p.data[userId+key]; ok { - delete(p.data, userId+key) + if _, ok := p.Data[userId+key]; ok { + delete(p.Data, userId+key) return nil } return model.ErrNotFound diff --git a/ui/src/album/AlbumDatesField.jsx b/ui/src/album/AlbumDatesField.jsx new file mode 100644 index 000000000..e4cdeedce --- /dev/null +++ b/ui/src/album/AlbumDatesField.jsx @@ -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 {label} +} diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index 690ae6604..f796f3b9d 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Card, CardContent, @@ -10,25 +10,25 @@ import { withWidth, } from '@material-ui/core' import { - useRecordContext, - useTranslate, ArrayField, - SingleFieldList, ChipField, Link, + SingleFieldList, + useRecordContext, + useTranslate, } from 'react-admin' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' import { ArtistLinkField, + CollapsibleComment, DurationField, formatRange, - SizeField, LoveButton, RatingField, + SizeField, useAlbumsPerPage, - CollapsibleComment, } from '../common' import config from '../config' 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 translate = useTranslate() const record = useRecordContext(props) + + // Create an array of detail elements let details = [] const addDetail = (obj) => { const id = details.length details.push({obj}) } - const originalYearRange = formatRange(record, 'originalYear') - const originalDate = record.originalDate - ? formatFullDate(record.originalDate) - : originalYearRange + // Calculate date related fields const yearRange = formatRange(record, 'year') const date = record.date ? formatFullDate(record.date) : yearRange - const releaseDate = record.releaseDate - ? formatFullDate(record.releaseDate) - : date - const showReleaseDate = date !== releaseDate && releaseDate.length > 3 - const showOriginalDate = - date !== originalDate && - originalDate !== releaseDate && - originalDate.length > 3 + const originalDate = record.originalDate + ? formatFullDate(record.originalDate) + : formatRange(record, 'originalYear') + const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) - showOriginalDate && - !isXsmall && + const dateToUse = originalDate || date + 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( - <> - {[translate('resources.album.fields.originalDate'), originalDate].join( - ' ', - )} - , + <>{[getReleaseDateLabel(), releaseDate].filter(Boolean).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( <> {record.songCount + @@ -215,6 +201,7 @@ const Details = (props) => { !isXsmall && addDetail() !isXsmall && addDetail() + // Return the details rendered with separators return <>{intersperse(details, ' · ')} } diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx new file mode 100644 index 000000000..e03022677 --- /dev/null +++ b/ui/src/album/AlbumDetails.test.jsx @@ -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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + 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( + +
+ , + ) + + expect(container).toMatchSnapshot() + }) + }) +}) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index efbfe6173..475519fca 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -13,14 +13,10 @@ import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' import { useDrag } from 'react-dnd' import subsonic from '../subsonic' -import { - AlbumContextMenu, - PlayButton, - ArtistLinkField, - RangeDoubleField, -} from '../common' +import { AlbumContextMenu, PlayButton, ArtistLinkField } from '../common' import { DraggableTypes } from '../consts' import clsx from 'clsx' +import { AlbumDatesField } from './AlbumDatesField.jsx' const useStyles = makeStyles( (theme) => ({ @@ -187,16 +183,7 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { {showArtist ? ( ) : ( - + )} ) diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx index d6d123895..453dbb167 100644 --- a/ui/src/album/AlbumInfo.jsx +++ b/ui/src/album/AlbumInfo.jsx @@ -20,6 +20,7 @@ import { ArtistLinkField, MultiLineTextField, ParticipantsInfo, + RangeField, } from '../common' const useStyles = makeStyles({ @@ -47,6 +48,20 @@ const AlbumInfo = (props) => { ), + date: + record?.maxYear && record.maxYear === record.minYear ? ( + + ) : ( + + ), + originalDate: + record?.maxOriginalYear && + record.maxOriginalYear === record.minOriginalYear ? ( + + ) : ( + + ), + releaseDate: , recordLabel: ( { 'songCount', 'playCount', 'year', + 'mood', 'duration', 'rating', 'size', diff --git a/ui/src/album/AlbumSongs.jsx b/ui/src/album/AlbumSongs.jsx index b5ca74a8a..bfb1a4d6a 100644 --- a/ui/src/album/AlbumSongs.jsx +++ b/ui/src/album/AlbumSongs.jsx @@ -124,6 +124,14 @@ const AlbumSongs = (props) => { size: isDesktop && , channels: isDesktop && , bpm: isDesktop && , + genre: , + mood: isDesktop && ( + r.tags?.mood?.[0] ?? ''} + sortable={false} + /> + ), rating: isDesktop && config.enableStarRating && ( { resource: 'albumSong', columns: toggleableFields, omittedColumns: ['title'], - defaultOff: ['channels', 'bpm', 'year', 'playCount', 'playDate', 'size'], + defaultOff: [ + 'channels', + 'bpm', + 'year', + 'playCount', + 'playDate', + 'size', + 'mood', + 'genre', + ], }) const bulkActionsLabel = isDesktop diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 7240f453b..1fa33d769 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -6,6 +6,7 @@ import { DateField, NumberField, TextField, + FunctionField, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' @@ -107,6 +108,13 @@ const AlbumTableView = ({ year: ( ), + mood: isDesktop && ( + r.tags?.mood?.[0] || ''} + sortable={false} + /> + ), duration: isDesktop && , size: isDesktop && , rating: config.enableStarRating && ( @@ -124,7 +132,7 @@ const AlbumTableView = ({ const columns = useSelectedFields({ resource: 'album', columns: toggleableFields, - defaultOff: ['createdAt'], + defaultOff: ['createdAt', 'size', 'mood'], }) return isXsmall ? ( diff --git a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap new file mode 100644 index 000000000..4f8f83531 --- /dev/null +++ b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap @@ -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`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + resources.album.fields.releaseDate Jun 15, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Desktop view > renders correctly with date 1`] = ` +
+ + May 1, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Desktop view > renders correctly with date and originalDate 1`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Desktop view > renders correctly with just year range 1`] = ` +
+ + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Desktop view > renders correctly with originalDate 1`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Desktop view > renders correctly with releaseDate 1`] = ` +
+ + resources.album.fields.releaseDate Jun 15, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > Mobile view > renders correctly with all date fields 1`] = ` +
+ + ♫ Mar 15, 2018 + + · + + ○ Jun 15, 2020 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with date 1`] = ` +
+ + ♫ May 1, 2020 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with date and originalDate 1`] = ` +
+ + ♫ Mar 15, 2018 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with just year range 1`] = ` +
+ + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with no date fields 1`] = ` +
+ + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with originalDate 1`] = ` +
+ + ♫ Mar 15, 2018 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with originalYear range 1`] = ` +
+ + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with releaseDate 1`] = ` +
+ + Jun 15, 2020 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > Mobile view > renders correctly with year range (start and end years) 1`] = ` +
+ + 12 resources.song.name + +
+`; + +exports[`Details component > renders correctly in mobile view 1`] = ` +
+ + ♫ Mar 15, 2018 + + · + + ○ Jun 15, 2020 + + · + + 12 resources.song.name + +
+`; + +exports[`Details component > renders correctly with all date fields 1`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + resources.album.fields.releaseDate Jun 15, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > renders correctly with date 1`] = ` +
+ + May 1, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > renders correctly with date and originalDate 1`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > renders correctly with just year range 1`] = ` +
+ + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > renders correctly with originalDate 1`] = ` +
+ + resources.album.fields.originalDate Mar 15, 2018 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; + +exports[`Details component > renders correctly with releaseDate 1`] = ` +
+ + resources.album.fields.releaseDate Jun 15, 2020 + + · + + 12 resources.song.name + + · + + + 01:00:00 + + + · + + + 100 KB + + +
+`; diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 2f3ff4299..b20fffeef 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -50,7 +50,7 @@ const ArtistDetails = (props) => { ) } -const AlbumShowLayout = (props) => { +const ArtistShowLayout = (props) => { const showContext = useShowContext(props) const record = useRecordContext() const { width } = props @@ -98,7 +98,7 @@ const ArtistShow = withWidth()((props) => { const controllerProps = useShowController(props) return ( - + ) }) diff --git a/ui/src/common/RangeDoubleField.jsx b/ui/src/common/RangeDoubleField.jsx deleted file mode 100644 index d388abeb7..000000000 --- a/ui/src/common/RangeDoubleField.jsx +++ /dev/null @@ -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 {subtitle} -} - -RangeDoubleField.propTypes = { - label: PropTypes.string, - record: PropTypes.object, - source: PropTypes.string.isRequired, -} - -RangeDoubleField.defaultProps = { - addLabel: true, -} diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx index 5adc1ebf0..77e91b653 100644 --- a/ui/src/common/SongInfo.jsx +++ b/ui/src/common/SongInfo.jsx @@ -75,6 +75,7 @@ export const SongInfo = (props) => { compilation: , bitRate: , bitDepth: , + sampleRate: , channels: , size: , updatedAt: , @@ -92,7 +93,14 @@ export const SongInfo = (props) => { 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) => { !record[field] && delete data[field] }) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 91d153e29..1a43047c1 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -13,7 +13,6 @@ export * from './Pagination' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' -export * from './RangeDoubleField' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 678e42cd4..76c4d5190 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -19,6 +19,7 @@ "updatedAt": "Updated at", "bitRate": "Bit rate", "bitDepth": "Bit depth", + "sampleRate": "Sample rate", "channels": "Channels", "discSubtitle": "Disc Subtitle", "starred": "Favourite", @@ -58,6 +59,7 @@ "genre": "Genre", "compilation": "Compilation", "year": "Year", + "date": "Recording Date", "originalDate": "Original", "releaseDate": "Released", "releases": "Release |||| Releases", diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 78182a36a..02d28d44f 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -168,6 +168,13 @@ const SongList = (props) => { ), bpm: isDesktop && , genre: , + mood: isDesktop && ( + r.tags?.mood?.[0] || ''} + sortable={false} + /> + ), comment: , path: , createdAt: , @@ -183,6 +190,7 @@ const SongList = (props) => { 'playDate', 'albumArtist', 'genre', + 'mood', 'comment', 'path', 'createdAt',