mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-28 03:19:38 +00:00
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
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:
parent
be06196168
commit
ab8a58157a
57 changed files with 1169 additions and 567 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ type configOptions struct {
|
|||
CoverArtPriority string
|
||||
CoverArtQuality int
|
||||
ArtistArtPriority string
|
||||
ArtistImageFolder string
|
||||
DiscArtPriority string
|
||||
LyricsPriority string
|
||||
EnableGravatar bool
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ const (
|
|||
DefaultCacheCleanUpInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Entity types
|
||||
const (
|
||||
EntityArtist = "artist"
|
||||
EntityPlaylist = "playlist"
|
||||
)
|
||||
|
||||
const (
|
||||
AlbumPlayCountModeAbsolute = "absolute"
|
||||
AlbumPlayCountModeNormalized = "normalized"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
71
core/image_upload.go
Normal 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
99
core/image_upload_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal file
22
db/migrations/20260315233131_add_artist_uploaded_image.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
30
model/artist_test.go
Normal 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
17
model/image.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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} минути"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} λεπτά πριν"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} минут назад"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} นาทีที่แล้ว"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} 分鐘前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
72
server/nativeapi/artists.go
Normal file
72
server/nativeapi/artists.go
Normal 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")
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
120
server/nativeapi/image_upload.go
Normal file
120
server/nativeapi/image_upload.go
Normal 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"}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
ui/src/artist/useArtistImageState.js
Normal file
46
ui/src/artist/useArtistImageState.js
Normal 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
|
||||
139
ui/src/common/ImageUploadOverlay.jsx
Normal file
139
ui/src/common/ImageUploadOverlay.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -43,3 +43,4 @@ export * from './PathField.jsx'
|
|||
export * from './ParticipantsInfo'
|
||||
export * from './OverflowTooltip'
|
||||
export * from './useSearchRefocus'
|
||||
export * from './ImageUploadOverlay'
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue