mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-05 09:48:37 +01:00
Enforce read-only restrictions for plugin playlists (PluginID != "") consistently across backend and UI, matching the existing smart playlist behavior. The Subsonic buildOSPlaylist now uses IsReadOnly() to mark both smart and plugin playlists as readonly. Backend Update and REST adapter block name/comment changes on plugin playlists while still allowing public toggle and cover art. The UI adds isPluginPlaylist helper to canChangeTracks, disables name/comment inputs in PlaylistEdit, disables auto-import toggle in PlaylistList, and adds a server-side readonly filter to exclude both smart and plugin playlists from the "add to playlist" dialog.
325 lines
10 KiB
Go
325 lines
10 KiB
Go
package playlists
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/bmatcuk/doublestar/v4"
|
|
"github.com/deluan/rest"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
)
|
|
|
|
type Playlists interface {
|
|
// Reads
|
|
GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error)
|
|
Get(ctx context.Context, id string) (*model.Playlist, error)
|
|
GetWithTracks(ctx context.Context, id string) (*model.Playlist, error)
|
|
GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error)
|
|
|
|
// Mutations
|
|
Create(ctx context.Context, playlistId string, name string, ids []string) (string, error)
|
|
Delete(ctx context.Context, id string) error
|
|
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
|
|
|
// Track management
|
|
AddTracks(ctx context.Context, playlistID string, ids []string) (int, error)
|
|
AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error)
|
|
AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error)
|
|
AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error)
|
|
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)
|
|
|
|
// REST adapters (follows Share/Library pattern)
|
|
NewRepository(ctx context.Context) rest.Repository
|
|
TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository
|
|
}
|
|
|
|
// ImageUploadService is a local interface satisfied by core.ImageUploadService.
|
|
// Defined here to avoid an import cycle between core and core/playlists.
|
|
type ImageUploadService interface {
|
|
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
|
|
RemoveImage(ctx context.Context, path string) error
|
|
}
|
|
|
|
type playlists struct {
|
|
ds model.DataStore
|
|
imgUpload ImageUploadService
|
|
}
|
|
|
|
func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists {
|
|
return &playlists{ds: ds, imgUpload: imgUpload}
|
|
}
|
|
|
|
func InPath(folder model.Folder) bool {
|
|
if conf.Server.PlaylistsPath == "" {
|
|
return true
|
|
}
|
|
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
|
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
|
if match, _ := doublestar.Match(path, rel); match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- Read operations ---
|
|
|
|
func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) {
|
|
return s.ds.Playlist(ctx).GetAll(options...)
|
|
}
|
|
|
|
func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
|
return s.ds.Playlist(ctx).Get(id)
|
|
}
|
|
|
|
func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) {
|
|
return s.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
|
}
|
|
|
|
func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) {
|
|
return s.ds.Playlist(ctx).GetPlaylists(mediaFileId)
|
|
}
|
|
|
|
// --- Mutation operations ---
|
|
|
|
// Create creates a new playlist (when name is provided) or replaces tracks on an existing
|
|
// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics.
|
|
func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) {
|
|
usr, _ := request.UserFrom(ctx)
|
|
err := s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
var pls *model.Playlist
|
|
var err error
|
|
|
|
if playlistId != "" {
|
|
pls, err = tx.Playlist(ctx).Get(playlistId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
} else {
|
|
pls = &model.Playlist{Name: name}
|
|
pls.OwnerID = usr.ID
|
|
}
|
|
pls.Tracks = nil
|
|
pls.AddMediaFilesByID(ids)
|
|
|
|
err = tx.Playlist(ctx).Put(pls)
|
|
playlistId = pls.ID
|
|
return err
|
|
})
|
|
return playlistId, err
|
|
}
|
|
|
|
func (s *playlists) Delete(ctx context.Context, id string) error {
|
|
pls, err := s.checkWritable(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up custom cover image file if one exists
|
|
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)
|
|
}
|
|
}
|
|
|
|
return s.ds.Playlist(ctx).Delete(id)
|
|
}
|
|
|
|
func (s *playlists) Update(ctx context.Context, playlistID string,
|
|
name *string, comment *string, public *bool,
|
|
idsToAdd []string, idxToRemove []int) error {
|
|
var pls *model.Playlist
|
|
var err error
|
|
hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0
|
|
if hasTrackChanges {
|
|
pls, err = s.checkTracksEditable(ctx, playlistID)
|
|
} else {
|
|
pls, err = s.checkWritable(ctx, playlistID)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Plugin playlists allow public toggle and cover art, but block name/comment changes
|
|
if pls.IsPluginPlaylist() && (name != nil || comment != nil) {
|
|
return model.ErrNotAuthorized
|
|
}
|
|
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
|
repo := tx.Playlist(ctx)
|
|
|
|
if len(idxToRemove) > 0 {
|
|
tracksRepo := repo.Tracks(playlistID, false)
|
|
// Convert 0-based indices to 1-based position IDs and delete them directly,
|
|
// avoiding the need to load all tracks into memory.
|
|
positions := make([]string, len(idxToRemove))
|
|
for i, idx := range idxToRemove {
|
|
positions[i] = strconv.Itoa(idx + 1)
|
|
}
|
|
if err := tracksRepo.Delete(positions...); err != nil {
|
|
return err
|
|
}
|
|
if len(idsToAdd) > 0 {
|
|
if _, err := tracksRepo.Add(idsToAdd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
}
|
|
|
|
if len(idsToAdd) > 0 {
|
|
if _, err := repo.Tracks(playlistID, false).Add(idsToAdd); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if name == nil && comment == nil && public == nil {
|
|
return nil
|
|
}
|
|
// Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters)
|
|
return s.updateMetadata(ctx, tx, pls, name, comment, public)
|
|
})
|
|
}
|
|
|
|
// --- Permission helpers ---
|
|
|
|
// checkWritable fetches the playlist and verifies the current user can modify it.
|
|
func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) {
|
|
pls, err := s.ds.Playlist(ctx).Get(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
usr, _ := request.UserFrom(ctx)
|
|
if !usr.IsAdmin && pls.OwnerID != usr.ID {
|
|
return nil, model.ErrNotAuthorized
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
// checkTracksEditable verifies the user can modify tracks (ownership + not smart/plugin playlist).
|
|
func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) {
|
|
pls, err := s.checkWritable(ctx, playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if pls.IsSmartPlaylist() || pls.IsPluginPlaylist() {
|
|
return nil, model.ErrNotAuthorized
|
|
}
|
|
return pls, nil
|
|
}
|
|
|
|
// updateMetadata applies optional metadata changes to a playlist and persists it.
|
|
// Accepts a DataStore parameter so it can be used inside transactions.
|
|
// The caller is responsible for permission checks.
|
|
func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error {
|
|
if name != nil {
|
|
pls.Name = *name
|
|
}
|
|
if comment != nil {
|
|
pls.Comment = *comment
|
|
}
|
|
if public != nil {
|
|
pls.Public = *public
|
|
}
|
|
return ds.Playlist(ctx).Put(pls)
|
|
}
|
|
|
|
// --- Track management operations ---
|
|
|
|
func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist(ctx).Tracks(playlistID, false).Add(ids)
|
|
}
|
|
|
|
func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddAlbums(albumIds)
|
|
}
|
|
|
|
func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddArtists(artistIds)
|
|
}
|
|
|
|
func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.ds.Playlist(ctx).Tracks(playlistID, false).AddDiscs(discs)
|
|
}
|
|
|
|
func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return err
|
|
}
|
|
return s.ds.WithTx(func(tx model.DataStore) error {
|
|
return tx.Playlist(ctx).Tracks(playlistID, false).Delete(trackIds...)
|
|
})
|
|
}
|
|
|
|
func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error {
|
|
if _, err := s.checkTracksEditable(ctx, playlistID); err != nil {
|
|
return err
|
|
}
|
|
return s.ds.WithTx(func(tx model.DataStore) error {
|
|
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
|
|
}
|
|
|
|
oldPath := pls.UploadedImagePath()
|
|
filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pls.UploadedImage = filename
|
|
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 err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
pls.UploadedImage = ""
|
|
return s.ds.Playlist(ctx).Put(pls)
|
|
}
|