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}