Files
navidrome/model/playlist.go
T
adrbn d004f99f8f 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>
2026-03-01 14:07:18 -05:00

165 lines
4.4 KiB
Go

package model
import (
"path/filepath"
"slices"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model/criteria"
)
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"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
}
func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Expression != nil
}
func (pls Playlist) MediaFiles() MediaFiles {
if len(pls.Tracks) == 0 {
return nil
}
return pls.Tracks.MediaFiles()
}
func (pls *Playlist) refreshStats() {
pls.SongCount = len(pls.Tracks)
pls.Duration = 0
pls.Size = 0
for _, t := range pls.Tracks {
pls.Duration += t.MediaFile.Duration
pls.Size += t.MediaFile.Size
}
}
func (pls *Playlist) SetTracks(tracks PlaylistTracks) {
pls.Tracks = tracks
pls.refreshStats()
}
func (pls *Playlist) RemoveTracks(idxToRemove []int) {
var newTracks PlaylistTracks
for i, t := range pls.Tracks {
if slices.Contains(idxToRemove, i) {
continue
}
newTracks = append(newTracks, t)
}
pls.Tracks = newTracks
pls.refreshStats()
}
// ToM3U8 exports the playlist to the Extended M3U8 format
func (pls *Playlist) ToM3U8() string {
return pls.MediaFiles().ToM3U8(pls.Name, true)
}
func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) {
pos := len(pls.Tracks)
for _, mfId := range mediaFileIds {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mfId,
MediaFile: MediaFile{ID: mfId},
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
pls.refreshStats()
}
func (pls *Playlist) AddMediaFiles(mfs MediaFiles) {
pos := len(pls.Tracks)
for _, mf := range mfs {
pos++
t := PlaylistTrack{
ID: strconv.Itoa(pos),
MediaFileID: mf.ID,
MediaFile: mf,
PlaylistID: pls.ID,
}
pls.Tracks = append(pls.Tracks, t)
}
pls.refreshStats()
}
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 {
ResourceRepository
CountAll(options ...QueryOptions) (int64, error)
Exists(id string) (bool, error)
Put(pls *Playlist) error
Get(id string) (*Playlist, error)
GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error)
GetAll(options ...QueryOptions) (Playlists, error)
FindByPath(path string) (*Playlist, error)
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(mediaFileId string) (Playlists, error)
}
type PlaylistTrack struct {
ID string `json:"id"`
MediaFileID string `json:"mediaFileId"`
PlaylistID string `json:"playlistId"`
MediaFile
}
type PlaylistTracks []PlaylistTrack
func (plt PlaylistTracks) MediaFiles() MediaFiles {
mfs := make(MediaFiles, len(plt))
for i, t := range plt {
mfs[i] = t.MediaFile
}
return mfs
}
type PlaylistTrackRepository interface {
ResourceRepository
GetAll(options ...QueryOptions) (PlaylistTracks, error)
GetAlbumIDs(options ...QueryOptions) ([]string, error)
Add(mediaFileIds []string) (int, error)
AddAlbums(albumIds []string) (int, error)
AddArtists(artistIds []string) (int, error)
AddDiscs(discs []DiscID) (int, error)
Delete(id ...string) error
DeleteAll() error
Reorder(pos int, newPos int) error
}