feat(playlist): add custom playlist cover art upload (#5110)

* feat(playlist): add custom playlist cover art upload - #406

Allow users to upload, view, and remove custom cover images for playlists.
Custom images take priority over the auto-generated tiled artwork.

Backend:
- Add `image_path` column to playlist table (migration with proper rollback)
- Add `SetImage`/`RemoveImage` methods to playlist service
- Add `POST/DELETE /api/playlist/{id}/image` endpoints
- Prioritize custom image in artwork reader pipeline
- Clean up image files on playlist deletion
- Use glob-based cleanup to prevent orphaned files across format changes
- Reject uploads with undetermined image type (400)

Frontend:
- Hover overlay on playlist cover with upload (camera) and remove (trash) buttons
- Lightbox for full-size cover art viewing
- Cover art thumbnails in the playlist list view
- Loading/error states and i18n strings

Closes #406

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com>

* refactor: rename playlist image path migration file

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

* fix(playlist): address review feedback for cover art upload - #406

- Use httpClient instead of raw fetch for image upload/remove
- Revert glob cleanup to simple imagePath check
- Add log.Error before all error HTTP responses
- Add backend tests for SetImage and RemoveImage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com>

* refactor(playlist): use Playlist.ArtworkPath() for image storage

Migrate all playlist image path handling to use the new
Playlist.ArtworkPath() method as the single source of truth. The DB now
stores only the filename (e.g. "pls-1.jpg") instead of a relative path,
and images are stored under {DataFolder}/artwork/playlist/ instead of
{DataFolder}/playlist_images/. The artwork root directory is created at
startup alongside DataFolder and CacheFolder. This also removes the
conf dependency from reader_playlist.go since path resolution is now
fully encapsulated in the model.

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

* refactor(playlist): streamline artwork image selection logic

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

* refactor: move translation keys, add pt-BR translations

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

* refactor(playlist): rename image_path to image_file

Rename the playlist cover art column and field from image_path/ImagePath
to image_file/ImageFile across the migration, model, service, tests, and
UI. The new name more accurately describes what the field stores (a
filename, not a path) and aligns with the existing ImageFiles/IsImageFile
naming conventions in the codebase.

---------

Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com>
Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
This commit is contained in:
adrbn
2026-03-01 20:07:18 +01:00
committed by GitHub
parent 4e34d3ac1f
commit d004f99f8f
13 changed files with 529 additions and 8 deletions
+6
View File
@@ -303,6 +303,12 @@ func Load(noConfigDump bool) {
os.Exit(1) os.Exit(1)
} }
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
os.Exit(1)
}
if Server.Plugins.Enabled { if Server.Plugins.Enabled {
if Server.Plugins.Folder == "" { if Server.Plugins.Folder == "" {
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
+1
View File
@@ -65,6 +65,7 @@ const (
I18nFolder = "i18n" I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore" ScanIgnoreFile = ".ndignore"
ArtworkFolder = "artwork"
PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp"
+17 -2
View File
@@ -8,6 +8,7 @@ import (
"image/draw" "image/draw"
"image/png" "image/png"
"io" "io"
"os"
"time" "time"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@@ -43,11 +44,25 @@ func (a *playlistArtworkReader) LastUpdated() time.Time {
} }
func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) {
ff := []sourceFunc{ return selectImageReader(ctx, a.artID,
a.fromPlaylistImage(),
a.fromGeneratedTiledCover(ctx), a.fromGeneratedTiledCover(ctx),
fromAlbumPlaceholder(), fromAlbumPlaceholder(),
)
}
func (a *playlistArtworkReader) fromPlaylistImage() sourceFunc {
return func() (io.ReadCloser, string, error) {
absPath := a.pl.ArtworkPath()
if absPath == "" {
return nil, "", nil
}
f, err := os.Open(absPath)
if err != nil {
return nil, "", err
}
return f, absPath, nil
} }
return selectImageReader(ctx, a.artID, ff...)
} }
func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc {
+71 -1
View File
@@ -2,7 +2,9 @@ package playlists
import ( import (
"context" "context"
"fmt"
"io" "io"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -10,6 +12,7 @@ import (
"github.com/bmatcuk/doublestar/v4" "github.com/bmatcuk/doublestar/v4"
"github.com/deluan/rest" "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
) )
@@ -34,6 +37,10 @@ type Playlists interface {
RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error
ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error
// Cover art
SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error
RemoveImage(ctx context.Context, playlistID string) error
// Import // Import
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
@@ -118,9 +125,18 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string,
} }
func (s *playlists) Delete(ctx context.Context, id string) error { func (s *playlists) Delete(ctx context.Context, id string) error {
if _, err := s.checkWritable(ctx, id); err != nil { pls, err := s.checkWritable(ctx, id)
if err != nil {
return err return err
} }
// Clean up custom cover image file if one exists
if path := pls.ArtworkPath(); path != "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err)
}
}
return s.ds.Playlist(ctx).Delete(id) return s.ds.Playlist(ctx).Delete(id)
} }
@@ -263,3 +279,57 @@ func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int
return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos) return tx.Playlist(ctx).Tracks(playlistID, false).Reorder(pos, newPos)
}) })
} }
// --- Cover art operations ---
func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return err
}
filename := playlistID + ext
oldPath := pls.ArtworkPath()
pls.ImageFile = filename
absPath := pls.ArtworkPath()
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)
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 s.ds.Playlist(ctx).Put(pls)
}
func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error {
pls, err := s.checkWritable(ctx, playlistID)
if err != nil {
return err
}
if path := pls.ArtworkPath(); path != "" {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
log.Warn(ctx, "Failed to remove playlist image", "path", path, err)
}
}
pls.ImageFile = ""
return s.ds.Playlist(ctx).Put(pls)
}
+120
View File
@@ -2,7 +2,12 @@ package playlists_test
import ( import (
"context" "context"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/criteria"
@@ -294,4 +299,119 @@ var _ = Describe("Playlists", func() {
Expect(err).To(MatchError(model.ErrNotAuthorized)) Expect(err).To(MatchError(model.ErrNotAuthorized))
}) })
}) })
Describe("SetImage", func() {
var tmpDir string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
mockPlsRepo.Data = map[string]*model.Playlist{
"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)
})
It("saves image file and updates ImageFile", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
reader := strings.NewReader("fake image data")
err := ps.SetImage(ctx, "pls-1", reader, ".jpg")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.ImageFile).To(Equal("pls-1.jpg"))
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
data, err := os.ReadFile(absPath)
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).To(Equal("fake image data"))
})
It("removes old image when replacing", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
// Upload first image
err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png")
Expect(err).ToNot(HaveOccurred())
oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.png")
Expect(oldPath).To(BeAnExistingFile())
// Upload replacement image
err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg")
Expect(err).ToNot(HaveOccurred())
Expect(oldPath).ToNot(BeAnExistingFile())
newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
Expect(newPath).To(BeAnExistingFile())
})
It("allows admin to set image on any playlist", func() {
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg")
Expect(err).ToNot(HaveOccurred())
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg")
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg")
Expect(err).To(Equal(model.ErrNotFound))
})
})
Describe("RemoveImage", func() {
var tmpDir string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir()
conf.Server.DataFolder = tmpDir
// Create a real image file on disk
imgDir := filepath.Join(tmpDir, "artwork", "playlist")
Expect(os.MkdirAll(imgDir, 0755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed())
mockPlsRepo.Data = map[string]*model.Playlist{
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", ImageFile: "pls-1.jpg"},
"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)
})
It("removes file and clears ImageFile", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-1")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty())
absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg")
Expect(absPath).ToNot(BeAnExistingFile())
})
It("succeeds even if playlist has no image", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-empty")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.ImageFile).To(BeEmpty())
})
It("denies non-owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
err := ps.RemoveImage(ctx, "pls-1")
Expect(err).To(MatchError(model.ErrNotAuthorized))
})
It("returns error when playlist not found", func() {
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
err := ps.RemoveImage(ctx, "nonexistent")
Expect(err).To(Equal(model.ErrNotFound))
})
})
}) })
@@ -0,0 +1,22 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile)
}
func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`)
return err
}
func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`)
return err
}
+11
View File
@@ -1,10 +1,13 @@
package model package model
import ( import (
"path/filepath"
"slices" "slices"
"strconv" "strconv"
"time" "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/criteria"
) )
@@ -21,6 +24,7 @@ type Playlist struct {
Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"`
Path string `structs:"path" json:"path"` Path string `structs:"path" json:"path"`
Sync bool `structs:"sync" json:"sync"` Sync bool `structs:"sync" json:"sync"`
ImageFile string `structs:"image_file" json:"imageFile"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"` CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
@@ -106,6 +110,13 @@ func (pls Playlist) CoverArtID() ArtworkID {
return artworkIDFromPlaylist(pls) return artworkIDFromPlaylist(pls)
} }
func (pls Playlist) ArtworkPath() string {
if pls.ImageFile == "" {
return ""
}
return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.ImageFile)
}
type Playlists []Playlist type Playlists []Playlist
type PlaylistRepository interface { type PlaylistRepository interface {
+7 -1
View File
@@ -218,9 +218,15 @@
"saveQueue": "Salvar fila em nova Playlist", "saveQueue": "Salvar fila em nova Playlist",
"searchOrCreate": "Buscar playlists ou criar nova...", "searchOrCreate": "Buscar playlists ou criar nova...",
"pressEnterToCreate": "Pressione Enter para criar nova playlist", "pressEnterToCreate": "Pressione Enter para criar nova playlist",
"removeFromSelection": "Remover da seleção" "removeFromSelection": "Remover da seleção",
"uploadCover": "Enviar Capa",
"removeCover": "Remover Capa"
}, },
"message": { "message": {
"coverUploaded": "Capa atualizada",
"coverRemoved": "Capa removida",
"coverUploadError": "Erro ao enviar capa",
"coverRemoveError": "Erro ao remover capa",
"duplicate_song": "Adicionar músicas duplicadas", "duplicate_song": "Adicionar músicas duplicadas",
"song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?",
"noPlaylistsFound": "Nenhuma playlist encontrada", "noPlaylistsFound": "Nenhuma playlist encontrada",
+2
View File
@@ -140,6 +140,8 @@ func (api *Router) addPlaylistRoute(r chi.Router) {
r.Get("/", rest.Get(constructor)) r.Get("/", rest.Get(constructor))
r.Put("/", rest.Put(constructor)) r.Put("/", rest.Put(constructor))
r.Delete("/", rest.Delete(constructor)) r.Delete("/", rest.Delete(constructor))
r.Post("/image", uploadPlaylistImage(api.playlists))
r.Delete("/image", deletePlaylistImage(api.playlists))
}) })
}) })
} }
+105
View File
@@ -5,7 +5,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -15,6 +21,7 @@ import (
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/req"
_ "golang.org/x/image/webp"
) )
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
@@ -224,3 +231,101 @@ func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc {
_, _ = w.Write(data) //nolint:gosec _, _ = w.Write(data) //nolint:gosec
} }
} }
const maxImageSize = 10 << 20 // 10MB
func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
p := req.Params(r)
playlistId, _ := p.String(":id")
if err := r.ParseMultipartForm(maxImageSize); err != nil {
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
}
}
func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
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
}
}
+7 -1
View File
@@ -218,9 +218,15 @@
"makePrivate": "Make Private", "makePrivate": "Make Private",
"searchOrCreate": "Search playlists or type to create new...", "searchOrCreate": "Search playlists or type to create new...",
"pressEnterToCreate": "Press Enter to create new playlist", "pressEnterToCreate": "Press Enter to create new playlist",
"removeFromSelection": "Remove from selection" "removeFromSelection": "Remove from selection",
"uploadCover": "Upload Cover",
"removeCover": "Remove Cover"
}, },
"message": { "message": {
"coverUploaded": "Cover art updated",
"coverRemoved": "Cover art removed",
"coverUploadError": "Error uploading cover art",
"coverRemoveError": "Error removing cover art",
"duplicate_song": "Add duplicated songs", "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?", "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?",
"noPlaylistsFound": "No playlists found", "noPlaylistsFound": "No playlists found",
+133 -3
View File
@@ -2,16 +2,27 @@ import {
Card, Card,
CardContent, CardContent,
CardMedia, CardMedia,
IconButton,
Tooltip,
Typography, Typography,
useMediaQuery, useMediaQuery,
} from '@material-ui/core' } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useTranslate } from 'react-admin' import PhotoCameraIcon from '@material-ui/icons/PhotoCamera'
import { useCallback, useState, useEffect } from 'react' import DeleteIcon from '@material-ui/icons/Delete'
import { useTranslate, useNotify, useRefresh } from 'react-admin'
import { useCallback, useRef, useState, useEffect } from 'react'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import 'react-image-lightbox/style.css' import 'react-image-lightbox/style.css'
import { CollapsibleComment, DurationField, SizeField } from '../common' import {
CollapsibleComment,
DurationField,
SizeField,
isWritable,
} from '../common'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { REST_URL } from '../consts'
import { httpClient } from '../dataProvider'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@@ -55,6 +66,7 @@ const useStyles = makeStyles(
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
position: 'relative',
}, },
cover: { cover: {
objectFit: 'contain', objectFit: 'contain',
@@ -68,6 +80,31 @@ const useStyles = makeStyles(
coverLoading: { coverLoading: {
opacity: 0.5, 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: { title: {
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
@@ -86,14 +123,18 @@ const useStyles = makeStyles(
const PlaylistDetails = (props) => { const PlaylistDetails = (props) => {
const { record = {} } = props const { record = {} } = props
const translate = useTranslate() const translate = useTranslate()
const notify = useNotify()
const refresh = useRefresh()
const classes = useStyles() const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const [isLightboxOpen, setLightboxOpen] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false)
const [imageLoading, setImageLoading] = useState(false) const [imageLoading, setImageLoading] = useState(false)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const fileInputRef = useRef(null)
const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
const canEdit = isWritable(record.ownerId)
// Reset image state when playlist changes // Reset image state when playlist changes
useEffect(() => { useEffect(() => {
@@ -119,6 +160,60 @@ const PlaylistDetails = (props) => {
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) 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 ( return (
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>
@@ -138,6 +233,41 @@ const PlaylistDetails = (props) => {
cursor: imageError ? 'default' : 'pointer', 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.imageFile && (
<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>
)}
</div> </div>
<div className={classes.details}> <div className={classes.details}>
<CardContent className={classes.content}> <CardContent className={classes.content}>
+27
View File
@@ -16,6 +16,7 @@ import {
usePermissions, usePermissions,
} from 'react-admin' } from 'react-admin'
import Switch from '@material-ui/core/Switch' import Switch from '@material-ui/core/Switch'
import { Avatar } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery } from '@material-ui/core'
import { import {
@@ -28,11 +29,17 @@ import {
} from '../common' } from '../common'
import PlaylistListActions from './PlaylistListActions' import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton' import ChangePublicStatusButton from './ChangePublicStatusButton'
import subsonic from '../subsonic'
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
button: { button: {
color: theme.palette.type === 'dark' ? 'white' : undefined, color: theme.palette.type === 'dark' ? 'white' : undefined,
}, },
coverArt: {
width: '40px',
height: '40px',
borderRadius: '4px',
},
})) }))
const PlaylistFilter = (props) => { const PlaylistFilter = (props) => {
@@ -119,6 +126,25 @@ const ToggleAutoImport = ({ resource, source }) => {
) : null ) : null
} }
const CoverArtField = () => {
const classes = useStyles()
const record = useRecordContext()
if (!record) return null
return (
<Avatar
src={subsonic.getCoverArtUrl(record, 80, true)}
variant="square"
className={classes.coverArt}
alt={record.name}
/>
)
}
CoverArtField.defaultProps = {
label: '',
sortable: false,
}
const PlaylistListBulkActions = (props) => { const PlaylistListBulkActions = (props) => {
const classes = useStyles() const classes = useStyles()
return ( return (
@@ -176,6 +202,7 @@ const PlaylistList = (props) => {
bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} bulkActionButtons={!isXsmall && <PlaylistListBulkActions />}
> >
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
<CoverArtField source="id" />
<TextField source="name" /> <TextField source="name" />
{columns} {columns}
<Writable> <Writable>