feat: add artist image uploads and image-folder artwork source (#5198)
Some checks are pending
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
POEditor export / push-translations (push) Has been skipped

* feat: add shared ImageUploadService for entity image management

* feat: add UploadedImage field and methods to Artist model

* feat: add uploaded_image column to artist table

* feat: add ArtistImageFolder config option

* refactor: wire ImageUploadService and delegate playlist file ops to it

Wire ImageUploadService into the DI container and refactor the playlist
service to delegate image file operations (SetImage/RemoveImage) to the
shared ImageUploadService, removing duplicated file I/O logic. A local
ImageUploadService interface is defined in core/playlists to avoid an
import cycle between core and core/playlists.

* feat: artist artwork reader checks uploaded image first

* feat: add image-folder priority source for artist artwork

* feat: cache key invalidation for image-folder and uploaded images

* refactor: extract shared image upload HTTP helpers

* feat: add artist image upload/delete API endpoints

* refactor: playlist handlers use shared image upload helpers

* feat: add shared ImageUploadOverlay component

* feat: add i18n keys for artist image upload

* feat: add image upload overlay to artist detail pages

* refactor: playlist details uses shared ImageUploadOverlay component

* fix: add gosec nolint directive for ParseMultipartForm

* refactor: deduplicate image upload code and optimize dir scanning

- Remove dead ImageFilename methods from Artist and Playlist models
  (production code uses core.imageFilename exclusively)
- Extract shared uploadedImagePath helper in model/image.go
- Extract findImageInArtistFolder to deduplicate dir-scanning logic
  between fromArtistImageFolder and getArtistImageFolderModTime
- Fix fileInputRef in useCallback dependency array

* fix: include artist UpdatedAt in artwork cache key

Without this, uploading or deleting an artist image would not
invalidate the cached artwork because the cache key was only based
on album folder timestamps, not the artist's own UpdatedAt field.

* feat: add Portuguese translations for artist image upload

* refactor: use shared i18n keys for cover art upload messages

Move cover art upload/remove translations from per-entity sections
(artist, playlist) to a shared top-level "message" section, avoiding
duplication across entity types and translation files.

* refactor: move cover art i18n keys to shared message section for all languages

* refactor: simplify image upload code and eliminate redundancies

Extracted duplicate image loading/lightbox state logic from
DesktopArtistDetails and MobileArtistDetails into a shared
useArtistImageState hook. Moved entity type constants to the consts
package and replaced raw string literals throughout model, core, and
nativeapi packages. Exported model.UploadedImagePath and reused it in
core/image_upload.go to consolidate path construction. Cached the
ArtistImageFolder lookup result in artistReader to eliminate a redundant
os.ReadDir call on every artwork request.

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

* style: fix prettier formatting in ImageUploadOverlay

* fix: address code review feedback on image upload error handling

- RemoveImage now returns errors instead of swallowing them
- Artist handlers distinguish not-found from other DB errors
- Defer multipart temp file cleanup after parsing

* fix: enforce hard request size limit with MaxBytesReader for image uploads

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

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-03-15 22:19:55 -04:00 committed by GitHub
parent be06196168
commit ab8a58157a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 1169 additions and 567 deletions

View file

@ -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

View file

@ -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

View file

@ -71,6 +71,7 @@ type configOptions struct {
CoverArtPriority string
CoverArtQuality int
ArtistArtPriority string
ArtistImageFolder string
DiscArtPriority string
LyricsPriority string
EnableGravatar bool

View file

@ -103,6 +103,12 @@ const (
DefaultCacheCleanUpInterval = 10 * time.Minute
)
// Entity types
const (
EntityArtist = "artist"
EntityPlaylist = "playlist"
)
const (
AlbumPlayCountModeAbsolute = "absolute"
AlbumPlayCountModeNormalized = "normalized"

View file

@ -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 ""
}

View file

@ -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 {

71
core/image_upload.go Normal file
View file

@ -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
}

99
core/image_upload_test.go Normal file
View file

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

View file

@ -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"})
})

View file

@ -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 = ""

View file

@ -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() {

View file

@ -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() {

View file

@ -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,

View file

@ -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
}

View file

@ -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))

30
model/artist_test.go Normal file
View file

@ -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")))
})
})
})

17
model/image.go Normal file
View file

@ -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)
}

View file

@ -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: <ID>_<clean_name><ext>, falling back to <ID><ext> 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

View file

@ -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() {

View file

@ -219,19 +219,13 @@
"saveQueue": "Запазване на опашката в плейлист",
"searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...",
"pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист",
"removeFromSelection": "Премахване от селекцията",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Премахване от селекцията"
},
"message": {
"duplicate_song": "Добави дублирани песни",
"song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
"noPlaylistsFound": "Няма намерени плейлисти",
"noPlaylists": "Няма налични плейлисти",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Няма налични плейлисти"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Нищо не се възпроизвежда",
"minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Desar la cua a una llista",
"searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...",
"pressEnterToCreate": "Prem Retorn per crear una nova llista",
"removeFromSelection": "Elimina de la selecció",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Elimina de la selecció"
},
"message": {
"duplicate_song": "Afegeix cançons duplicades",
"song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?",
"noPlaylistsFound": "No s'ha trobat cap llista",
"noPlaylists": "No hi ha cap llista disponible",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "No hi ha cap llista disponible"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "No s'està reproduint res",
"minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Gem kø på afspilningsliste",
"searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...",
"pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste",
"removeFromSelection": "Fjern fra valg",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Fjern fra valg"
},
"message": {
"duplicate_song": "Tilføj dubletter af sange",
"song_exist": "Der føjes dubletter til playlisten",
"noPlaylistsFound": "Ingen playlister fundet",
"noPlaylists": "Ingen tilgængelige playlister",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Ingen tilgængelige playlister"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Intet afspilles nu",
"minutesAgo": "for %{smart_count} minut siden |||| for %{smart_count} minutter siden"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Warteschlange in Wiedergabeliste speichern",
"searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...",
"pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen",
"removeFromSelection": "Von Auswahl entfernen",
"uploadCover": "Cover hochladen",
"removeCover": "Cover entfernen"
"removeFromSelection": "Von Auswahl entfernen"
},
"message": {
"duplicate_song": "Duplikate hinzufügen",
"song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?",
"noPlaylistsFound": "Keine Wiedergabeliste gefunden",
"noPlaylists": "Keine Wiedergabelisten vorhanden",
"coverUploaded": "Cover aktualisiert",
"coverRemoved": "Cover entfernt",
"coverUploadError": "Fehler beim Hochladen des Covers",
"coverRemoveError": "Fehler beim Entfernen des Covers"
"noPlaylists": "Keine Wiedergabelisten vorhanden"
}
},
"radio": {
@ -597,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",
@ -718,4 +718,4 @@
"empty": "Keine Wiedergabe",
"minutesAgo": "Vor %{smart_count} Minute |||| Vor %{smart_count} Minuten"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής",
"searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...",
"pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής",
"removeFromSelection": "Αφαίρεση από την επιλογή",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Αφαίρεση από την επιλογή"
},
"message": {
"duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών",
"song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?",
"noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής",
"noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Δεν παίζει τίποτα",
"minutesAgo": "%{smart_count} λεπτό πριν |||| %{smart_count} λεπτά πριν"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Guardar la fila de reproducción en una playlist",
"searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…",
"pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción",
"removeFromSelection": "Quitar de la selección",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Quitar de la selección"
},
"message": {
"duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist",
"song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?",
"noPlaylistsFound": "No se encontraron listas de reproducción",
"noPlaylists": "No hay listas de reproducción disponibles",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "No hay listas de reproducción disponibles"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Nada en reproducción",
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Tallenna jono soittolistaan",
"searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...",
"pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan",
"removeFromSelection": "Poista valinnasta",
"uploadCover": "Lataa kansikuva",
"removeCover": "Poista kansikuva"
"removeFromSelection": "Poista valinnasta"
},
"message": {
"duplicate_song": "Lisää olemassa oleva kappale",
"song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?",
"noPlaylistsFound": "Soittolistoja ei löytynyt",
"noPlaylists": "Soittolistoja ei ole saatavilla",
"coverUploaded": "Kansikuva päivitetty",
"coverRemoved": "Kansikuva poistettu",
"coverUploadError": "Virhe ladattaessa kansikuvaa",
"coverRemoveError": "Virhe poistettaessa kansikuvaa"
"noPlaylists": "Soittolistoja ei ole saatavilla"
}
},
"radio": {
@ -597,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",
@ -718,4 +718,4 @@
"empty": "Ei soita mitään",
"minutesAgo": "%{smart_count} minuutti sitten |||| %{smart_count} minuuttia sitten"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Sauvegarder la file de lecture dans la playlist",
"searchOrCreate": "Chercher ou créer une nouvelle playlist...",
"pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist",
"removeFromSelection": "Supprimer de la sélection",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Supprimer de la sélection"
},
"message": {
"duplicate_song": "Ajouter les titres déjà présents dans la playlist",
"song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?",
"noPlaylistsFound": "Aucune playlist trouvée",
"noPlaylists": "Aucune playlist disponible",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Aucune playlist disponible"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Aucun titre en cours de lecture",
"minutesAgo": "Il y a %{smart_count} minute |||| Il y a %{smart_count} minutes"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Salvar a Cola como Lista de reprodución",
"searchOrCreate": "Buscar listas ou escribe para crear nova…",
"pressEnterToCreate": "Preme Enter para crear nova lista",
"removeFromSelection": "Retirar da selección",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Retirar da selección"
},
"message": {
"duplicate_song": "Engadir cancións duplicadas",
"song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
"noPlaylistsFound": "Sen listas de reprodución",
"noPlaylists": "Sen listas dispoñibles",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Sen listas dispoñibles"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Sen reprodución",
"minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
}
}
}

