refactor: move playlist business logic from repositories to service layer (#5027)
* refactor: move playlist business logic from repositories to core.Playlists service Move authorization, permission checks, and orchestration logic from playlist repositories to the core.Playlists service, following the existing pattern used by core.Share and core.Library. Changes: - Expand core.Playlists interface with read, mutation, track management, and REST adapter methods - Add playlistRepositoryWrapper for REST Save/Update/Delete with permission checks (follows Share/Library pattern) - Simplify persistence/playlist_repository.go: remove isWritable(), auth checks from Delete()/Put()/updatePlaylist() - Simplify persistence/playlist_track_repository.go: remove isTracksEditable() and permission checks from Add/Delete/Reorder - Update Subsonic API handlers to route through service - Update Native API handlers to accept core.Playlists instead of model.DataStore * test: add coverage for playlist service methods and REST wrapper Add 30 new tests covering the service methods added during the playlist refactoring: - Delete: owner, admin, denied, not found - Create: new playlist, replace tracks, admin bypass, denied, not found - AddTracks: owner, admin, denied, smart playlist, not found - RemoveTracks: owner, smart playlist denied, non-owner denied - ReorderTrack: owner, smart playlist denied - NewRepository wrapper: Save (owner assignment, ID clearing), Update (owner, admin, denied, ownership change, not found), Delete (delegation with permission checks) Expand mockedPlaylistRepo with Get, Delete, Tracks, GetWithTracks, and rest.Persistable methods. Add mockedPlaylistTrackRepo for track operation verification. * fix: add authorization check to playlist Update method Added ownership verification to the Subsonic Update endpoint in the playlist service layer. The authorization check was present in the old repository code but was not carried over during the refactoring to the service layer, allowing any authenticated user to modify playlists they don't own via the Subsonic API. Also added corresponding tests for the Update method's permission logic. * refactor: improve playlist permission checks and error handling, add e2e tests Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename core.Playlists to playlists package and update references Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename playlists_internal_test.go to parse_m3u_test.go and update tests; add new parse_nsp.go and rest_adapter.go files Signed-off-by: Deluan <deluan@navidrome.org> * fix: block track mutations on smart playlists in Create and Update Create now rejects replacing tracks on smart playlists (pre-existing gap). Update now uses checkTracksEditable instead of checkWritable when track changes are requested, restoring the protection that was removed from the repository layer during the refactoring. Metadata-only updates on smart playlists remain allowed. * test: add smart playlist protection tests to ensure readonly behavior and mutation restrictions * refactor: optimize track removal and renumbering in playlists Signed-off-by: Deluan <deluan@navidrome.org> * refactor: implement track reordering in playlists with SQL updates Signed-off-by: Deluan <deluan@navidrome.org> * refactor: wrap track deletion and reordering in transactions for consistency Signed-off-by: Deluan <deluan@navidrome.org> * refactor: remove unused getTracks method from playlistTrackRepository Signed-off-by: Deluan <deluan@navidrome.org> * refactor: optimize playlist track renumbering with CTE-based UPDATE Replace the DELETE + re-INSERT renumbering strategy with a two-step UPDATE approach using a materialized CTE and ROW_NUMBER() window function. The previous approach (SELECT all IDs, DELETE all tracks, re-INSERT in chunks of 200) required 13 SQL operations for a 2000-track playlist. The new approach uses just 2 UPDATEs: first negating all IDs to clear the positive space, then assigning sequential positions via UPDATE...FROM with a CTE. This avoids the UNIQUE constraint violations that affected the original correlated subquery while reducing per-delete request time from ~110ms to ~12ms on a 2000-track playlist. Signed-off-by: Deluan <deluan@navidrome.org> * refactor: rename New function to NewPlaylists for clarity Signed-off-by: Deluan <deluan@navidrome.org> * refactor: update mock playlist repository and tests for consistency Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core_test
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
@@ -19,18 +19,18 @@ import (
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var _ = Describe("Playlists - Import", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps core.Playlists
|
||||
var mockPlsRepo mockedPlaylistRepo
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
var mockLibRepo *tests.MockLibraryRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = mockedPlaylistRepo{}
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
mockLibRepo = &tests.MockLibraryRepo{}
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: &mockPlsRepo,
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: mockLibRepo,
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
@@ -39,7 +39,7 @@ var _ = Describe("Playlists", func() {
|
||||
Describe("ImportFile", func() {
|
||||
var folder *model.Folder
|
||||
BeforeEach(func() {
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
ds.MockedMediaFile = &mockedMediaFileRepo{}
|
||||
libPath, _ := os.Getwd()
|
||||
// Set up library with the actual library path that matches the folder
|
||||
@@ -61,7 +61,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg"))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("parses playlists using LF ending", func() {
|
||||
@@ -99,7 +99,7 @@ var _ = Describe("Playlists", func() {
|
||||
It("parses well-formed playlists", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
Expect(pls.OwnerID).To(Equal("123"))
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Comment).To(Equal("Recently played tracks"))
|
||||
@@ -149,7 +149,7 @@ var _ = Describe("Playlists", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
|
||||
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
// Create the playlist file on disk with the filesystem's normalization form
|
||||
plsFile := tmpDir + "/" + filesystemName + ".m3u"
|
||||
@@ -163,7 +163,7 @@ var _ = Describe("Playlists", func() {
|
||||
Path: storedPath,
|
||||
Sync: true,
|
||||
}
|
||||
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
|
||||
mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls}
|
||||
|
||||
// Import using the filesystem's normalization form
|
||||
plsFolder := &model.Folder{
|
||||
@@ -209,7 +209,7 @@ var _ = Describe("Playlists", func() {
|
||||
"def.mp3", // This is playlists/def.mp3 relative to plsDir
|
||||
},
|
||||
}
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("handles relative paths that reference files in other libraries", func() {
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Playlists", func() {
|
||||
},
|
||||
}
|
||||
// Recreate playlists service to pick up new mock
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
|
||||
// Create playlist in music library that references both tracks
|
||||
plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
|
||||
@@ -408,7 +408,7 @@ var _ = Describe("Playlists", func() {
|
||||
BeforeEach(func() {
|
||||
repo = &mockedMediaFileFromListRepo{}
|
||||
ds.MockedMediaFile = repo
|
||||
ps = core.NewPlaylists(ds)
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
@@ -439,7 +439,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg"))
|
||||
Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac"))
|
||||
Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3"))
|
||||
Expect(mockPlsRepo.last).To(Equal(pls))
|
||||
Expect(mockPlsRepo.Last).To(Equal(pls))
|
||||
})
|
||||
|
||||
It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() {
|
||||
@@ -460,7 +460,7 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns only tracks that exist in the database and in the same other as the m3u", func() {
|
||||
It("returns only tracks that exist in the database and in the same order as the m3u", func() {
|
||||
repo.data = []string{
|
||||
"album1/test1.mp3",
|
||||
"album2/test2.mp3",
|
||||
@@ -570,7 +570,7 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
})
|
||||
|
||||
Describe("InPlaylistsPath", func() {
|
||||
Describe("InPath", func() {
|
||||
var folder model.Folder
|
||||
|
||||
BeforeEach(func() {
|
||||
@@ -584,27 +584,27 @@ var _ = Describe("Playlists", func() {
|
||||
|
||||
It("returns true if PlaylistsPath is empty", func() {
|
||||
conf.Server.PlaylistsPath = ""
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if PlaylistsPath is any (**/**)", func() {
|
||||
conf.Server.PlaylistsPath = "**/**"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns true if folder is in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other/**:playlists/**"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false if folder is not in PlaylistsPath", func() {
|
||||
conf.Server.PlaylistsPath = "other"
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
|
||||
conf.Server.PlaylistsPath = "."
|
||||
Expect(core.InPlaylistsPath(folder)).To(BeFalse())
|
||||
Expect(playlists.InPath(folder)).To(BeFalse())
|
||||
|
||||
folder2 := model.Folder{
|
||||
LibraryPath: "/music",
|
||||
@@ -612,7 +612,7 @@ var _ = Describe("Playlists", func() {
|
||||
Name: ".",
|
||||
}
|
||||
|
||||
Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
|
||||
Expect(playlists.InPath(folder2)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -693,23 +693,3 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
|
||||
}
|
||||
return mfs, nil
|
||||
}
|
||||
|
||||
type mockedPlaylistRepo struct {
|
||||
last *model.Playlist
|
||||
data map[string]*model.Playlist // keyed by path
|
||||
model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
|
||||
if r.data != nil {
|
||||
if pls, ok := r.data[path]; ok {
|
||||
return pls, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error {
|
||||
r.last = pls
|
||||
return nil
|
||||
}
|
||||
@@ -1,183 +1,28 @@
|
||||
package core
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/ioutils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type Playlists interface {
|
||||
ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error)
|
||||
Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error
|
||||
ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error)
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
func InPlaylistsPath(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
|
||||
}
|
||||
|
||||
func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) {
|
||||
pls, err := s.parsePlaylist(ctx, filename, folder)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
return nil, err
|
||||
}
|
||||
log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks))
|
||||
err = s.updatePlaylist(ctx, pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
pls := &model.Playlist{
|
||||
OwnerID: owner.ID,
|
||||
Public: false,
|
||||
Sync: false,
|
||||
}
|
||||
err := s.parseM3U(ctx, pls, nil, reader)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error parsing playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
err = s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error saving playlist", err)
|
||||
return nil, err
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) {
|
||||
pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Open(pls.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := ioutils.UTF8Reader(file)
|
||||
extension := strings.ToLower(filepath.Ext(playlistFile))
|
||||
switch extension {
|
||||
case ".nsp":
|
||||
err = s.parseNSP(ctx, pls, reader)
|
||||
default:
|
||||
err = s.parseM3U(ctx, pls, folder, reader)
|
||||
}
|
||||
return pls, err
|
||||
}
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
|
||||
mediaFileRepository := s.ds.MediaFile(ctx)
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var mfs model.MediaFiles
|
||||
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
|
||||
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
|
||||
@@ -202,7 +47,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
}
|
||||
filteredLines = append(filteredLines, line)
|
||||
}
|
||||
resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
|
||||
resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
|
||||
continue
|
||||
@@ -258,7 +103,9 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
existing[key] = idx
|
||||
}
|
||||
|
||||
// Find media files in the order of the resolved paths, to keep playlist order
|
||||
// Find media files in the order of the resolved paths, to keep playlist order.
|
||||
// Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath",
|
||||
// so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant).
|
||||
for _, path := range resolvedPaths {
|
||||
key := strings.ToLower(norm.NFC.String(path))
|
||||
idx, ok := existing[key]
|
||||
@@ -398,15 +245,10 @@ func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||
// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
|
||||
// For relative paths, it resolves them to absolute paths first, then determines which
|
||||
// library they belong to. This allows playlists to reference files across library boundaries.
|
||||
func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
resolver, err := newPathResolver(ctx, s.ds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
|
||||
results := make([]string, 0, len(lines))
|
||||
for idx, line := range lines {
|
||||
resolution := resolver.resolvePath(line, folder)
|
||||
resolution := r.resolvePath(line, folder)
|
||||
|
||||
if !resolution.valid {
|
||||
log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
|
||||
@@ -425,123 +267,3 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
|
||||
owner, _ := request.UserFrom(ctx)
|
||||
|
||||
// Try to find existing playlist by path. Since filesystem normalization differs across
|
||||
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
|
||||
// playlists that may have been imported on a different platform.
|
||||
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
// Try alternate normalization form
|
||||
altPath := norm.NFD.String(newPls.Path)
|
||||
if altPath == newPls.Path {
|
||||
altPath = norm.NFC.String(newPls.Path)
|
||||
}
|
||||
if altPath != newPls.Path {
|
||||
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
|
||||
}
|
||||
}
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && !pls.Sync {
|
||||
log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
|
||||
newPls.ID = pls.ID
|
||||
newPls.Name = pls.Name
|
||||
newPls.Comment = pls.Comment
|
||||
newPls.OwnerID = pls.OwnerID
|
||||
newPls.Public = pls.Public
|
||||
newPls.EvaluatedAt = &time.Time{}
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
|
||||
func (s *playlists) Update(ctx context.Context, playlistID string,
|
||||
name *string, comment *string, public *bool,
|
||||
idsToAdd []string, idxToRemove []int) error {
|
||||
needsInfoUpdate := name != nil || comment != nil || public != nil
|
||||
needsTrackRefresh := len(idxToRemove) > 0
|
||||
|
||||
return s.ds.WithTxImmediate(func(tx model.DataStore) error {
|
||||
var pls *model.Playlist
|
||||
var err error
|
||||
repo := tx.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistID, true)
|
||||
if tracks == nil {
|
||||
return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID)
|
||||
}
|
||||
if needsTrackRefresh {
|
||||
pls, err = repo.GetWithTracks(playlistID, true, false)
|
||||
pls.RemoveTracks(idxToRemove)
|
||||
pls.AddMediaFilesByID(idsToAdd)
|
||||
} else {
|
||||
if len(idsToAdd) > 0 {
|
||||
_, err = tracks.Add(idsToAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if needsInfoUpdate {
|
||||
pls, err = repo.Get(playlistID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !needsTrackRefresh && !needsInfoUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if name != nil {
|
||||
pls.Name = *name
|
||||
}
|
||||
if comment != nil {
|
||||
pls.Comment = *comment
|
||||
}
|
||||
if public != nil {
|
||||
pls.Public = *public
|
||||
}
|
||||
// Special case: The playlist is now empty
|
||||
if len(idxToRemove) > 0 && len(pls.Tracks) == 0 {
|
||||
if err = tracks.DeleteAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return repo.Put(pls)
|
||||
})
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package core
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -214,38 +214,38 @@ var _ = Describe("pathResolver", func() {
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
Context("basic", func() {
|
||||
It("resolves absolute paths", func() {
|
||||
resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.libraryPath).To(Equal("/music"))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
It("resolves relative paths when folder is provided", func() {
|
||||
folder := &model.Folder{
|
||||
Path: "playlists",
|
||||
LibraryPath: "/music",
|
||||
LibraryID: 1,
|
||||
}
|
||||
|
||||
resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
|
||||
|
||||
Expect(resolution.valid).To(BeTrue())
|
||||
Expect(resolution.libraryID).To(Equal(1))
|
||||
Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
|
||||
})
|
||||
|
||||
It("returns invalid resolution for paths outside any library", func() {
|
||||
resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
|
||||
|
||||
Expect(resolution.valid).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("resolvePath", func() {
|
||||
Context("With absolute paths", func() {
|
||||
Context("cross-library", func() {
|
||||
It("resolves path within a library", func() {
|
||||
resolution := resolver.resolvePath("/music/track.mp3", nil)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/RaveNoX/go-jsoncommentstrip"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
)
|
||||
|
||||
func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) {
|
||||
playlistPath := filepath.Join(baseDir, playlistFile)
|
||||
info, err := os.Stat(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extension = filepath.Ext(playlistFile)
|
||||
var name = playlistFile[0 : len(playlistFile)-len(extension)]
|
||||
|
||||
pls := &model.Playlist{
|
||||
Name: name,
|
||||
Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile),
|
||||
Public: false,
|
||||
Path: playlistPath,
|
||||
Sync: true,
|
||||
UpdatedAt: info.ModTime(),
|
||||
}
|
||||
return pls, nil
|
||||
}
|
||||
|
||||
func getPositionFromOffset(data []byte, offset int64) (line, column int) {
|
||||
line = 1
|
||||
for _, b := range data[:offset] {
|
||||
if b == '\n' {
|
||||
line++
|
||||
column = 1
|
||||
} else {
|
||||
column++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error {
|
||||
nsp := &nspFile{}
|
||||
reader = io.LimitReader(reader, 100*1024) // Limit to 100KB
|
||||
reader = jsoncommentstrip.NewReader(reader)
|
||||
input, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading SmartPlaylist: %w", err)
|
||||
}
|
||||
err = json.Unmarshal(input, nsp)
|
||||
if err != nil {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if errors.As(err, &syntaxErr) {
|
||||
line, col := getPositionFromOffset(input, syntaxErr.Offset)
|
||||
return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err)
|
||||
}
|
||||
return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err)
|
||||
}
|
||||
pls.Rules = &nsp.Criteria
|
||||
if nsp.Name != "" {
|
||||
pls.Name = nsp.Name
|
||||
}
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("parseNSP", func() {
|
||||
var s *playlists
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("parses a well-formed NSP with all fields", func() {
|
||||
nsp := `{
|
||||
"name": "My Smart Playlist",
|
||||
"comment": "A test playlist",
|
||||
"public": true,
|
||||
"all": [{"is": {"loved": true}}],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 50
|
||||
}`
|
||||
pls := &model.Playlist{Name: "default-name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Smart Playlist"))
|
||||
Expect(pls.Comment).To(Equal("A test playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("title"))
|
||||
Expect(pls.Rules.Order).To(Equal("asc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(50))
|
||||
Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{}))
|
||||
})
|
||||
|
||||
It("keeps existing name when NSP has no name field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original Name"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Original Name"))
|
||||
})
|
||||
|
||||
It("keeps existing comment when NSP has no comment field", func() {
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Comment: "Original Comment"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Comment).To(Equal("Original Comment"))
|
||||
})
|
||||
|
||||
It("strips JSON comments before parsing", func() {
|
||||
nsp := `{
|
||||
// Line comment
|
||||
"name": "Commented Playlist",
|
||||
/* Block comment */
|
||||
"all": [{"is": {"loved": true}}]
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Commented Playlist"))
|
||||
})
|
||||
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
|
||||
It("honors explicit public: false over server default", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
nsp := `{"public": false, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns a syntax error with line and column info", func() {
|
||||
nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}"
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist"))
|
||||
Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`))
|
||||
})
|
||||
|
||||
It("returns a parsing error for completely invalid JSON", func() {
|
||||
nsp := `not json at all`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("SmartPlaylist"))
|
||||
})
|
||||
|
||||
It("gracefully handles non-string name field", func() {
|
||||
nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}`
|
||||
pls := &model.Playlist{Name: "Original"}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Type assertion in UnmarshalJSON fails silently; name stays as original
|
||||
Expect(pls.Name).To(Equal("Original"))
|
||||
})
|
||||
|
||||
It("parses criteria with multiple rules", func() {
|
||||
nsp := `{
|
||||
"all": [
|
||||
{"is": {"loved": true}},
|
||||
{"contains": {"title": "rock"}}
|
||||
],
|
||||
"sort": "lastPlayed",
|
||||
"order": "desc",
|
||||
"limit": 100
|
||||
}`
|
||||
pls := &model.Playlist{}
|
||||
err := s.parseNSP(ctx, pls, strings.NewReader(nsp))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Rules).ToNot(BeNil())
|
||||
Expect(pls.Rules.Sort).To(Equal("lastPlayed"))
|
||||
Expect(pls.Rules.Order).To(Equal("desc"))
|
||||
Expect(pls.Rules.Limit).To(Equal(100))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("getPositionFromOffset", func() {
|
||||
It("returns correct position on first line", func() {
|
||||
data := []byte("hello world")
|
||||
line, col := getPositionFromOffset(data, 5)
|
||||
Expect(line).To(Equal(1))
|
||||
Expect(col).To(Equal(5))
|
||||
})
|
||||
|
||||
It("returns correct position after newlines", func() {
|
||||
data := []byte("line1\nline2\nline3")
|
||||
// Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8)
|
||||
line, col := getPositionFromOffset(data, 8)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(3))
|
||||
})
|
||||
|
||||
It("returns correct position at start of new line", func() {
|
||||
data := []byte("line1\nline2")
|
||||
// After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(2))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
|
||||
It("handles multiple newlines", func() {
|
||||
data := []byte("a\nb\nc\nd")
|
||||
// a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6)
|
||||
line, col := getPositionFromOffset(data, 6)
|
||||
Expect(line).To(Equal(4))
|
||||
Expect(col).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("newSyncedPlaylist", func() {
|
||||
var s *playlists
|
||||
|
||||
BeforeEach(func() {
|
||||
s = &playlists{}
|
||||
})
|
||||
|
||||
It("creates a synced playlist with correct attributes", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("test"))
|
||||
Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u")))
|
||||
Expect(pls.Sync).To(BeTrue())
|
||||
Expect(pls.UpdatedAt).ToNot(BeZero())
|
||||
})
|
||||
|
||||
It("strips extension from filename to derive name", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed())
|
||||
|
||||
pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("My Favorites"))
|
||||
})
|
||||
|
||||
It("returns error for non-existent file", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
_, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
type playlists struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func NewPlaylists(ds model.DataStore) Playlists {
|
||||
return &playlists{ds: ds}
|
||||
}
|
||||
|
||||
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() {
|
||||
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 {
|
||||
if _, err := s.checkWritable(ctx, id); err != nil {
|
||||
return 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
|
||||
}
|
||||
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 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() {
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPlaylists(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Playlists Suite")
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Playlists", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to delete their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("allows admin to delete any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from deleting", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "pls-1")
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
Expect(mockPlsRepo.Deleted).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns error when playlist not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Delete(ctx, "nonexistent")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"},
|
||||
"pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("creates a new playlist with owner set from context", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist"))
|
||||
Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("replaces tracks on existing playlist when owner matches", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-1"))
|
||||
Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("allows admin to replace tracks on any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).To(Equal("pls-2"))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from replacing tracks on existing playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-2", "", []string{"song-3"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("returns error when existing playlistId not found", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies replacing tracks on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
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"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin from updating", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil)
|
||||
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})
|
||||
newName := "Updated Name"
|
||||
err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil)
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies adding tracks to a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies removing tracks from a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("allows metadata updates on a smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
newName := "Updated Smart"
|
||||
err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
"pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to add tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2"))
|
||||
})
|
||||
|
||||
It("allows admin to add tracks to any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(count).To(Equal(2))
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies editing smart playlists", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
_, err := ps.AddTracks(ctx, "pls-smart", []string{"song-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.AddTracks(ctx, "nonexistent", []string{"song-1"})
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RemoveTracks", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to remove tracks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2"))
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"})
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReorderTrack", func() {
|
||||
var mockTracks *tests.MockPlaylistTrackRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
mockTracks = &tests.MockPlaylistTrackRepo{}
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
"pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1",
|
||||
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}},
|
||||
}
|
||||
mockPlsRepo.TracksRepo = mockTracks
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
It("allows owner to reorder", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-1", 1, 3)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockTracks.Reordered).To(BeTrue())
|
||||
})
|
||||
|
||||
It("denies on smart playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
err := ps.ReorderTrack(ctx, "pls-smart", 1, 3)
|
||||
Expect(err).To(MatchError(model.ErrNotAuthorized))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
// --- REST adapter (follows Share/Library pattern) ---
|
||||
|
||||
func (s *playlists) NewRepository(ctx context.Context) rest.Repository {
|
||||
return &playlistRepositoryWrapper{
|
||||
ctx: ctx,
|
||||
PlaylistRepository: s.ds.Playlist(ctx),
|
||||
service: s,
|
||||
}
|
||||
}
|
||||
|
||||
// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter.
|
||||
// It satisfies rest.Repository through the embedded PlaylistRepository (via ResourceRepository),
|
||||
// and rest.Persistable by delegating to service methods for all mutations.
|
||||
type playlistRepositoryWrapper struct {
|
||||
model.PlaylistRepository
|
||||
ctx context.Context
|
||||
service *playlists
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return r.service.savePlaylist(r.ctx, entity.(*model.Playlist))
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...)
|
||||
}
|
||||
|
||||
func (r *playlistRepositoryWrapper) Delete(id string) error {
|
||||
err := r.service.Delete(r.ctx, id)
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository {
|
||||
repo := s.ds.Playlist(ctx)
|
||||
tracks := repo.Tracks(playlistId, refreshSmartPlaylist)
|
||||
if tracks == nil {
|
||||
return nil
|
||||
}
|
||||
return tracks.(rest.Repository)
|
||||
}
|
||||
|
||||
// savePlaylist creates a new playlist, assigning the owner from context.
|
||||
func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) {
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
pls.OwnerID = usr.ID
|
||||
pls.ID = "" // Force new creation
|
||||
err := s.ds.Playlist(ctx).Put(pls)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pls.ID, nil
|
||||
}
|
||||
|
||||
// updatePlaylistEntity updates playlist metadata with permission checks.
|
||||
// Used by the REST API wrapper.
|
||||
func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error {
|
||||
current, err := s.checkWritable(ctx, id)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, model.ErrNotFound):
|
||||
return rest.ErrNotFound
|
||||
case errors.Is(err, model.ErrNotAuthorized):
|
||||
return rest.ErrPermissionDenied
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
usr, _ := request.UserFrom(ctx)
|
||||
if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
// Apply ownership change (admin only)
|
||||
if entity.OwnerID != "" {
|
||||
current.OwnerID = entity.OwnerID
|
||||
}
|
||||
return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("REST Adapter", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var ps playlists.Playlists
|
||||
var mockPlsRepo *tests.MockPlaylistRepo
|
||||
ctx := context.Background()
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo = tests.CreateMockPlaylistRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: mockPlsRepo,
|
||||
MockedLibrary: &tests.MockLibraryRepo{},
|
||||
}
|
||||
ctx = request.WithUser(ctx, model.User{ID: "123"})
|
||||
})
|
||||
|
||||
Describe("NewRepository", func() {
|
||||
var repo rest.Persistable
|
||||
|
||||
BeforeEach(func() {
|
||||
mockPlsRepo.Data = map[string]*model.Playlist{
|
||||
"pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"},
|
||||
}
|
||||
ps = playlists.NewPlaylists(ds)
|
||||
})
|
||||
|
||||
Describe("Save", func() {
|
||||
It("sets the owner from the context user", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "New Playlist"}
|
||||
id, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(id).ToNot(BeEmpty())
|
||||
Expect(pls.OwnerID).To(Equal("user-1"))
|
||||
})
|
||||
|
||||
It("forces a new creation by clearing ID", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{ID: "should-be-cleared", Name: "New"}
|
||||
_, err := repo.Save(pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.ID).ToNot(Equal("should-be-cleared"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("allows owner to update their playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("allows admin to update any playlist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("denies non-owner, non-admin", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("denies regular user from changing ownership", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"}
|
||||
err := repo.Update("pls-1", pls)
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
|
||||
It("returns rest.ErrNotFound when playlist doesn't exist", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
pls := &model.Playlist{Name: "Updated"}
|
||||
err := repo.Update("nonexistent", pls)
|
||||
Expect(err).To(Equal(rest.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("delegates to service Delete with permission checks", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1"))
|
||||
})
|
||||
|
||||
It("denies non-owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false})
|
||||
repo = ps.NewRepository(ctx).(rest.Persistable)
|
||||
err := repo.Delete("pls-1")
|
||||
Expect(err).To(Equal(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
)
|
||||
|
||||
@@ -16,7 +17,7 @@ var Set = wire.NewSet(
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
NewPlaylists,
|
||||
playlists.NewPlaylists,
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
|
||||
Reference in New Issue
Block a user