diff --git a/cmd/scan.go b/cmd/scan.go index ffb77b108..d8a563396 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -8,6 +8,7 @@ import ( "os" "strings" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -74,7 +75,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds) + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c2f6edaf8..5b9fd648f 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -63,7 +63,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) insights := metrics.GetInstance(dataStore) fileCache := artwork.GetImageCache() fFmpeg := ffmpeg.New() @@ -79,7 +80,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) return router } @@ -100,7 +101,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) @@ -169,7 +171,8 @@ func CreateScanner(ctx context.Context) model.Scanner { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) return modelScanner } @@ -186,7 +189,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher { provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - playlistsPlaylists := playlists.NewPlaylists(dataStore) + imageUploadService := core.NewImageUploadService() + playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher diff --git a/conf/configuration.go b/conf/configuration.go index b1d99335a..fbe60f983 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -71,6 +71,7 @@ type configOptions struct { CoverArtPriority string CoverArtQuality int ArtistArtPriority string + ArtistImageFolder string DiscArtPriority string LyricsPriority string EnableGravatar bool diff --git a/consts/consts.go b/consts/consts.go index 061aebd7b..9f4387ae6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -103,6 +103,12 @@ const ( DefaultCacheCleanUpInterval = 10 * time.Minute ) +// Entity types +const ( + EntityArtist = "artist" + EntityPlaylist = "playlist" +) + const ( AlbumPlayCountModeAbsolute = "absolute" AlbumPlayCountModeNormalized = "normalized" diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 5ca32f401..4b2359898 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -28,7 +28,7 @@ var _ = Describe("Artwork", func() { var ffmpeg *tests.MockFFmpeg var folderRepo *fakeFolderRepo ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album var arMultipleCovers model.Artist var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile @@ -44,8 +44,9 @@ var _ = Describe("Artwork", func() { } alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} + alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} arMultipleCovers = model.Artist{ID: "777", Name: "All options"} alMultipleCovers = model.Album{ ID: "666", @@ -193,6 +194,7 @@ var _ = Describe("Artwork", func() { ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alOnlyEmbed, alOnlyExternal, + alSingleDisc, }) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ mfWithEmbed, @@ -236,6 +238,28 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) Expect(path).To(Equal("al-444_0")) }) + It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { + mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Should fall back to disc art, which itself falls back to album art + Expect(path).To(Equal("dc-444:2_0")) + }) + It("falls back to album cover art for single-disc albums even with a disc number", func() { + mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) + + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + // Single-disc album should skip disc art and go straight to album art + Expect(path).To(Equal("al-888_0")) + }) }) }) Describe("playlistArtworkReader", func() { diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 98a2105eb..6de1d31d1 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -59,10 +59,11 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar } func (a *albumArtworkReader) Key() string { - var hash [16]byte + hashInput := conf.Server.CoverArtPriority if conf.Server.EnableExternalServices { - hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority)) + hashInput += conf.Server.Agents } + hash := md5.Sum([]byte(hashInput)) return fmt.Sprintf( "%s.%x.%t", a.cacheKey.Key(), diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 990942d87..96ba08b8f 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -29,11 +29,12 @@ const ( type artistReader struct { cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string + a *artwork + provider external.Provider + artist model.Artist + artistFolder string + imgFiles []string + imgFolderImgPath string // cached path from ArtistImageFolder lookup } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -71,9 +72,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt a.cacheKey.lastUpdate = *imagesUpdatedAt + if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *ar.UpdatedAt + } if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = artistFolderLastUpdate } + if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { + a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) + if a.imgFolderImgPath != "" { + if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = info.ModTime() + } + } + } a.cacheKey.artID = artID return a, nil } @@ -93,10 +105,15 @@ func (a *artistReader) LastUpdated() time.Time { } func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority) + ff := []sourceFunc{a.fromArtistUploadedImage()} + ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) return selectImageReader(ctx, a.artID, ff...) } +func (a *artistReader) fromArtistUploadedImage() sourceFunc { + return fromLocalFile(a.artist.UploadedImagePath()) +} + func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { var ff []sourceFunc for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { @@ -104,6 +121,8 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin switch { case pattern == "external": ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) + case pattern == "image-folder": + ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) default: @@ -196,3 +215,51 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu } return folderPath, folders[0].ImagesUpdatedAt, nil } + +func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return nil, "", nil + } + // Use cached path from newArtistArtworkReader if available, + // avoiding a second directory scan. + path := a.imgFolderImgPath + if path == "" { + path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) + } + if path == "" { + return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + return f, path, nil + } +} + +// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name +// (case-insensitive). Returns the full path, or empty string if not found. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := strings.TrimSuffix(name, filepath.Ext(name)) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 4aa71c9ca..5e2066aeb 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -413,6 +415,257 @@ var _ = Describe("artistArtworkReader", func() { }) }) }) + + Describe("fromArtistUploadedImage", func() { + var ( + tempDir string + reader *artistReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + // Create the artwork/artist directory + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) + + reader = &artistReader{} + }) + + When("artist has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("uploaded artist image")) + r.Close() + }) + }) + + When("artist has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.artist = model.Artist{ID: "ar-1"} + sf := reader.fromArtistUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("fromArtistImageFolder", func() { + var ( + ctx context.Context + tempDir string + ar *artistReader + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + ar = &artistReader{} + }) + + When("ArtistImageFolder is not configured", func() { + It("returns nil (skips)", func() { + conf.Server.ArtistImageFolder = "" + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + + When("image exists matching MBID", func() { + It("finds the image by MBID", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("MBID match is case-insensitive", func() { + It("finds the image regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" + imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") + Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("no MBID file exists but artist name file does", func() { + It("falls back to artist name match", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("name image")) + r.Close() + }) + }) + + When("artist name match is case-insensitive", func() { + It("matches regardless of case", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "test artist.jpg") + Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("both MBID and name files exist", func() { + It("prefers MBID over name match", func() { + conf.Server.ArtistImageFolder = tempDir + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + mbidPath := filepath.Join(tempDir, mbid+".jpg") + namePath := filepath.Join(tempDir, "Test Artist.jpg") + Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) + Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(mbidPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("mbid image")) + r.Close() + }) + }) + + When("no matching image found", func() { + It("returns an error", func() { + conf.Server.ArtistImageFolder = tempDir + // Create an unrelated file + Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + sf := ar.fromArtistImageFolder(ctx) + r, _, err := sf() + Expect(err).To(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(err.Error()).To(ContainSubstring("no image found")) + }) + }) + + When("cached imgFolderImgPath is set", func() { + It("uses cached path instead of scanning", func() { + conf.Server.ArtistImageFolder = tempDir + imgPath := filepath.Join(tempDir, "cached.jpg") + Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) + + ar.artist = model.Artist{Name: "Test Artist"} + ar.imgFolderImgPath = imgPath + sf := ar.fromArtistImageFolder(ctx) + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("cached image")) + r.Close() + }) + }) + }) + + Describe("findImageInArtistFolder", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + When("matching file exists by MBID", func() { + It("returns the file path", func() { + mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" + imgPath := filepath.Join(tempDir, mbid+".jpg") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, mbid, "Test") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("matching file exists by name", func() { + It("returns the file path", func() { + imgPath := filepath.Join(tempDir, "Test Artist.png") + Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) + + path := findImageInArtistFolder(tempDir, "", "Test Artist") + Expect(path).To(Equal(imgPath)) + }) + }) + + When("no matching file exists", func() { + It("returns empty string", func() { + path := findImageInArtistFolder(tempDir, "", "Unknown Artist") + Expect(path).To(BeEmpty()) + }) + }) + + When("folder does not exist", func() { + It("returns empty string", func() { + path := findImageInArtistFolder("/nonexistent/path", "", "Test") + Expect(path).To(BeEmpty()) + }) + }) + }) }) type fakeFolderRepo struct { diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index c72d9543d..cf25c8f5d 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -26,16 +26,22 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, } a.cacheKey.artID = artID - if al.UpdatedAt.After(mf.UpdatedAt) { + a.cacheKey.lastUpdate = mf.UpdatedAt + if al.UpdatedAt.After(a.cacheKey.lastUpdate) { a.cacheKey.lastUpdate = al.UpdatedAt - } else { - a.cacheKey.lastUpdate = mf.UpdatedAt + } + if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = *imagesUpdatedAt } return a, nil } @@ -60,6 +66,12 @@ func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, str fromFFmpegTag(ctx, a.a.ffmpeg, path), } } - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + // For multi-disc albums, fall back to disc artwork first; for single-disc albums, + // skip disc resolution (it would just fall through to album art anyway). + if len(a.album.Discs) > 1 { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) + } else { + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + } return selectImageReader(ctx, a.artID, ff...) } diff --git a/core/image_upload.go b/core/image_upload.go new file mode 100644 index 000000000..c2432b647 --- /dev/null +++ b/core/image_upload.go @@ -0,0 +1,71 @@ +package core + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error +} + +type imageUploadService struct{} + +func NewImageUploadService() ImageUploadService { + return &imageUploadService{} +} + +func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { + filename := imageFilename(entityID, name, ext) + absPath := model.UploadedImagePath(entityType, filename) + + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return "", fmt.Errorf("creating image directory: %w", err) + } + + // Remove old image if it exists + if oldPath != "" { + if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { + log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + } + } + + // Save new image + f, err := os.Create(absPath) + if err != nil { + return "", fmt.Errorf("creating image file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, reader); err != nil { + return "", fmt.Errorf("writing image file: %w", err) + } + + return filename, nil +} + +func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { + if path == "" { + return nil + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("removing image %q: %w", path, err) + } + return nil +} + +func imageFilename(id, name, ext string) string { + clean := utils.CleanFileName(name) + if clean == "" { + return id + ext + } + return id + "_" + clean + ext +} diff --git a/core/image_upload_test.go b/core/image_upload_test.go new file mode 100644 index 000000000..d13a04775 --- /dev/null +++ b/core/image_upload_test.go @@ -0,0 +1,99 @@ +package core_test + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageUploadService", func() { + var svc core.ImageUploadService + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + svc = core.NewImageUploadService() + }) + + Describe("SetImage", func() { + It("creates directory and saves image file", func() { + ctx := context.Background() + reader := strings.NewReader("fake image data") + filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) + + absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") + data, err := os.ReadFile(absPath) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + }) + + It("falls back to ID-only filename when name cleans to empty", func() { + ctx := context.Background() + reader := strings.NewReader("data") + filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") + Expect(err).ToNot(HaveOccurred()) + Expect(filename).To(Equal("pl-1.png")) + }) + + It("removes old image when replacing", func() { + ctx := context.Background() + oldDir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) + oldFile := filepath.Join(oldDir, "ar-1_old.png") + Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) + + reader := strings.NewReader("new image") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + Expect(oldFile).ToNot(BeAnExistingFile()) + + newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") + Expect(newPath).To(BeAnExistingFile()) + }) + + It("ignores missing old file without error", func() { + ctx := context.Background() + reader := strings.NewReader("data") + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("RemoveImage", func() { + It("removes the file at the given path", func() { + ctx := context.Background() + dir := filepath.Join(tmpDir, "artwork", "artist") + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, "ar-1_test.jpg") + Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) + + err := svc.RemoveImage(ctx, path) + Expect(err).ToNot(HaveOccurred()) + Expect(path).ToNot(BeAnExistingFile()) + }) + + It("succeeds when file does not exist", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds with empty path", func() { + ctx := context.Background() + err := svc.RemoveImage(ctx, "") + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 5312df95d..a6320bc7e 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -117,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -135,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -154,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -173,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -190,7 +191,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -207,7 +208,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -224,7 +225,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -242,7 +243,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") @@ -256,7 +257,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -283,7 +284,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -358,7 +359,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -418,7 +419,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("handles relative paths that reference files in other libraries", func() { @@ -574,7 +575,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -617,7 +618,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 0649b16a9..a0086cd2d 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -2,7 +2,6 @@ package playlists import ( "context" - "fmt" "io" "os" "path/filepath" @@ -12,6 +11,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -50,12 +50,20 @@ type Playlists interface { TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -type playlists struct { - ds model.DataStore +// ImageUploadService is a local interface satisfied by core.ImageUploadService. +// Defined here to avoid an import cycle between core and core/playlists. +type ImageUploadService interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error } -func NewPlaylists(ds model.DataStore) Playlists { - return &playlists{ds: ds} +type playlists struct { + ds model.DataStore + imgUpload ImageUploadService +} + +func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { + return &playlists{ds: ds, imgUpload: imgUpload} } func InPath(folder model.Folder) bool { @@ -288,33 +296,13 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R return err } - filename := pls.ImageFilename(ext) oldPath := pls.UploadedImagePath() - pls.UploadedImage = filename - absPath := pls.UploadedImagePath() - - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return fmt.Errorf("creating playlist images directory: %w", err) - } - - // Remove old image if it exists - if oldPath != "" { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove old playlist image", "path", oldPath, err) - } - } - - // Save new image - f, err := os.Create(absPath) + filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext) if err != nil { - return fmt.Errorf("creating playlist image file: %w", err) - } - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return fmt.Errorf("writing playlist image file: %w", err) + return err } + pls.UploadedImage = filename return s.ds.Playlist(ctx).Put(pls) } @@ -324,10 +312,8 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { return err } - if path := pls.UploadedImagePath(); path != "" { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove playlist image", "path", path, err) - } + if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil { + return err } pls.UploadedImage = "" diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index ec73c329a..52d5c88d8 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -41,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to delete their playlist", func() { @@ -80,7 +81,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("creates a new playlist with owner set from context", func() { @@ -138,7 +139,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to update their playlist", func() { @@ -201,7 +202,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to add tracks", func() { @@ -249,7 +250,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to remove tracks", func() { @@ -283,7 +284,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("allows owner to reorder", func() { @@ -312,7 +313,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("saves image file and updates UploadedImage", func() { @@ -382,7 +383,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) It("removes file and clears UploadedImage", func() { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 70ca8a9e4..097bc6310 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/deluan/rest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -36,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) }) Describe("Save", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index 153df7262..276d9556a 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -23,6 +23,8 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + NewImageUploadService, + wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go new file mode 100644 index 000000000..964e346f5 --- /dev/null +++ b/db/migrations/20260315233131_add_artist_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage) +} + +func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`) + return err +} + +func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql new file mode 100644 index 000000000..a2e1183e9 --- /dev/null +++ b/db/migrations/20260316000000_normalize_timestamps.sql @@ -0,0 +1,74 @@ +-- +goose Up + +-- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format. +-- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32) +-- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering. + +UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%'; +UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%'; + +UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; + +UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%'; +UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%'; +UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%'; + +UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%'; + +UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%'; +UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%'; + +UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%'; + +UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%'; +UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%'; + +UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%'; + +UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; +UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%'; +UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%'; + +UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%'; +UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%'; + +UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; +UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; + +-- Replace plain indexes with expression indexes for datetime()-based sorting +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(datetime(created_at)); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(datetime(updated_at)); + +-- +goose Down +DROP INDEX IF EXISTS album_created_at; +CREATE INDEX album_created_at ON album(created_at); +DROP INDEX IF EXISTS album_updated_at; +CREATE INDEX album_updated_at ON album(updated_at); diff --git a/model/artist.go b/model/artist.go index 309ee800f..2085f0051 100644 --- a/model/artist.go +++ b/model/artist.go @@ -4,6 +4,8 @@ import ( "maps" "slices" "time" + + "github.com/navidrome/navidrome/consts" ) type Artist struct { @@ -34,6 +36,8 @@ type Artist struct { Missing bool `structs:"missing" json:"missing"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` } @@ -58,6 +62,10 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } +func (a Artist) UploadedImagePath() string { + return UploadedImagePath(consts.EntityArtist, a.UploadedImage) +} + // Roles returns the roles this artist has participated in., based on the Stats field func (a Artist) Roles() []Role { return slices.Collect(maps.Keys(a.Stats)) diff --git a/model/artist_test.go b/model/artist_test.go new file mode 100644 index 000000000..5a24504eb --- /dev/null +++ b/model/artist_test.go @@ -0,0 +1,30 @@ +package model_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artist", func() { + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + a := model.Artist{ID: "ar-1"} + Expect(a.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} + Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg"))) + }) + }) +}) diff --git a/model/image.go b/model/image.go new file mode 100644 index 000000000..68d8ae64c --- /dev/null +++ b/model/image.go @@ -0,0 +1,17 @@ +package model + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" +) + +// UploadedImagePath returns the absolute filesystem path for a manually uploaded +// entity cover image. Returns empty string if filename is empty. +func UploadedImagePath(entityType, filename string) string { + if filename == "" { + return "" + } + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) +} diff --git a/model/mediafile.go b/model/mediafile.go index 20532bfb9..ec83b76fd 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -119,7 +119,16 @@ func (mf MediaFile) CoverArtID() ArtworkID { if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt { return artworkIDFromMediaFile(mf) } - // if it does not have a coverArt, fallback to the album cover + // Otherwise fallback to disc (if available) or album cover + return mf.DiscCoverArtID() +} + +// DiscCoverArtID returns the disc artwork ID when the media file has a disc number, +// otherwise it returns the album artwork ID. +func (mf MediaFile) DiscCoverArtID() ArtworkID { + if mf.DiscNumber > 0 { + return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + } return mf.AlbumCoverArtID() } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 207d3c155..038ac93d5 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -504,13 +504,26 @@ var _ = Describe("MediaFile", func() { Expect(id.Kind).To(Equal(KindMediaFileArtwork)) Expect(id.ID).To(Equal(mf.ID)) }) - It("returns its album id if HasCoverArt is false", func() { + It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() { + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:2")) + }) + It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false} id := mf.CoverArtID() Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) - It("returns its album id if EnableMediaFileCoverArt is disabled", func() { + It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindDiscArtwork)) + Expect(id.ID).To(Equal("1:3")) + }) + It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() { conf.Server.EnableMediaFileCoverArt = false mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() diff --git a/model/playlist.go b/model/playlist.go index 5c9052eb7..e2f93993d 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,15 +1,12 @@ package model import ( - "path/filepath" "slices" "strconv" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/utils" ) type Playlist struct { @@ -108,16 +105,6 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { pls.refreshStats() } -// ImageFilename returns a human-friendly filename for an uploaded playlist cover image. -// Format: _, falling back to if the name cleans to empty. -func (pls Playlist) ImageFilename(ext string) string { - clean := utils.CleanFileName(pls.Name) - if clean == "" { - return pls.ID + ext - } - return pls.ID + "_" + clean + ext -} - func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } @@ -127,10 +114,7 @@ func (pls Playlist) CoverArtID() ArtworkID { // This does NOT cover sidecar images or external URLs — those are resolved // by the artwork reader's fallback chain. func (pls Playlist) UploadedImagePath() string { - if pls.UploadedImage == "" { - return "" - } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage) + return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) } type Playlists []Playlist diff --git a/model/playlist_test.go b/model/playlist_test.go index 98dd4e978..a54cecd53 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -7,28 +7,6 @@ import ( ) var _ = Describe("Playlist", func() { - Describe("ImageFilename", func() { - It("returns ID_cleanname.ext for a normal name", func() { - pls := model.Playlist{ID: "abc123", Name: "My Cool Playlist"} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123_my_cool_playlist.jpg")) - }) - - It("falls back to ID.ext when name cleans to empty", func() { - pls := model.Playlist{ID: "abc123", Name: "!!!"} - Expect(pls.ImageFilename(".png")).To(Equal("abc123.png")) - }) - - It("falls back to ID.ext for empty name", func() { - pls := model.Playlist{ID: "abc123", Name: ""} - Expect(pls.ImageFilename(".jpg")).To(Equal("abc123.jpg")) - }) - - It("handles names with special characters", func() { - pls := model.Playlist{ID: "x1", Name: "Rock & Roll! (2024)"} - Expect(pls.ImageFilename(".webp")).To(Equal("x1_rock__roll_2024.webp")) - }) - }) - Describe("ToM3U8()", func() { var pls model.Playlist BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 7207bf5a2..c51a5beb1 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -143,9 +143,9 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "updated_at" + return "datetime(album.updated_at)" } - return "created_at" + return "datetime(album.created_at)" } func recentlyPlayedFilter(string, any) Sqlizer { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 66b6eba9f..2792cec97 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -85,6 +85,53 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("recently_added sort", func() { + It("sorts correctly regardless of timestamp format (T-format vs space-format)", func() { + // Both timestamps share the same date prefix "2024-01-15" so the T vs space + // character at position 10 determines sort order in raw string comparison. + // Without normalization, 'T' (ASCII 84) > ' ' (ASCII 32) makes the older + // T-format timestamp sort AFTER the newer space-format one. + + // Older album: morning of Jan 15, stored in T-format + olderAlbum := &model.Album{LibraryID: 1, ID: "ts-older", Name: "Older Album"} + Expect(albumRepo.Put(olderAlbum)).To(Succeed()) + _, err := albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15T08:00:00Z"). + Where(squirrel.Eq{"id": "ts-older"})) + Expect(err).ToNot(HaveOccurred()) + + // Newer album: evening of Jan 15, stored in space-format + newerAlbum := &model.Album{LibraryID: 1, ID: "ts-newer", Name: "Newer Album"} + Expect(albumRepo.Put(newerAlbum)).To(Succeed()) + _, err = albumRepo.executeSQL(squirrel.Update("album"). + Set("created_at", "2024-01-15 20:00:00+00:00"). + Where(squirrel.Eq{"id": "ts-newer"})) + Expect(err).ToNot(HaveOccurred()) + + albums, err := albumRepo.GetAll(model.QueryOptions{Sort: "recently_added", Order: "desc"}) + Expect(err).ToNot(HaveOccurred()) + + // Find positions of our test albums + olderIdx, newerIdx := -1, -1 + for i, a := range albums { + switch a.ID { + case "ts-older": + olderIdx = i + case "ts-newer": + newerIdx = i + } + } + Expect(olderIdx).To(BeNumerically(">=", 0), "older album not found in results") + Expect(newerIdx).To(BeNumerically(">=", 0), "newer album not found in results") + // Newer album (evening, space-format) should come before older album (morning, T-format) in desc order + Expect(newerIdx).To(BeNumerically("<", olderIdx), + "Newer album (20:00 space-format) should sort before older album (08:00 T-format) in desc order") + + // Clean up + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"ts-older", "ts-newer"}})) + }) + }) + Context("Filters", func() { var albumWithoutAnnotation model.Album diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index bce5a3a6e..7a0281f33 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -31,13 +31,14 @@ "mood": "Настроение", "participants": "Допълнителни участници", "tags": "Допълнителни етикети", - "mappedTags": "", - "rawTags": "", + "mappedTags": "Картирани тагове", + "rawTags": "Сурови тагове", "bitDepth": "Битова дълбочина", - "sampleRate": "", + "sampleRate": "Честота на семплиране", "missing": "Липсва", - "libraryName": "", - "composer": "" + "libraryName": "Библиотека", + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "Пусни по-късно", @@ -47,8 +48,8 @@ "download": "Свали", "playNext": "Следваща", "info": "Информация", - "showInPlaylist": "", - "instantMix": "" + "showInPlaylist": "Показване в плейлиста", + "instantMix": "Незабавен микс" } }, "album": { @@ -80,7 +81,7 @@ "mood": "Настроение", "date": "Дата на запис", "missing": "Липсва", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "playAll": "Пусни", @@ -129,12 +130,12 @@ "remixer": "Ремиксер |||| Ремиксери", "djmixer": "DJ миксер |||| DJ миксери", "performer": "Изпълнител |||| Изпълнители", - "maincredit": "" + "maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители" }, "actions": { - "shuffle": "", - "radio": "", - "topSongs": "" + "shuffle": "Разбъркване", + "radio": "Радио", + "topSongs": "Топ песни" } }, "user": { @@ -152,11 +153,11 @@ "newPassword": "Нова парола", "token": "Токен", "lastAccessAt": "Последен достъп", - "libraries": "" + "libraries": "Библиотеки" }, "helperTexts": { "name": "Промените в името ще бъдат отразени при следващото влизане", - "libraries": "" + "libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране" }, "notifications": { "created": "Потребителят е създаден", @@ -166,11 +167,11 @@ "message": { "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.", "clickHereForToken": "Кликнете тук, за да получите Вашия токен", - "selectAllLibraries": "", - "adminAutoLibraries": "" + "selectAllLibraries": "Изберете всички библиотеки", + "adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки" }, "validation": { - "librariesRequired": "" + "librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права" } }, "player": { @@ -215,16 +216,16 @@ "export": "Експорт", "makePublic": "Направи публичен", "makePrivate": "Направи личен", - "saveQueue": "", - "searchOrCreate": "", - "pressEnterToCreate": "", - "removeFromSelection": "" + "saveQueue": "Запазване на опашката в плейлист", + "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", + "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", + "removeFromSelection": "Премахване от селекцията" }, "message": { "duplicate_song": "Добави дублирани песни", "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", - "noPlaylistsFound": "", - "noPlaylists": "" + "noPlaylistsFound": "Няма намерени плейлисти", + "noPlaylists": "Няма налични плейлисти" } }, "radio": { @@ -263,7 +264,7 @@ "path": "Път", "size": "Размер", "updatedAt": "Изчезнал на", - "libraryName": "" + "libraryName": "Библиотека" }, "actions": { "remove": "Премахни", @@ -275,134 +276,136 @@ "empty": "Няма липсващи файлове" }, "library": { - "name": "", + "name": "Библиотека |||| Библиотеки", "fields": { - "name": "", - "path": "", - "remotePath": "", - "lastScanAt": "", - "songCount": "", - "albumCount": "", - "artistCount": "", - "totalSongs": "", - "totalAlbums": "", - "totalArtists": "", - "totalFolders": "", - "totalFiles": "", - "totalMissingFiles": "", - "totalSize": "", - "totalDuration": "", - "defaultNewUsers": "", - "createdAt": "", - "updatedAt": "" + "name": "Име", + "path": "Път", + "remotePath": "Отдалечен път", + "lastScanAt": "Последно сканиране", + "songCount": "Песни", + "albumCount": "Албуми", + "artistCount": "Изпълнители", + "totalSongs": "Песни", + "totalAlbums": "Албуми", + "totalArtists": "Изпълнители", + "totalFolders": "Папки", + "totalFiles": "Файлове", + "totalMissingFiles": "Липсващи файлове", + "totalSize": "Общ размер", + "totalDuration": "Продължителност", + "defaultNewUsers": "По подразбиране за нови потребители", + "createdAt": "Създаден", + "updatedAt": "Актуализиран" }, "sections": { - "basic": "", - "statistics": "" + "basic": "Основна информация", + "statistics": "Статистика" }, "actions": { - "scan": "", - "manageUsers": "", - "viewDetails": "", + "scan": "Сканирай библиотеката", + "manageUsers": "Управление на потребителския достъп", + "viewDetails": "Преглед на подробности", "quickScan": "Quick Scan", - "fullScan": "" + "fullScan": "Пълно сканиране" }, "notifications": { - "created": "", - "updated": "", - "deleted": "", - "scanStarted": "", - "scanCompleted": "", - "quickScanStarted": "", - "fullScanStarted": "", - "scanError": "" + "created": "Библиотеката е създадена успешно", + "updated": "Библиотеката е актуализирана успешно", + "deleted": "Библиотеката е изтрита успешно", + "scanStarted": "Сканирането на библиотеката започна", + "scanCompleted": "Сканирането на библиотеката е завършено", + "quickScanStarted": "Бързото сканиране започна", + "fullScanStarted": "Пълното сканиране започна", + "scanError": "Грешка при стартиране на сканирането. Проверете лог файловете" }, "validation": { - "nameRequired": "", - "pathRequired": "", - "pathNotDirectory": "", - "pathNotFound": "", - "pathNotAccessible": "", - "pathInvalid": "" + "nameRequired": "Името на библиотеката е задължително", + "pathRequired": "Пътят към библиотеката е задължителен", + "pathNotDirectory": "Пътят до библиотеката трябва да е директория", + "pathNotFound": "Пътят към библиотеката не е намерен", + "pathNotAccessible": "Пътят до библиотеката не е достъпен", + "pathInvalid": "Невалиден път към библиотеката" }, "messages": { - "deleteConfirm": "", - "scanInProgress": "", - "noLibrariesAssigned": "" + "deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.", + "scanInProgress": "Сканирането е в ход...", + "noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител" } }, "plugin": { - "name": "", + "name": "Плъгин |||| Плъгини", "fields": { - "id": "", - "name": "", - "description": "", - "version": "", - "author": "", - "website": "", - "permissions": "", - "enabled": "", - "status": "", - "path": "", - "lastError": "", - "hasError": "", - "updatedAt": "", - "createdAt": "", - "configKey": "", - "configValue": "", - "allUsers": "", - "selectedUsers": "", - "allLibraries": "", - "selectedLibraries": "" + "id": "ID номер", + "name": "Име", + "description": "Описание", + "version": "Версия", + "author": "Автор", + "website": "Уебсайт", + "permissions": "Разрешения", + "enabled": "Активирано", + "status": "Статус", + "path": "Път", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Актуализирано", + "createdAt": "Инсталирано", + "configKey": "Ключ", + "configValue": "Стойност", + "allUsers": "Разрешаване на всички потребители", + "selectedUsers": "Избрани потребители", + "allLibraries": "Разрешаване на всички библиотеки", + "selectedLibraries": "Избрани библиотеки", + "allowWriteAccess": "" }, "sections": { - "status": "", - "info": "", - "configuration": "", - "manifest": "", - "usersPermission": "", - "libraryPermission": "" + "status": "Статус", + "info": "Информация за плъгина", + "configuration": "Конфигурация", + "manifest": "Манифест", + "usersPermission": "Права за потребители", + "libraryPermission": "Права за библиотека" }, "status": { - "enabled": "", - "disabled": "" + "enabled": "Активирано", + "disabled": "Деактивирано" }, "actions": { - "enable": "", - "disable": "", - "disabledDueToError": "", - "disabledUsersRequired": "", - "disabledLibrariesRequired": "", - "addConfig": "", - "rescan": "" + "enable": "Активирай", + "disable": "Деактивирай", + "disabledDueToError": "Поправете грешката преди активиране", + "disabledUsersRequired": "Изберете потребители преди активиране", + "disabledLibrariesRequired": "Изберете библиотеки преди активиране", + "addConfig": "Добавяне на конфигурация", + "rescan": "Повторно сканиране" }, "notifications": { - "enabled": "", - "disabled": "", - "updated": "", - "error": "" + "enabled": "Плъгинът е активиран", + "disabled": "Плъгинът е деактивиран", + "updated": "Плъгинът е актуализиран", + "error": "Грешка при актуализиране на плъгина" }, "validation": { - "invalidJson": "" + "invalidJson": "Конфигурацията трябва да е валиден JSON" }, "messages": { - "configHelp": "", - "clickPermissions": "", - "noConfig": "", - "allUsersHelp": "", - "noUsers": "", - "permissionReason": "", - "usersRequired": "", - "allLibrariesHelp": "", - "noLibraries": "", - "librariesRequired": "", - "requiredHosts": "", - "configValidationError": "", - "schemaRenderError": "" + "configHelp": "Конфигурирайте плъгина, използвайки двойки ключ-стойност. Оставете празно, ако плъгинът не изисква конфигурация.", + "clickPermissions": "Кликнете върху разрешение за подробности", + "noConfig": "Няма зададена конфигурация", + "allUsersHelp": "Когато е активиран, плъгинът ще има достъп до всички потребители, включително тези, създадени в бъдеще.", + "noUsers": "Няма избрани потребители", + "permissionReason": "Причина", + "usersRequired": "Този плъгин изисква достъп до потребителска информация. Изберете до кои потребители плъгинът може да има достъп или активирайте „Разрешаване на всички потребители“.", + "allLibrariesHelp": "Когато е активиран, плъгинът ще има достъп до всички библиотеки, включително тези, създадени в бъдеще.", + "noLibraries": "Няма избрани библиотеки", + "librariesRequired": "Този плъгин изисква достъп до информация за библиотеката. Изберете до кои библиотеки плъгинът може да има достъп или активирайте „Разрешаване на всички библиотеки“.", + "requiredHosts": "Необходими хостове", + "configValidationError": "Валидирането на конфигурацията не бе успешно:", + "schemaRenderError": "Не може да се изобрази формята за конфигурация. Схемата на плъгина може да е невалидна.", + "allowWriteAccessHelp": "" }, "placeholders": { - "configKey": "", - "configValue": "" + "configKey": "ключ", + "configValue": "стойност" } } }, @@ -586,9 +589,9 @@ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", "remove_all_missing_title": "Премахни всички липсващи файлове", "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "noSimilarSongsFound": "", - "noTopSongsFound": "", - "startingInstantMix": "" + "noSimilarSongsFound": "Не са намерени подобни песни", + "noTopSongsFound": "Няма намерени топ песни", + "startingInstantMix": "Зареждане на незабавен микс..." }, "menu": { "library": "Библиотека", @@ -619,10 +622,10 @@ "playlists": "Плейлисти", "sharedPlaylists": "Споделени плейлисти", "librarySelector": { - "allLibraries": "", - "multipleLibraries": "", - "selectLibraries": "", - "none": "" + "allLibraries": "Всички библиотеки (%{count})", + "multipleLibraries": "%{selected} от %{total} библиотеки", + "selectLibraries": "Изберете библиотеки", + "none": "Няма" } }, "player": { @@ -655,7 +658,7 @@ "homepage": "Начална страница", "source": "Програмен код", "featureRequests": "Заявете функционалност", - "lastInsightsCollection": "", + "lastInsightsCollection": "Последна колекция от анализи", "insights": { "disabled": "Деактивиран", "waiting": "Изчакване" @@ -669,12 +672,13 @@ "configName": "Име на конфигурация", "environmentVariable": "Променлива на средата", "currentValue": "Текуща стойност", - "configurationFile": "", + "configurationFile": "Конфигурационен файл", "exportToml": "Експортиране на конфигурация (TOML)", "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML", "exportFailed": "Неуспешно копиране на конфигурация", - "devFlagsHeader": "", - "devFlagsComment": "" + "devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)", + "devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.", + "downloadToml": "Изтегляне на конфигурация (TOML)" } }, "activity": { @@ -687,7 +691,7 @@ "scanType": "Последно сканиране", "status": "Грешка при сканиране", "elapsedTime": "Изминало време", - "selectiveScan": "" + "selectiveScan": "Селективен" }, "help": { "title": "Бързи клавиши на Navidrome", @@ -704,8 +708,8 @@ } }, "nowPlaying": { - "title": "", - "empty": "", - "minutesAgo": "" + "title": "Сега свири", + "empty": "Нищо не се възпроизвежда", + "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" } -} \ No newline at end of file +} diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 264a76639..1ef2ce016 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -37,7 +37,8 @@ "sampleRate": "Freqüencia de mostreig", "missing": "Desaparegut", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reprodueix després", @@ -353,7 +354,8 @@ "allUsers": "Permet tots els usuaris", "selectedUsers": "Usuaris seleccionats", "allLibraries": "Permet totes les llibreries", - "selectedLibraries": "Biblioteques seleccionades" + "selectedLibraries": "Biblioteques seleccionades", + "allowWriteAccess": "" }, "sections": { "status": "Estat", @@ -398,7 +400,8 @@ "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».", "requiredHosts": "Hosts requerits", "configValidationError": "Ha fallat la validació de la configuració:", - "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid." + "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clau", @@ -674,7 +677,8 @@ "exportSuccess": "Configuració exportada al porta-retalls en format TOML", "exportFailed": "La còpia de la configuració ha fallat", "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)", - "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures" + "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures", + "downloadToml": "Descarrega la configuració (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "No s'està reproduint res", "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts" } -} \ No newline at end of file +} diff --git a/resources/i18n/da.json b/resources/i18n/da.json index 01d0856d6..a47b30bbc 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingfrekvens", "missing": "Manglende", "libraryName": "Bibliotek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Afspil senere", @@ -353,7 +354,8 @@ "allUsers": "Tillad alle brugere", "selectedUsers": "Valgte brugere", "allLibraries": "Tillad alle biblioteker", - "selectedLibraries": "Valgte biblioteker" + "selectedLibraries": "Valgte biblioteker", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +400,8 @@ "librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.", "requiredHosts": "Påkrævede hosts", "configValidationError": "Konfigurationsvalidering mislykkedes:", - "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt." + "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nøgle", @@ -675,7 +678,7 @@ "exportFailed": "Kunne ikke kopiere konfigurationen", "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver", - "downloadToml": "" + "downloadToml": "Download konfigurationen (TOML)" } }, "activity": { @@ -709,4 +712,4 @@ "empty": "Intet afspilles nu", "minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden" } -} \ No newline at end of file +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json index 568c65c51..ab1760ed5 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -37,7 +37,8 @@ "sampleRate": "Samplerate", "missing": "Fehlend", "libraryName": "Bibliothek", - "composer": "Komponist" + "composer": "Komponist", + "disc": "" }, "actions": { "addToQueue": "Später abspielen", @@ -353,7 +354,8 @@ "allUsers": "Alle Benutzer", "selectedUsers": "Ausgewählte Benutzer", "allLibraries": "Alle Bibliotheken", - "selectedLibraries": "Ausgewählte Bibliotheken" + "selectedLibraries": "Ausgewählte Bibliotheken", + "allowWriteAccess": "Schreibzugriff erlauben" }, "sections": { "status": "Status", @@ -398,7 +400,8 @@ "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.", "requiredHosts": "Benötigte Hosts", "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", - "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt." + "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", + "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." }, "placeholders": { "configKey": "Schlüssel", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", "noTopSongsFound": "Keine beliebten Titel gefunden", - "startingInstantMix": "Lade Sofort-Mix..." + "startingInstantMix": "Lade Sofort-Mix...", + "uploadCover": "Cover hochladen", + "removeCover": "Cover entfernen", + "coverUploaded": "Cover aktualisiert", + "coverRemoved": "Cover entfernt", + "coverUploadError": "Fehler beim Hochladen des Covers", + "coverRemoveError": "Fehler beim Entfernen des Covers" }, "menu": { "library": "Bibliothek", @@ -674,7 +683,8 @@ "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert", "exportFailed": "Fehler beim Kopieren der Konfiguration", "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)", - "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden" + "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden", + "downloadToml": "Konfiguration Herunterladen (TOML)" } }, "activity": { @@ -708,4 +718,4 @@ "empty": "Keine Wiedergabe", "minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten" } -} \ No newline at end of file +} diff --git a/resources/i18n/el.json b/resources/i18n/el.json index 02d0b06c4..019d05978 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -37,7 +37,8 @@ "sampleRate": "Ποσοστό δειγματοληψίας", "missing": "Απών", "libraryName": "Βιβλιοθήκη", - "composer": "Συνθέτης" + "composer": "Συνθέτης", + "disc": "" }, "actions": { "addToQueue": "Αναπαραγωγη Μετα", @@ -353,7 +354,8 @@ "allUsers": "Επιτρέψτε όλους τους χρήστες", "selectedUsers": "Επιλογή χρηστών", "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", - "selectedLibraries": "Επιλεγμένες βιβλιοθήκες" + "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", + "allowWriteAccess": "" }, "sections": { "status": "Κατάσταση", @@ -398,7 +400,8 @@ "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", "requiredHosts": "Απαιτούμενοι hosts", "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", - "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο." + "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "κλειδί", @@ -674,7 +677,8 @@ "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", - "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις" + "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις", + "downloadToml": "Λήψη διαμόρφωσης (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Δεν παίζει τίποτα", "minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν" } -} \ No newline at end of file +} diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 38c1379c9..29d1a367f 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -37,7 +37,8 @@ "sampleRate": "Frecuencia de muestreo", "missing": "Faltante", "libraryName": "Biblioteca", - "composer": "Compositor" + "composer": "Compositor", + "disc": "" }, "actions": { "addToQueue": "Reproducir después", @@ -353,7 +354,8 @@ "allUsers": "Permitir todos los usuarios", "selectedUsers": "Usuarios seleccionados", "allLibraries": "Permitir todas las bibliotecas", - "selectedLibraries": "Bibliotecas seleccionadas" + "selectedLibraries": "Bibliotecas seleccionadas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +400,8 @@ "librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.", "requiredHosts": "Hosts requeridos", "configValidationError": "La validación de la configuración falló:", - "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido." + "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +677,8 @@ "exportSuccess": "Configuración exportada al portapapeles en formato TOML", "exportFailed": "Error al copiar la configuración", "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)", - "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras" + "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras", + "downloadToml": "Descargar la configuración (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Nada en reproducción", "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index 0d260fb44..59f353350 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -37,7 +37,8 @@ "sampleRate": "Näytteenottotaajuus", "missing": "Puuttuva", "libraryName": "Kirjasto", - "composer": "Säveltäjä" + "composer": "Säveltäjä", + "disc": "" }, "actions": { "addToQueue": "Lisää jonoon", @@ -353,7 +354,8 @@ "allUsers": "Salli kaikki käyttäjät", "selectedUsers": "Valitut käyttäjät", "allLibraries": "Salli kaikki kirjastot", - "selectedLibraries": "Valitut kirjastot" + "selectedLibraries": "Valitut kirjastot", + "allowWriteAccess": "Salli kirjoitusoikeus" }, "sections": { "status": "Tila", @@ -398,7 +400,8 @@ "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.", "requiredHosts": "Vaaditut palvelimet", "configValidationError": "Määrityksen validointi epäonnistui:", - "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen." + "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", + "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus." }, "placeholders": { "configKey": "avain", @@ -588,7 +591,13 @@ "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt", - "startingInstantMix": "Ladataan Pikasekoitus..." + "startingInstantMix": "Ladataan Pikasekoitus...", + "uploadCover": "Lataa kansikuva", + "removeCover": "Poista kansikuva", + "coverUploaded": "Kansikuva päivitetty", + "coverRemoved": "Kansikuva poistettu", + "coverUploadError": "Virhe ladattaessa kansikuvaa", + "coverRemoveError": "Virhe poistettaessa kansikuvaa" }, "menu": { "library": "Kirjasto", @@ -674,7 +683,8 @@ "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa", "exportFailed": "Määritysten kopiointi epäonnistui", "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", - "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" + "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa", + "downloadToml": "Lataa määritykset (TOML)" } }, "activity": { @@ -708,4 +718,4 @@ "empty": "Ei soita mitään", "minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten" } -} \ No newline at end of file +} diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 66bd454cc..891fde03a 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -37,7 +37,8 @@ "sampleRate": "Fréquence d'échantillonnage", "missing": "Manquant", "libraryName": "Bibliothèque", - "composer": "Compositeur·e" + "composer": "Compositeur·e", + "disc": "" }, "actions": { "addToQueue": "Ajouter à la file", @@ -353,7 +354,8 @@ "allUsers": "Autoriser tous les utilisateur·rices", "selectedUsers": "Utilisateur·rices sélectionné.e.s", "allLibraries": "Autoriser toutes les bibliothèques", - "selectedLibraries": "Bibliothèques sélectionnées" + "selectedLibraries": "Bibliothèques sélectionnées", + "allowWriteAccess": "" }, "sections": { "status": "Statut", @@ -398,7 +400,8 @@ "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.", "requiredHosts": "Hôtes requis", "configValidationError": "Erreur lors de la validation de la configuration", - "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide." + "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clef", @@ -674,7 +677,8 @@ "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML", "exportFailed": "Une erreur est survenue en copiant la configuration", "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)", - "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur" + "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur", + "downloadToml": "Télécharger la configuration (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Aucun titre en cours de lecture", "minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes" } -} \ No newline at end of file +} diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index 32d0d919f..aba22b714 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -37,7 +37,8 @@ "sampleRate": "Taxa de mostra", "missing": "Falta", "libraryName": "Biblioteca", - "composer": "Composición" + "composer": "Composición", + "disc": "" }, "actions": { "addToQueue": "Ao final da cola", @@ -353,7 +354,8 @@ "allUsers": "Para todas as usuarias", "selectedUsers": "Usuarias seleccionadas", "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Selecciona bibliotecas" + "selectedLibraries": "Selecciona bibliotecas", + "allowWriteAccess": "" }, "sections": { "status": "Estado", @@ -398,7 +400,8 @@ "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.", "requiredHosts": "Servidores requeridos", "configValidationError": "Fallou a comprobación da configuración:", - "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido." + "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "clave", @@ -674,7 +677,8 @@ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML", "exportFailed": "Fallou a copia da configuración", "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)", - "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións" + "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións", + "downloadToml": "Descargar configuración (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Sen reprodución", "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" } -} \ No newline at end of file +} diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 6c9e154d1..bc2a5f85e 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -218,15 +218,9 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção", - "uploadCover": "Enviar Capa", - "removeCover": "Remover Capa" + "removeFromSelection": "Remover da seleção" }, "message": { - "coverUploaded": "Capa atualizada", - "coverRemoved": "Capa removida", - "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa", "duplicate_song": "Adicionar músicas duplicadas", "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", "noPlaylistsFound": "Nenhuma playlist encontrada", @@ -560,6 +554,12 @@ } }, "message": { + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa", + "coverUploaded": "Capa atualizada", + "coverRemoved": "Capa removida", + "coverUploadError": "Erro ao enviar capa", + "coverRemoveError": "Erro ao remover capa", "note": "ATENÇÃO", "transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}", "transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão", diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 5b20c3e19..78e7cfa26 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -37,7 +37,8 @@ "sampleRate": "Частота дискретизации (Hz)", "missing": "Поле отсутствует", "libraryName": "Библиотека", - "composer": "Композитор" + "composer": "Композитор", + "disc": "" }, "actions": { "addToQueue": "В очередь", @@ -353,7 +354,8 @@ "allUsers": "Разрешить всем пользователям", "selectedUsers": "Выбранные пользователи", "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Избранные библиотеки" + "selectedLibraries": "Избранные библиотеки", + "allowWriteAccess": "" }, "sections": { "status": "Статус", @@ -398,7 +400,8 @@ "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".", "requiredHosts": "Необходимые хосты", "configValidationError": "Проверка конфигурации завершилась неудачей:", - "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна." + "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ключ", @@ -674,7 +677,8 @@ "exportSuccess": "Конфигурация экспортирована в буфер обмена в формате TOML", "exportFailed": "Не удалось скопировать конфигурацию", "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", - "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях." + "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", + "downloadToml": "Скачать конфигурацию (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Ничего не играет", "minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад" } -} \ No newline at end of file +} diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index f499d6ad5..ceb56e9b7 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -37,7 +37,8 @@ "sampleRate": "Frekvenca vzorčenja", "missing": "Manjka", "libraryName": "Knjižnica", - "composer": "Skladatelj" + "composer": "Skladatelj", + "disc": "" }, "actions": { "addToQueue": "Predvajaj kasneje", @@ -48,7 +49,7 @@ "playNext": "Naslednji", "info": "Več informacij", "showInPlaylist": "Prikaži na seznamu predvajanja", - "instantMix": "" + "instantMix": "Instant Mix" } }, "album": { @@ -353,7 +354,8 @@ "allUsers": "Dovoli vsem uporabnikom", "selectedUsers": "Izbrani uporabniki", "allLibraries": "Dovoli vse knjižnice", - "selectedLibraries": "Izbrane knjižnice" + "selectedLibraries": "Izbrane knjižnice", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -397,8 +399,9 @@ "noLibraries": "Ni izbranih knjižnic", "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.", "requiredHosts": "Zahtevani gostitelji", - "configValidationError": "", - "schemaRenderError": "" + "configValidationError": "Validacija konfiguracije neuspešna:", + "schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "ključ", @@ -588,7 +591,7 @@ "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", "noSimilarSongsFound": "Ni najdenih podobnih pesmi", "noTopSongsFound": "Ni najdenih najboljših pesmi", - "startingInstantMix": "" + "startingInstantMix": "Nalaganje Instant Mix..." }, "menu": { "library": "Knjižnica", @@ -674,7 +677,8 @@ "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML", "exportFailed": "Kopiranje konfiguracije ni uspelo", "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)", - "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah" + "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah", + "downloadToml": "Naloži konfiguracijo (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Nič se ne predvaja", "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami" } -} \ No newline at end of file +} diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index 5896b4ed9..23bd5fbc2 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -37,7 +37,8 @@ "sampleRate": "Samplingsfrekvens", "missing": "Saknade", "libraryName": "Bibliotek", - "composer": "Kompositör" + "composer": "Kompositör", + "disc": "" }, "actions": { "addToQueue": "Lägg till i kön", @@ -353,7 +354,8 @@ "allUsers": "Tillåt alla användare", "selectedUsers": "Valda användare", "allLibraries": "Tillåt alla bibliotek", - "selectedLibraries": "Valda bibliotek" + "selectedLibraries": "Valda bibliotek", + "allowWriteAccess": "" }, "sections": { "status": "Status", @@ -398,7 +400,8 @@ "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.", "requiredHosts": "Krävda värdar", "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt." + "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "nyckel", @@ -674,7 +677,8 @@ "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", "exportFailed": "Kopiering av inställningarna misslyckades", "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", - "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner" + "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", + "downloadToml": "Ladda ner konfiguration (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "Inget spelas", "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" } -} \ No newline at end of file +} diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 45a5e5f34..b445d7464 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -37,7 +37,8 @@ "sampleRate": "แซมเปิ้ลเรต", "missing": "หายไป", "libraryName": "ห้องสมุด", - "composer": "ผู้แต่ง" + "composer": "ผู้แต่ง", + "disc": "" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -48,7 +49,7 @@ "playNext": "เล่นถัดไป", "info": "ดูรายละเอียด", "showInPlaylist": "แสดงในเพลย์ลิสต์", - "instantMix": "" + "instantMix": "อินสแตนต์ มิก" } }, "album": { @@ -353,7 +354,8 @@ "allUsers": "อนุญาติผู้ใช้ทั้งหมด", "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", - "selectedLibraries": "ห้องสมุดเพลงถูกเลือก" + "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", + "allowWriteAccess": "" }, "sections": { "status": "สถานะ", @@ -398,7 +400,8 @@ "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด", "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", - "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน" + "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", + "allowWriteAccessHelp": "" }, "placeholders": { "configKey": "คีย์", @@ -588,7 +591,7 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "" + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." }, "menu": { "library": "ห้องสมุดเพลง", @@ -674,7 +677,8 @@ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", - "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง" + "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง", + "downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)" } }, "activity": { @@ -708,4 +712,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} \ No newline at end of file +} diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 1bb59a8b1..dabf61bdb 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -10,19 +10,14 @@ "playCount": "播放次數", "title": "標題", "artist": "藝人", - "composer": "作曲者", "album": "專輯", "path": "檔案路徑", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", "size": "檔案大小", "updatedAt": "更新於", "bitRate": "位元率", - "bitDepth": "位元深度", - "sampleRate": "取樣率", - "channels": "聲道", "discSubtitle": "光碟副標題", "starred": "收藏", "comment": "註解", @@ -30,6 +25,7 @@ "quality": "品質", "bpm": "BPM", "playDate": "上次播放", + "channels": "聲道", "createdAt": "建立於", "grouping": "分組", "mood": "情緒", @@ -37,17 +33,22 @@ "tags": "額外標籤", "mappedTags": "分類後標籤", "rawTags": "原始標籤", - "missing": "遺失" + "bitDepth": "位元深度", + "sampleRate": "取樣率", + "missing": "遺失", + "libraryName": "媒體庫", + "composer": "作曲者", + "disc": "" }, "actions": { "addToQueue": "加入至播放佇列", "playNow": "立即播放", "addToPlaylist": "加入至播放清單", - "showInPlaylist": "在播放清單中顯示", "shuffleAll": "全部隨機播放", "download": "下載", "playNext": "下一首播放", "info": "取得資訊", + "showInPlaylist": "在播放清單中顯示", "instantMix": "即時混音" } }, @@ -59,38 +60,38 @@ "duration": "長度", "songCount": "歌曲數", "playCount": "播放次數", - "size": "檔案大小", "name": "名稱", - "libraryName": "媒體庫", "genre": "曲風", "compilation": "合輯", "year": "發行年份", - "date": "錄製日期", - "originalDate": "原始日期", - "releaseDate": "發行日期", - "releases": "發行", - "released": "已發行", "updatedAt": "更新於", "comment": "註解", "rating": "評分", "createdAt": "建立於", + "size": "檔案大小", + "originalDate": "原始日期", + "releaseDate": "發行日期", + "releases": "發行", + "released": "已發行", "recordLabel": "唱片公司", "catalogNum": "目錄編號", "releaseType": "發行類型", "grouping": "分組", "media": "媒體類型", "mood": "情緒", - "missing": "遺失" + "date": "錄製日期", + "missing": "遺失", + "libraryName": "媒體庫" }, "actions": { "playAll": "播放全部", "playNext": "下一首播放", "addToQueue": "加入至播放佇列", - "share": "分享", "shuffle": "隨機播放", "addToPlaylist": "加入至播放清單", "download": "下載", - "info": "取得資訊" + "info": "取得資訊", + "share": "分享" }, "lists": { "all": "所有", @@ -108,10 +109,10 @@ "name": "名稱", "albumCount": "專輯數", "songCount": "歌曲數", - "size": "檔案大小", "playCount": "播放次數", "rating": "評分", "genre": "曲風", + "size": "檔案大小", "role": "參與角色", "missing": "遺失" }, @@ -132,9 +133,9 @@ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人" }, "actions": { - "topSongs": "熱門歌曲", "shuffle": "隨機播放", - "radio": "電台" + "radio": "電台", + "topSongs": "熱門歌曲" } }, "user": { @@ -143,7 +144,6 @@ "userName": "使用者名稱", "isAdmin": "管理員", "lastLoginAt": "上次登入", - "lastAccessAt": "上次存取", "updatedAt": "更新於", "name": "名稱", "password": "密碼", @@ -152,6 +152,7 @@ "currentPassword": "目前密碼", "newPassword": "新密碼", "token": "權杖", + "lastAccessAt": "上次存取", "libraries": "媒體庫" }, "helperTexts": { @@ -163,14 +164,14 @@ "updated": "使用者已更新", "deleted": "使用者已刪除" }, - "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" - }, "message": { "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖", "selectAllLibraries": "選取全部媒體庫", "adminAutoLibraries": "管理員預設可存取所有媒體庫" + }, + "validation": { + "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫" } }, "player": { @@ -213,9 +214,9 @@ "selectPlaylist": "選取播放清單:", "addNewPlaylist": "建立「%{name}」", "export": "匯出", - "saveQueue": "將播放佇列儲存到播放清單", "makePublic": "設為公開", "makePrivate": "設為私人", + "saveQueue": "將播放佇列儲存到播放清單", "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", "removeFromSelection": "移除選取項目" @@ -246,7 +247,6 @@ "username": "分享者", "url": "網址", "description": "描述", - "downloadable": "允許下載?", "contents": "內容", "expiresAt": "過期時間", "lastVisitedAt": "上次造訪時間", @@ -254,19 +254,17 @@ "format": "格式", "maxBitRate": "最大位元率", "updatedAt": "更新於", - "createdAt": "建立於" - }, - "notifications": {}, - "actions": {} + "createdAt": "建立於", + "downloadable": "允許下載?" + } }, "missing": { "name": "遺失檔案 |||| 遺失檔案", - "empty": "無遺失檔案", "fields": { "path": "路徑", "size": "檔案大小", - "libraryName": "媒體庫", - "updatedAt": "遺失於" + "updatedAt": "遺失於", + "libraryName": "媒體庫" }, "actions": { "remove": "刪除", @@ -274,7 +272,8 @@ }, "notifications": { "removed": "遺失檔案已刪除" - } + }, + "empty": "無遺失檔案" }, "library": { "name": "媒體庫 |||| 媒體庫", @@ -304,20 +303,20 @@ }, "actions": { "scan": "掃描媒體庫", - "quickScan": "快速掃描", - "fullScan": "完整掃描", "manageUsers": "管理使用者權限", - "viewDetails": "查看詳細資料" + "viewDetails": "查看詳細資料", + "quickScan": "快速掃描", + "fullScan": "完整掃描" }, "notifications": { "created": "成功建立媒體庫", "updated": "成功更新媒體庫", "deleted": "成功刪除媒體庫", "scanStarted": "開始掃描媒體庫", + "scanCompleted": "媒體庫掃描完成", "quickScanStarted": "快速掃描已開始", "fullScanStarted": "完整掃描已開始", - "scanError": "掃描啟動失敗,請檢查日誌", - "scanCompleted": "媒體庫掃描完成" + "scanError": "掃描啟動失敗,請檢查日誌" }, "validation": { "nameRequired": "請輸入媒體庫名稱", @@ -355,7 +354,8 @@ "allUsers": "允許所有使用者", "selectedUsers": "選定的使用者", "allLibraries": "允許所有媒體庫", - "selectedLibraries": "選定的媒體庫" + "selectedLibraries": "選定的媒體庫", + "allowWriteAccess": "允許寫入權限" }, "sections": { "status": "狀態", @@ -389,8 +389,6 @@ }, "messages": { "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", - "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", @@ -400,7 +398,10 @@ "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", - "requiredHosts": "必要的 Hosts" + "requiredHosts": "必要的 Hosts", + "configValidationError": "設定驗證失敗:", + "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -443,7 +444,6 @@ "add": "加入", "back": "返回", "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "取消", "clear_input_value": "清除", "clone": "複製", @@ -467,6 +467,7 @@ "close_menu": "關閉選單", "unselect": "取消選取", "skip": "略過", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "分享", "download": "下載" }, @@ -558,48 +559,48 @@ "transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。", "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", - "noSimilarSongsFound": "找不到相似歌曲", - "startingInstantMix": "正在載入即時混音...", - "noTopSongsFound": "找不到熱門歌曲", "noPlaylistsAvailable": "沒有可用的播放清單", "delete_user_title": "刪除使用者「%{name}」", "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", - "remove_missing_title": "刪除遺失檔案", - "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", - "remove_all_missing_title": "刪除所有遺失檔案", - "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", "lastfmLinkFailure": "無法連接 Last.fm", "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}", - "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}", "shareFailure": "分享連結複製失敗:%{url}", "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "下載原始格式" + "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", + "remove_missing_title": "刪除遺失檔案", + "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", + "remove_all_missing_title": "刪除所有遺失檔案", + "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", + "noSimilarSongsFound": "找不到相似歌曲", + "noTopSongsFound": "找不到熱門歌曲", + "startingInstantMix": "正在載入即時混音...", + "uploadCover": "上傳封面", + "removeCover": "移除封面", + "coverUploaded": "已更新封面圖", + "coverRemoved": "已移除封面圖", + "coverUploadError": "上傳封面圖時發生錯誤", + "coverRemoveError": "移除封面圖時發生錯誤" }, "menu": { "library": "媒體庫", - "librarySelector": { - "allLibraries": "所有媒體庫 (%{count})", - "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", - "selectLibraries": "選取媒體庫", - "none": "無" - }, "settings": "設定", "version": "版本", "theme": "主題", @@ -610,7 +611,6 @@ "language": "語言", "defaultView": "預設畫面", "desktop_notifications": "桌面通知", - "lastfmNotConfigured": "Last.fm API 金鑰未設定", "lastfmScrobbling": "啟用 Last.fm 音樂記錄", "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", "replaygain": "重播增益模式", @@ -619,13 +619,20 @@ "none": "無", "album": "專輯增益", "track": "曲目增益" - } + }, + "lastfmNotConfigured": "Last.fm API 金鑰未設定" } }, "albumList": "專輯", + "about": "關於", "playlists": "播放清單", "sharedPlaylists": "分享的播放清單", - "about": "關於" + "librarySelector": { + "allLibraries": "所有媒體庫 (%{count})", + "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", + "selectLibraries": "選取媒體庫", + "none": "無" + } }, "player": { "playListsText": "播放佇列", @@ -676,7 +683,8 @@ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿", "exportFailed": "設定複製失敗", "devFlagsHeader": "開發旗標(可能會更改/刪除)", - "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除" + "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除", + "downloadToml": "下載設定檔 (TOML)" } }, "activity": { @@ -684,17 +692,12 @@ "totalScanned": "已掃描的資料夾總數", "quickScan": "快速掃描", "fullScan": "完全掃描", - "selectiveScan": "選擇性掃描", "serverUptime": "伺服器運作時間", "serverDown": "伺服器已離線", "scanType": "掃描類型", "status": "掃描錯誤", - "elapsedTime": "經過時間" - }, - "nowPlaying": { - "title": "正在播放", - "empty": "無播放內容", - "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" + "elapsedTime": "經過時間", + "selectiveScan": "選擇性掃描" }, "help": { "title": "Navidrome 快捷鍵", @@ -704,10 +707,15 @@ "toggle_play": "播放/暫停", "prev_song": "上一首歌", "next_song": "下一首歌", - "current_song": "前往目前歌曲", "vol_up": "提高音量", "vol_down": "降低音量", - "toggle_love": "新增此歌曲至收藏" + "toggle_love": "新增此歌曲至收藏", + "current_song": "前往目前歌曲" } + }, + "nowPlaying": { + "title": "正在播放", + "empty": "無播放內容", + "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } } diff --git a/scanner/controller_test.go b/scanner/controller_test.go index 2af52066b..d60d432b4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -31,7 +32,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 1ac7b50a4..8f0dcd340 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,6 +12,7 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -40,7 +41,7 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 6990f1984..856015239 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -77,7 +78,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 6e4511179..594b74e38 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -63,7 +64,7 @@ var _ = Describe("ScanFolders", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index d5688a1dc..922d21e62 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -84,7 +85,7 @@ var _ = Describe("Scanner", Ordered, func() { Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index ef379ad05..cb851debf 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -442,7 +442,7 @@ var _ = BeforeSuite(func() { buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS), metrics.NewNoopInstance()) + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -479,7 +479,7 @@ func setupTestDB() { streamerSpy = &spyStreamer{} decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -489,7 +489,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds), + playlists.NewPlaylists(ds, core.NewImageUploadService()), noopPlayTracker{}, core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/e2e/subsonic_multilibrary_test.go b/server/e2e/subsonic_multilibrary_test.go index f59187d00..a837da124 100644 --- a/server/e2e/subsonic_multilibrary_test.go +++ b/server/e2e/subsonic_multilibrary_test.go @@ -6,6 +6,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -53,7 +54,7 @@ var _ = Describe("Multi-Library Support", Ordered, func() { // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds), metrics.NewNoopInstance()) + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go new file mode 100644 index 000000000..1b78bb93e --- /dev/null +++ b/server/nativeapi/artists.go @@ -0,0 +1,72 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addArtistRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Artist{}) + } + r.Route("/artist", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Post("/image", api.uploadArtistImage()) + r.Delete("/image", api.deleteArtistImage()) + }) + }) +} + +func (api *Router) uploadArtistImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := ar.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityArtist, ar.ID, ar.Name, oldPath, reader, ext) + if err != nil { + return err + } + ar.UploadedImage = filename + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} + +func (api *Router) deleteArtistImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + artistID := chi.URLParamFromCtx(ctx, "id") + ar, err := api.ds.Artist(ctx).Get(artistID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, ar.UploadedImagePath()); err != nil { + return err + } + ar.UploadedImage = "" + now := time.Now() + ar.UpdatedAt = &now + return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + }) +} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go index d7368cabf..4e6e9e89b 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -28,7 +28,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go new file mode 100644 index 000000000..c29f14bdc --- /dev/null +++ b/server/nativeapi/image_upload.go @@ -0,0 +1,120 @@ +package nativeapi + +import ( + "context" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + _ "golang.org/x/image/webp" +) + +const maxImageSize = 10 << 20 // 10MB + +func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { + user, _ := request.UserFrom(r.Context()) + if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { + http.Error(w, "cover art upload is disabled", http.StatusForbidden) + return false + } + return true +} + +func handleImageUpload(saveFn func(ctx context.Context, reader io.Reader, ext string) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxImageSize) + if err := r.ParseMultipartForm(maxImageSize / 2); err != nil { + log.Error(ctx, "Error parsing multipart form", err) + http.Error(w, "file too large or invalid form", http.StatusBadRequest) + return + } + defer func() { + if r.MultipartForm != nil { + if err := r.MultipartForm.RemoveAll(); err != nil { + log.Warn(ctx, "Error removing multipart temp files", err) + } + } + }() + file, header, err := r.FormFile("image") + if err != nil { + log.Error(ctx, "Error reading uploaded file", err) + http.Error(w, "missing image file", http.StatusBadRequest) + return + } + defer file.Close() + _, format, err := image.DecodeConfig(file) + if err != nil { + log.Error(ctx, "Uploaded file is not a valid image", err) + http.Error(w, "invalid image file", http.StatusBadRequest) + return + } + if seeker, ok := file.(io.Seeker); ok { + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + log.Error(ctx, "Error seeking file", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + ext := "." + format + if ext == "." { + ext = strings.ToLower(filepath.Ext(header.Filename)) + } + if ext == "" || ext == "." { + log.Error(ctx, "Could not determine image type", "filename", header.Filename) + http.Error(w, "could not determine image type", http.StatusBadRequest) + return + } + if err := saveFn(ctx, file, ext); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error saving image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} + +func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !checkImageUploadPermission(w, r) { + return + } + if err := deleteFn(ctx); err != nil { + if errors.Is(err, model.ErrNotAuthorized) { + http.Error(w, "not authorized", http.StatusForbidden) + return + } + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + return + } + log.Error(ctx, "Error removing image", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, `{"status":"ok"}`) + } +} diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 5b9cf7e4e..ed5564a41 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -29,7 +29,7 @@ var _ = Describe("Library API", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3191991eb..3ef00ebb1 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -44,10 +44,11 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager + imgUpload core.ImageUploadService } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager} +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r } @@ -66,7 +67,7 @@ func (api *Router) routes() http.Handler { api.RX(r, "/user", api.users.NewRepository, true) api.R(r, "/song", model.MediaFile{}, false) api.R(r, "/album", model.Album{}, false) - api.R(r, "/artist", model.Artist{}, false) + api.addArtistRoute(r) api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b192e00ac..f0ee50ebb 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -94,7 +94,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 118528f68..ea1cf579b 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,25 +5,17 @@ import ( "encoding/json" "errors" "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" "io" "net/http" - "path/filepath" "strconv" "strings" "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/req" - _ "golang.org/x/image/webp" ) type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc @@ -234,110 +226,16 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { } } -const maxImageSize = 10 << 20 // 10MB - func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - if err := r.ParseMultipartForm(maxImageSize); err != nil { //nolint:gosec // size is limited by maxImageSize parameter - log.Error(ctx, "Error parsing multipart form", err) - http.Error(w, "file too large or invalid form", http.StatusBadRequest) - return - } - - file, header, err := r.FormFile("image") - if err != nil { - log.Error(ctx, "Error reading uploaded file", err) - http.Error(w, "missing image file", http.StatusBadRequest) - return - } - defer file.Close() - - // Validate the uploaded file is a valid image - _, format, err := image.DecodeConfig(file) - if err != nil { - log.Error(ctx, "Uploaded file is not a valid image", err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - - // Reset reader after DecodeConfig consumed some bytes - if seeker, ok := file.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - log.Error(ctx, "Error seeking file", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - - // Determine file extension from decoded format or original filename - ext := "." + format - if ext == "." { - ext = strings.ToLower(filepath.Ext(header.Filename)) - } - if ext == "" || ext == "." { - log.Error(ctx, "Could not determine image type", "playlistId", playlistId, "filename", header.Filename) - http.Error(w, "could not determine image type", http.StatusBadRequest) - return - } - - err = pls.SetImage(ctx, playlistId, file, ext) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to upload playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image upload", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error saving playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.SetImage(ctx, playlistId, reader, ext) + }) } func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if !conf.Server.EnableCoverArtUpload && !user.IsAdmin { - http.Error(w, "cover art upload is disabled", http.StatusForbidden) - return - } - p := req.Params(r) - playlistId, _ := p.String(":id") - - err := pls.RemoveImage(ctx, playlistId) - if errors.Is(err, model.ErrNotAuthorized) { - log.Error(ctx, "Not authorized to remove playlist image", "playlistId", playlistId, err) - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Playlist not found for image removal", "playlistId", playlistId, err) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - log.Error(ctx, "Error removing playlist image", "playlistId", playlistId, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) //nolint:gosec - } + return handleImageDelete(func(ctx context.Context) error { + playlistId := chi.URLParamFromCtx(ctx, "id") + return pls.RemoveImage(ctx, playlistId) + }) } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 7f0cd7de1..dfe6b9296 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -98,7 +98,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index 7946b90fd..8fc88e09c 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -33,7 +33,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index 1fa33d769..d1a89d512 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,6 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, + CoverArtAvatar, DurationField, RangeField, SimpleList, @@ -161,12 +162,18 @@ const AlbumTableView = ({       )} + leftIcon={(r) => ( + + + + )} linkType={'show'} rightIcon={(r) => } {...rest} /> ) : ( + {columns} { /> ) : ( + linkType(id)}> + + +
{data[id].name}
diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index 1e074ce4e..da8d06014 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,12 +6,13 @@ import CardContent from '@material-ui/core/CardContent' import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -57,6 +58,7 @@ const useStyles = makeStyles( alignItems: 'center', justifyContent: 'center', boxShadow: 'none', + position: 'relative', }, artistDetail: { flex: '1', @@ -85,36 +87,15 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return (
@@ -135,6 +116,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { }} /> )} +
diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 4947a4634..3add1e994 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,10 +4,11 @@ import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField } from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -67,6 +68,7 @@ const useStyles = makeStyles( minWidth: '7rem', display: 'flex', borderRadius: '5em', + position: 'relative', }, loveButton: { top: theme.spacing(-0.2), @@ -87,36 +89,15 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const [isLightboxOpen, setLightboxOpen] = React.useState(false) - const [imageLoading, setImageLoading] = React.useState(false) - const [imageError, setImageError] = React.useState(false) - - // Reset image state when artist changes - React.useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = React.useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = React.useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = React.useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = React.useCallback( - () => setLightboxOpen(false), - [], - ) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useArtistImageState(record.id) return ( <> @@ -138,6 +119,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { }} /> )} +
{ + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + const [isLightboxOpen, setLightboxOpen] = useState(false) + + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [recordId]) + + const handleImageLoad = useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) + + return { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } +} + +export default useArtistImageState diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx new file mode 100644 index 000000000..6610cb75e --- /dev/null +++ b/ui/src/common/CoverArtAvatar.jsx @@ -0,0 +1,36 @@ +import { useRecordContext } from 'react-admin' +import { Avatar } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import subsonic from '../subsonic' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + square: { + borderRadius: '4px', + }, +}) + +export const CoverArtAvatar = ({ + record: recordProp, + variant = 'circular', +}) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + if (!record) return null + const square = variant !== 'circular' + return ( + + ) +} + +CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx new file mode 100644 index 000000000..e0d0d0a9a --- /dev/null +++ b/ui/src/common/ImageUploadOverlay.jsx @@ -0,0 +1,139 @@ +import { IconButton, Tooltip } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' +import DeleteIcon from '@material-ui/icons/Delete' +import { useTranslate, useNotify, useRefresh } from 'react-admin' +import { useCallback, useRef } from 'react' +import config from '../config' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' + +const useStyles = makeStyles(() => ({ + coverOverlay: { + position: 'absolute', + bottom: 0, + right: 0, + display: 'flex', + gap: '2px', + padding: '2px', + backgroundColor: 'rgba(0,0,0,0.5)', + borderRadius: '4px 0 0 0', + opacity: 0, + transition: 'opacity 0.2s ease-in-out', + '*:hover > &': { + opacity: 1, + }, + }, + overlayButton: { + color: '#fff', + padding: '4px', + '&:hover': { + backgroundColor: 'rgba(255,255,255,0.2)', + }, + }, + overlayIcon: { + fontSize: '1.2rem', + }, +})) + +export const ImageUploadOverlay = ({ + entityType, + entityId, + hasUploadedImage, + onImageChange, +}) => { + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + const classes = useStyles() + const fileInputRef = useRef(null) + + const canEdit = + config.enableCoverArtUpload || localStorage.getItem('role') === 'admin' + + const handleUploadClick = useCallback((e) => { + e.stopPropagation() + if (fileInputRef.current) { + fileInputRef.current.click() + } + }, []) + + const handleFileChange = useCallback( + async (e) => { + const file = e.target.files[0] + if (!file || !entityId) return + + const formData = new FormData() + formData.append('image', file) + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'POST', + headers: new Headers({}), + body: formData, + }) + notify(`message.coverUploaded`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverUploadError`, 'warning') + } + + e.target.value = '' + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + const handleRemoveCover = useCallback( + async (e) => { + e.stopPropagation() + if (!entityId) return + + try { + await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { + method: 'DELETE', + }) + notify(`message.coverRemoved`, 'success') + if (onImageChange) onImageChange() + refresh() + } catch (err) { + notify(`message.coverRemoveError`, 'warning') + } + }, + [entityType, entityId, notify, refresh, onImageChange], + ) + + if (!canEdit) return null + + return ( +
+ + + + + + {hasUploadedImage && ( + + + + + + )} + +
+ ) +} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index a8dc354cb..b93e40219 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -43,3 +43,5 @@ export * from './PathField.jsx' export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' +export * from './ImageUploadOverlay' +export * from './CoverArtAvatar' diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index baf4c9fa3..6c6592178 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -219,15 +219,9 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection", - "uploadCover": "Upload Cover", - "removeCover": "Remove Cover" + "removeFromSelection": "Remove from selection" }, "message": { - "coverUploaded": "Cover art updated", - "coverRemoved": "Cover art removed", - "coverUploadError": "Error uploading cover art", - "coverRemoveError": "Error removing cover art", "duplicate_song": "Add duplicated songs", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", "noPlaylistsFound": "No playlists found", @@ -563,6 +557,12 @@ } }, "message": { + "uploadCover": "Upload Cover", + "removeCover": "Remove Cover", + "coverUploaded": "Cover art updated", + "coverRemoved": "Cover art removed", + "coverUploadError": "Error uploading cover art", + "coverRemoveError": "Error removing cover art", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index aeddf0d42..a2d5e753b 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,29 +2,23 @@ import { Card, CardContent, CardMedia, - IconButton, - Tooltip, Typography, useMediaQuery, } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' -import DeleteIcon from '@material-ui/icons/Delete' -import { useTranslate, useNotify, useRefresh } from 'react-admin' -import { useCallback, useRef, useState, useEffect } from 'react' +import { useTranslate } from 'react-admin' +import { useCallback, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { CollapsibleComment, DurationField, + ImageUploadOverlay, SizeField, isWritable, OverflowTooltip, } from '../common' -import config from '../config' import subsonic from '../subsonic' -import { REST_URL } from '../consts' -import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -82,31 +76,6 @@ const useStyles = makeStyles( coverLoading: { opacity: 0.5, }, - coverOverlay: { - position: 'absolute', - bottom: 0, - right: 0, - display: 'flex', - gap: '2px', - padding: '2px', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: '4px 0 0 0', - opacity: 0, - transition: 'opacity 0.2s ease-in-out', - '$coverParent:hover &': { - opacity: 1, - }, - }, - overlayButton: { - color: '#fff', - padding: '4px', - '&:hover': { - backgroundColor: 'rgba(255,255,255,0.2)', - }, - }, - overlayIcon: { - fontSize: '1.2rem', - }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -125,20 +94,14 @@ const useStyles = makeStyles( const PlaylistDetails = (props) => { const { record = {} } = props const translate = useTranslate() - const notify = useNotify() - const refresh = useRefresh() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const [isLightboxOpen, setLightboxOpen] = useState(false) const [imageLoading, setImageLoading] = useState(false) const [imageError, setImageError] = useState(false) - const fileInputRef = useRef(null) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - const canEdit = - isWritable(record.ownerId) && - (config.enableCoverArtUpload || localStorage.getItem('role') === 'admin') // Reset image state when playlist changes useEffect(() => { @@ -164,60 +127,6 @@ const PlaylistDetails = (props) => { const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - const handleUploadClick = useCallback( - (e) => { - e.stopPropagation() - if (fileInputRef.current) { - fileInputRef.current.click() - } - }, - [fileInputRef], - ) - - const handleFileChange = useCallback( - async (e) => { - const file = e.target.files[0] - if (!file || !record.id) return - - const formData = new FormData() - formData.append('image', file) - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'POST', - headers: new Headers({}), - body: formData, - }) - notify('resources.playlist.message.coverUploaded', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverUploadError', 'warning') - } - - // Reset file input so the same file can be re-selected - e.target.value = '' - }, - [record.id, notify, refresh], - ) - - const handleRemoveCover = useCallback( - async (e) => { - e.stopPropagation() - if (!record.id) return - - try { - await httpClient(`${REST_URL}/playlist/${record.id}/image`, { - method: 'DELETE', - }) - notify('resources.playlist.message.coverRemoved', 'success') - refresh() - } catch (err) { - notify('resources.playlist.message.coverRemoveError', 'warning') - } - }, - [record.id, notify, refresh], - ) - return (
@@ -237,40 +146,12 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> - {canEdit && ( -
- - - - - - {record.uploadedImage && ( - - - - - - )} - -
+ {isWritable(record.ownerId) && ( + )}
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 67c456f27..8732725bc 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -16,10 +16,10 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' -import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { + CoverArtAvatar, DurationField, List, Writable, @@ -29,17 +29,11 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' -import subsonic from '../subsonic' const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, - coverArt: { - width: '40px', - height: '40px', - borderRadius: '4px', - }, })) const PlaylistFilter = (props) => { @@ -126,25 +120,6 @@ const ToggleAutoImport = ({ resource, source }) => { ) : null } -const CoverArtField = () => { - const classes = useStyles() - const record = useRecordContext() - if (!record) return null - return ( - - ) -} - -CoverArtField.defaultProps = { - label: '', - sortable: false, -} - const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -204,7 +179,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && } > isWritable(r?.ownerId)}> - + {columns}