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 && (