View file

@ -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",

View file

@ -219,19 +219,13 @@
"saveQueue": "Сохранить очередь в плейлист",
"searchOrCreate": "Поиск плейлистов или введите текст для создания новых...",
"pressEnterToCreate": "Нажмите Enter, чтобы создать новый список воспроизведения",
"removeFromSelection": "Удалить из списка выделенных",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Удалить из списка выделенных"
},
"message": {
"duplicate_song": "Повторяющиеся треки",
"song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?",
"noPlaylistsFound": "Плейлисты не найдены",
"noPlaylists": "Нет доступных плейлистов",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Нет доступных плейлистов"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Ничего не играет",
"minutesAgo": "%{smart_count} минут назад |||| %{smart_count} минут назад"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Shrani čakalno vrsto na seznam predvajanja",
"searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...",
"pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja",
"removeFromSelection": "Odstrani iz izbora",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Odstrani iz izbora"
},
"message": {
"duplicate_song": "Dodaj podvojene pesmi",
"song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?",
"noPlaylistsFound": "Ni najdenih seznamov predvajanja",
"noPlaylists": "Ni na voljo seznamov predvajanja",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Ni na voljo seznamov predvajanja"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Nič se ne predvaja",
"minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "Spara kö till spellista",
"searchOrCreate": "Sök spellista eller skapa ny...",
"pressEnterToCreate": "Tryck Enter för att skapa ny spellista",
"removeFromSelection": "Ta bort från urval",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "Ta bort från urval"
},
"message": {
"duplicate_song": "Lägg till dubletter",
"song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?",
"noPlaylistsFound": "Hittade inga spellistor",
"noPlaylists": "Inga spellistor tillgängliga",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "Inga spellistor tillgängliga"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "Inget spelas",
"minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
"searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
"pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
"removeFromSelection": "เอาออกจากที่เลือกไว้",
"uploadCover": "",
"removeCover": ""
"removeFromSelection": "เอาออกจากที่เลือกไว้"
},
"message": {
"duplicate_song": "เพิ่มเพลงซ้ำ",
"song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
"noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
"noPlaylists": "ไม่มีเพลย์ลิสต์อยู่",
"coverUploaded": "",
"coverRemoved": "",
"coverUploadError": "",
"coverRemoveError": ""
"noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
}
},
"radio": {
@ -718,4 +712,4 @@
"empty": "ไม่มีเพลงเล่น",
"minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
}
}
}

View file

