diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go index 09bfe221b..10ba2dd89 100644 --- a/core/artwork/reader_playlist.go +++ b/core/artwork/reader_playlist.go @@ -45,15 +45,15 @@ func (a *playlistArtworkReader) LastUpdated() time.Time { func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { return selectImageReader(ctx, a.artID, - a.fromPlaylistImage(), + a.fromPlaylistUploadedImage(), a.fromGeneratedTiledCover(ctx), fromAlbumPlaceholder(), ) } -func (a *playlistArtworkReader) fromPlaylistImage() sourceFunc { +func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { return func() (io.ReadCloser, string, error) { - absPath := a.pl.ArtworkPath() + absPath := a.pl.UploadedImagePath() if absPath == "" { return nil, "", nil } diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 7e881b730..d84d5a088 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -131,7 +131,7 @@ func (s *playlists) Delete(ctx context.Context, id string) error { } // Clean up custom cover image file if one exists - if path := pls.ArtworkPath(); path != "" { + if path := pls.UploadedImagePath(); path != "" { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err) } @@ -289,9 +289,9 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } filename := playlistID + ext - oldPath := pls.ArtworkPath() - pls.ImageFile = filename - absPath := pls.ArtworkPath() + 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) @@ -324,12 +324,12 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { return err } - if path := pls.ArtworkPath(); path != "" { + 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) } } - pls.ImageFile = "" + pls.UploadedImage = "" return s.ds.Playlist(ctx).Put(pls) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 4d42bbeb9..9b835924f 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -315,13 +315,13 @@ var _ = Describe("Playlists", func() { ps = playlists.NewPlaylists(ds) }) - It("saves image file and updates ImageFile", func() { + It("saves image file and updates UploadedImage", 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")) + Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1.jpg")) absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") data, err := os.ReadFile(absPath) Expect(err).ToNot(HaveOccurred()) @@ -378,19 +378,19 @@ var _ = Describe("Playlists", func() { 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-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "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() { + It("removes file and clears UploadedImage", 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()) + Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") Expect(absPath).ToNot(BeAnExistingFile()) }) @@ -399,7 +399,7 @@ var _ = Describe("Playlists", 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()) + Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) It("denies non-owner", func() { diff --git a/model/playlist.go b/model/playlist.go index b6a52dcf2..35f6ca5c6 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -12,21 +12,22 @@ import ( ) type Playlist struct { - ID string `structs:"id" json:"id"` - Name string `structs:"name" json:"name"` - Comment string `structs:"comment" json:"comment"` - Duration float32 `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - SongCount int `structs:"song_count" json:"songCount"` - OwnerName string `structs:"-" json:"ownerName"` - OwnerID string `structs:"owner_id" json:"ownerId"` - Public bool `structs:"public" json:"public"` - 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"` + ID string `structs:"id" json:"id"` + Name string `structs:"name" json:"name"` + Comment string `structs:"comment" json:"comment"` + Duration float32 `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + SongCount int `structs:"song_count" json:"songCount"` + OwnerName string `structs:"-" json:"ownerName"` + OwnerID string `structs:"owner_id" json:"ownerId"` + Public bool `structs:"public" json:"public"` + Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` + Path string `structs:"path" json:"path"` + Sync bool `structs:"sync" json:"sync"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage"` + ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // SmartPlaylist attributes Rules *criteria.Criteria `structs:"rules" json:"rules"` @@ -110,11 +111,15 @@ func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } -func (pls Playlist) ArtworkPath() string { - if pls.ImageFile == "" { +// UploadedImagePath returns the absolute filesystem path for a manually uploaded +// playlist cover image. Returns empty string if no image has been uploaded. +// 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.ImageFile) + return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, "playlist", pls.UploadedImage) } type Playlists []Playlist diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 4c242dd2a..b24446cb9 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -246,7 +246,7 @@ const PlaylistDetails = (props) => { - {record.imageFile && ( + {record.uploadedImage && (