From d004f99f8f2ab3f753e3847bdf28548f3a5a3753 Mon Sep 17 00:00:00 2001 From: adrbn <128328324+adrbn@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:07:18 +0100 Subject: [PATCH] feat(playlist): add custom playlist cover art upload (#5110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Signed-off-by: adrbn <128328324+adrbn@users.noreply.github.com> * refactor: rename playlist image path migration file Signed-off-by: Deluan * 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 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 * refactor(playlist): streamline artwork image selection logic Signed-off-by: Deluan * refactor: move translation keys, add pt-BR translations Signed-off-by: Deluan * 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 Co-authored-by: Claude Opus 4.6 Co-authored-by: Deluan Quintão --- conf/configuration.go | 6 + consts/consts.go | 1 + core/artwork/reader_playlist.go | 19 ++- core/playlists/playlists.go | 72 +++++++++- core/playlists/playlists_test.go | 120 ++++++++++++++++ .../20260228172956_add_playlist_image_file.go | 22 +++ model/playlist.go | 11 ++ resources/i18n/pt-br.json | 8 +- server/nativeapi/native_api.go | 2 + server/nativeapi/playlists.go | 105 ++++++++++++++ ui/src/i18n/en.json | 8 +- ui/src/playlist/PlaylistDetails.jsx | 136 +++++++++++++++++- ui/src/playlist/PlaylistList.jsx | 27 ++++ 13 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 db/migrations/20260228172956_add_playlist_image_file.go diff --git a/conf/configuration.go b/conf/configuration.go index 61448d31..3e3b4235 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -303,6 +303,12 @@ func Load(noConfigDump bool) { 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.Folder == "" { Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") diff --git a/consts/consts.go b/consts/consts.go index ebde9d1d..295abe8a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -65,6 +65,7 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" + ArtworkFolder = "artwork" PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index a9f289ad..09bfe221 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -8,6 +8,7 @@ import ( "image/draw" "image/png" "io" + "os" "time" "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) { - ff := []sourceFunc{ + return selectImageReader(ctx, a.artID, + a.fromPlaylistImage(), a.fromGeneratedTiledCover(ctx), 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 { diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 5a9a908e..7e881b73 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -2,7 +2,9 @@ package playlists import ( "context" + "fmt" "io" + "os" "path/filepath" "strconv" "strings" @@ -10,6 +12,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) @@ -34,6 +37,10 @@ type Playlists interface { RemoveTracks(ctx context.Context, playlistID string, trackIds []string) 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 ImportFile(ctx context.Context, folder *model.Folder, filename string) (*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 { - if _, err := s.checkWritable(ctx, id); err != nil { + pls, err := s.checkWritable(ctx, id) + if err != nil { 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) } @@ -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) }) } + +// --- 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) +} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index a4c309d7..4d42bbeb 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -2,7 +2,12 @@ package playlists_test import ( "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/model" "github.com/navidrome/navidrome/model/criteria" @@ -294,4 +299,119 @@ var _ = Describe("Playlists", func() { 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)) + }) + }) }) diff --git a/db/migrations/20260228172956_add_playlist_image_file.go b/db/migrations/20260228172956_add_playlist_image_file.go new file mode 100644 index 00000000..da2177ab --- /dev/null +++ b/db/migrations/20260228172956_add_playlist_image_file.go @@ -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 +} diff --git a/model/playlist.go b/model/playlist.go index a87019ed..b6a52dcf 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,10 +1,13 @@ package model import ( + "path/filepath" "slices" "strconv" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" ) @@ -21,6 +24,7 @@ type Playlist struct { Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` Path string `structs:"path" json:"path"` Sync bool `structs:"sync" json:"sync"` + ImageFile string `structs:"image_file" json:"imageFile"` CreatedAt time.Time `structs:"created_at" json:"createdAt"` UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` @@ -106,6 +110,13 @@ func (pls Playlist) CoverArtID() ArtworkID { 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 PlaylistRepository interface { diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 1fc72bf3..6c9e154d 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -218,9 +218,15 @@ "saveQueue": "Salvar fila em nova Playlist", "searchOrCreate": "Buscar playlists ou criar nova...", "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção" + "removeFromSelection": "Remover da seleção", + "uploadCover": "Enviar Capa", + "removeCover": "Remover Capa" }, "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", diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 062cbf70..3191991e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -140,6 +140,8 @@ func (api *Router) addPlaylistRoute(r chi.Router) { r.Get("/", rest.Get(constructor)) r.Put("/", rest.Put(constructor)) r.Delete("/", rest.Delete(constructor)) + r.Post("/image", uploadPlaylistImage(api.playlists)) + r.Delete("/image", deletePlaylistImage(api.playlists)) }) }) } diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 60e8024b..c7230a20 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,7 +5,13 @@ import ( "encoding/json" "errors" "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" "net/http" + "path/filepath" "strconv" "strings" @@ -15,6 +21,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" + _ "golang.org/x/image/webp" ) 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 } } + +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 + } +} diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 224b1c43..7c4b8ff0 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -218,9 +218,15 @@ "makePrivate": "Make Private", "searchOrCreate": "Search playlists or type to create new...", "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection" + "removeFromSelection": "Remove from selection", + "uploadCover": "Upload Cover", + "removeCover": "Remove Cover" }, "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", diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index acccb15f..4c242dd2 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -2,16 +2,27 @@ import { Card, CardContent, CardMedia, + IconButton, + Tooltip, Typography, useMediaQuery, } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' -import { useTranslate } from 'react-admin' -import { useCallback, useState, useEffect } from 'react' +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 Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' -import { CollapsibleComment, DurationField, SizeField } from '../common' +import { + CollapsibleComment, + DurationField, + SizeField, + isWritable, +} from '../common' import subsonic from '../subsonic' +import { REST_URL } from '../consts' +import { httpClient } from '../dataProvider' const useStyles = makeStyles( (theme) => ({ @@ -55,6 +66,7 @@ const useStyles = makeStyles( display: 'flex', alignItems: 'center', justifyContent: 'center', + position: 'relative', }, cover: { objectFit: 'contain', @@ -68,6 +80,31 @@ 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', @@ -86,14 +123,18 @@ 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) // Reset image state when playlist changes useEffect(() => { @@ -119,6 +160,60 @@ 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 (
@@ -138,6 +233,41 @@ const PlaylistDetails = (props) => { cursor: imageError ? 'default' : 'pointer', }} /> + {canEdit && ( +
+ + + + + + {record.imageFile && ( + + + + + + )} + +
+ )}
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index eae9d863..4ec2d5ca 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -16,6 +16,7 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' +import { Avatar } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { @@ -28,11 +29,17 @@ import { } from '../common' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' +import subsonic from '../subsonic' const useStyles = makeStyles((theme) => ({ button: { color: theme.palette.type === 'dark' ? 'white' : undefined, }, + coverArt: { + width: '40px', + height: '40px', + borderRadius: '4px', + }, })) const PlaylistFilter = (props) => { @@ -119,6 +126,25 @@ const ToggleAutoImport = ({ resource, source }) => { ) : null } +const CoverArtField = () => { + const classes = useStyles() + const record = useRecordContext() + if (!record) return null + return ( + + ) +} + +CoverArtField.defaultProps = { + label: '', + sortable: false, +} + const PlaylistListBulkActions = (props) => { const classes = useStyles() return ( @@ -176,6 +202,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && } > isWritable(r?.ownerId)}> + {columns}