@ -219,19 +219,13 @@
"saveQueue": "將播放佇列儲存到播放清單",
"searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
"pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
"removeFromSelection": "移除選取項目",
"uploadCover": "上傳封面",
"removeCover": "移除封面"
"removeFromSelection": "移除選取項目"
},
"message": {
"duplicate_song": "加入重複的歌曲",
"song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?",
"noPlaylistsFound": "找不到播放清單",
"noPlaylists": "暫無播放清單",
"coverUploaded": "已更新封面圖",
"coverRemoved": "已移除封面圖",
"coverUploadError": "上傳封面圖時發生錯誤",
"coverRemoveError": "移除封面圖時發生錯誤"
"noPlaylists": "暫無播放清單"
}
},
"radio": {
@ -597,7 +591,13 @@
"remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。",
"noSimilarSongsFound": "找不到相似歌曲",
"noTopSongsFound": "找不到熱門歌曲",
"startingInstantMix": "正在載入即時混音..."
"startingInstantMix": "正在載入即時混音...",
"uploadCover": "上傳封面",
"removeCover": "移除封面",
"coverUploaded": "已更新封面圖",
"coverRemoved": "已移除封面圖",
"coverUploadError": "上傳封面圖時發生錯誤",
"coverRemoveError": "移除封面圖時發生錯誤"
},
"menu": {
"library": "媒體庫",
@ -718,4 +718,4 @@
"empty": "無播放內容",
"minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
}
}
}

View file

@ -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() {

View file

@ -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)

View file

@ -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"}

View file

@ -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())

View file

@ -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())

View file

@ -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),

View file

@ -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())

View file

@ -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")
})
}

View file

@ -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

View file

@ -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"}`)
}
}

View file

@ -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

View file

@ -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)

View file

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

View file

@ -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)
})
}

View file

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

View file

@ -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

View file

@ -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 (
<div className={classes.root}>
@ -135,6 +116,11 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
}}
/>
)}
<ImageUploadOverlay
entityType="artist"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
/>
</Card>
<div className={classes.details}>
<CardContent className={classes.content}>

View file

@ -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 }) => {
}}
/>
)}
<ImageUploadOverlay
entityType="artist"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
/>
</Card>
<div className={classes.details}>
<Typography

View file

@ -0,0 +1,46 @@
import { useState, useEffect, useCallback } from 'react'
/**
* Manages image loading/error state and lightbox open/close for artist detail views.
* Resets when record.id changes.
*/
const useArtistImageState = (recordId) => {
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

View file

@ -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 (
<div className={classes.coverOverlay}>
<Tooltip title={translate(`message.uploadCover`)}>
<IconButton
className={classes.overlayButton}
onClick={handleUploadClick}
size="small"
>
<PhotoCameraIcon className={classes.overlayIcon} />
</IconButton>
</Tooltip>
{hasUploadedImage && (
<Tooltip title={translate(`message.removeCover`)}>
<IconButton
className={classes.overlayButton}
onClick={handleRemoveCover}
size="small"
>
<DeleteIcon className={classes.overlayIcon} />
</IconButton>
</Tooltip>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
)
}

View file

@ -43,3 +43,4 @@ export * from './PathField.jsx'
export * from './ParticipantsInfo'
export * from './OverflowTooltip'
export * from './useSearchRefocus'
export * from './ImageUploadOverlay'

View file

@ -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.",

View file

@ -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 (
<Card className={classes.root}>
<div className={classes.cardContents}>
@ -237,40 +146,12 @@ const PlaylistDetails = (props) => {
cursor: imageError ? 'default' : 'pointer',
}}
/>
{canEdit && (
<div className={classes.coverOverlay}>
<Tooltip
title={translate('resources.playlist.actions.uploadCover')}
>
<IconButton
className={classes.overlayButton}
onClick={handleUploadClick}
size="small"
>
<PhotoCameraIcon className={classes.overlayIcon} />
</IconButton>
</Tooltip>
{record.uploadedImage && (
<Tooltip
title={translate('resources.playlist.actions.removeCover')}
>
<IconButton
className={classes.overlayButton}
onClick={handleRemoveCover}
size="small"
>
<DeleteIcon className={classes.overlayIcon} />
</IconButton>
</Tooltip>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
</div>
{isWritable(record.ownerId) && (
<ImageUploadOverlay
entityType="playlist"
entityId={record.id}
hasUploadedImage={!!record.uploadedImage}
/>
)}
</div>
<div className={classes.details}>