mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
POST /Playlists dropped the client's IsPublic flag, so every playlist was created private. JellyBox Player's create-playlist form defaults its "public" checkbox to true, so JellyBox users could never create a public playlist. Upstream's PlaylistsController passes IsPublic into PlaylistCreationRequest. core/playlists.Create has no visibility parameter and widening it would ripple into the Subsonic and native APIs, so createPlaylist follows the same pattern updatePlaylist already uses: after Create succeeds, a non-nil IsPublic is applied with a follow-up Update. The field is a pointer so an absent one keeps today's default instead of forcing private. If that second write fails the handler surfaces the error through playlistError rather than returning the id: answering 200 for a playlist that is not as visible as the client asked is the same silent drop this fixes.
578 lines
23 KiB
Go
578 lines
23 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/filter"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so
|
|
// unimplemented methods aren't needed here; only the ones this test exercises are overridden.
|
|
type fakePlaylists struct {
|
|
playlists.Playlists
|
|
|
|
createdName string
|
|
createdIds []string
|
|
createErr error
|
|
|
|
getPls *model.Playlist
|
|
getErr error
|
|
tracksRepo *tests.MockPlaylistTrackRepo
|
|
|
|
getByIDPls *model.Playlist
|
|
getByIDErr error
|
|
|
|
addPlaylistID string
|
|
addIds []string
|
|
addErr error
|
|
|
|
removePlaylistID string
|
|
removeIds []string
|
|
removeErr error
|
|
|
|
setImagePlaylistID string
|
|
setImageBytes []byte
|
|
setImageExt string
|
|
setImageErr error
|
|
|
|
removeImagePlaylistID string
|
|
removeImageErr error
|
|
|
|
deletePlaylistID string
|
|
deleteErr error
|
|
|
|
updatePlaylistID string
|
|
updatePublic *bool
|
|
updateErr error
|
|
}
|
|
|
|
func (f *fakePlaylists) Update(_ context.Context, playlistID string, _ *string, _ *string, public *bool, _ []string, _ []int) error {
|
|
f.updatePlaylistID = playlistID
|
|
f.updatePublic = public
|
|
return f.updateErr
|
|
}
|
|
|
|
func (f *fakePlaylists) Delete(_ context.Context, id string) error {
|
|
f.deletePlaylistID = id
|
|
return f.deleteErr
|
|
}
|
|
|
|
func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) {
|
|
f.createdName = name
|
|
f.createdIds = ids
|
|
if f.createErr != nil {
|
|
return "", f.createErr
|
|
}
|
|
return testID("pl-new"), nil
|
|
}
|
|
|
|
// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real
|
|
// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't
|
|
// care about playlists leave it unconfigured.
|
|
func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) {
|
|
if f.getByIDErr != nil {
|
|
return nil, f.getByIDErr
|
|
}
|
|
if f.getByIDPls == nil {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
return f.getByIDPls, nil
|
|
}
|
|
|
|
func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) {
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
if f.getPls == nil {
|
|
return nil, model.ErrNotFound // mirror the real repo: never (nil, nil)
|
|
}
|
|
return f.getPls, nil
|
|
}
|
|
|
|
// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert
|
|
// what was pushed down to the query.
|
|
func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) {
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
if f.getPls == nil {
|
|
return nil, model.ErrNotFound
|
|
}
|
|
f.tracksRepo = &tests.MockPlaylistTrackRepo{}
|
|
f.tracksRepo.SetData(f.getPls.Tracks)
|
|
return f.tracksRepo, nil
|
|
}
|
|
|
|
func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) {
|
|
f.addPlaylistID = playlistID
|
|
f.addIds = ids
|
|
return len(ids), f.addErr
|
|
}
|
|
|
|
func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error {
|
|
f.removePlaylistID = playlistID
|
|
f.removeIds = trackIds
|
|
return f.removeErr
|
|
}
|
|
|
|
func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error {
|
|
f.setImagePlaylistID = playlistID
|
|
f.setImageExt = ext
|
|
if reader != nil {
|
|
f.setImageBytes, _ = io.ReadAll(reader)
|
|
}
|
|
return f.setImageErr
|
|
}
|
|
|
|
func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error {
|
|
f.removeImagePlaylistID = playlistID
|
|
return f.removeImageErr
|
|
}
|
|
|
|
var _ = DescribeTable("insertPosition",
|
|
func(position int64, want int) {
|
|
Expect(insertPosition(position)).To(Equal(want))
|
|
},
|
|
Entry("zero-based index becomes a 1-based position", int64(2), 3),
|
|
Entry("negative prepends", int64(-5), 1),
|
|
Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32),
|
|
Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32),
|
|
Entry("smallest int64 prepends", int64(math.MinInt64), 1),
|
|
)
|
|
|
|
var _ = Describe("Playlists", func() {
|
|
var api *Router
|
|
var fp *fakePlaylists
|
|
|
|
BeforeEach(func() {
|
|
fp = &fakePlaylists{}
|
|
api = &Router{ds: &tests.MockDataStore{}, playlists: fp}
|
|
})
|
|
|
|
Describe("createPlaylist", func() {
|
|
It("creates a playlist and returns its id", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res map[string]string
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new"))))
|
|
Expect(fp.createdName).To(Equal("Mix"))
|
|
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
|
|
})
|
|
|
|
It("returns 400 on an invalid JSON body", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
})
|
|
|
|
It("returns 500 when the service fails", func() {
|
|
fp.createErr = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)).
|
|
WithContext(context.Background())
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
createReq := func(body string) *http.Request {
|
|
return httptest.NewRequest("POST", "/Playlists", strings.NewReader(body)).
|
|
WithContext(GinkgoT().Context())
|
|
}
|
|
|
|
DescribeTable("visibility",
|
|
func(body string, wantPublic *bool, wantUpdatedID string) {
|
|
w := httptest.NewRecorder()
|
|
invoke(api.createPlaylist, w, createReq(body))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(fp.updatePublic).To(Equal(wantPublic))
|
|
Expect(fp.updatePlaylistID).To(Equal(wantUpdatedID))
|
|
},
|
|
Entry("applies IsPublic true", `{"Name":"Mix","IsPublic":true}`, new(true), testID("pl-new")),
|
|
Entry("applies an explicit IsPublic false", `{"Name":"Mix","IsPublic":false}`, new(false), testID("pl-new")),
|
|
Entry("leaves visibility alone when IsPublic is omitted", `{"Name":"Mix"}`, nil, ""),
|
|
)
|
|
|
|
It("returns 500 when the visibility update fails", func() {
|
|
fp.updateErr = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
invoke(api.createPlaylist, w, createReq(`{"Name":"Mix","IsPublic":true}`))
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistItems", func() {
|
|
It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() {
|
|
fp.getPls = &model.Playlist{
|
|
ID: testID("pl1"),
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}},
|
|
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}},
|
|
},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
api.getPlaylistItems(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.TotalRecordCount).To(Equal(2))
|
|
Expect(res.Items).To(HaveLen(2))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1"))))
|
|
Expect(res.Items[0].Type).To(Equal("Audio"))
|
|
Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1")))
|
|
Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2"))))
|
|
Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2")))
|
|
})
|
|
|
|
It("pages with StartIndex/Limit, pushing them down to the query", func() {
|
|
fp.getPls = &model.Playlist{
|
|
ID: testID("pl1"),
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
|
|
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
|
|
{ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}},
|
|
},
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil).
|
|
WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.getPlaylistItems, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.QueryResult
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.TotalRecordCount).To(Equal(3))
|
|
Expect(res.Items).To(HaveLen(1))
|
|
Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2"))))
|
|
Expect(fp.tracksRepo.Options.Offset).To(Equal(1))
|
|
Expect(fp.tracksRepo.Options.Max).To(Equal(1))
|
|
})
|
|
|
|
It("returns 404 for a non-owned or absent playlist", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing")))
|
|
api.getPlaylistItems(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("container id expansion", func() {
|
|
var ds *tests.MockDataStore
|
|
var ctx context.Context
|
|
|
|
BeforeEach(func() {
|
|
ctx = context.Background()
|
|
ds = &tests.MockDataStore{}
|
|
api = &Router{ds: ds, playlists: fp}
|
|
})
|
|
|
|
createWith := func(id string) {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)).
|
|
WithContext(ctx)
|
|
invoke(api.createPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
}
|
|
|
|
It("passes a bare song id through unchanged", func() {
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}})
|
|
createWith(testID("s1"))
|
|
Expect(fp.createdIds).To(Equal([]string{testID("s1")}))
|
|
})
|
|
|
|
It("expands an album id into its songs, filtered by album", func() {
|
|
ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}})
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
|
{ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")},
|
|
})
|
|
createWith(testID("al1"))
|
|
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
|
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters))
|
|
})
|
|
|
|
It("expands an artist id into its songs", func() {
|
|
ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}})
|
|
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}})
|
|
createWith(testID("ar1"))
|
|
Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")}))
|
|
Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters))
|
|
})
|
|
|
|
It("expands a playlist id into its tracks' media file ids", func() {
|
|
fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")},
|
|
}}
|
|
createWith(testID("pl9"))
|
|
Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")}))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylist", func() {
|
|
It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() {
|
|
pls := &model.Playlist{
|
|
ID: testID("pl1"),
|
|
Public: true,
|
|
Tracks: model.PlaylistTracks{
|
|
{ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}},
|
|
{ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}},
|
|
},
|
|
}
|
|
fp.getPls, fp.getByIDPls = pls, pls
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.getPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.PlaylistInfo
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.OpenAccess).To(BeTrue())
|
|
Expect(res.Shares).To(BeEmpty())
|
|
Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))}))
|
|
})
|
|
|
|
It("returns 404 for a non-owned or absent playlist", func() {
|
|
fp.getErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing")))
|
|
invoke(api.getPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("deleteItem", func() {
|
|
deleteReq := func(id string) *http.Request {
|
|
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background())
|
|
return withChiURLParam(r, "itemId", dto.EncodeID(id))
|
|
}
|
|
|
|
It("deletes the playlist and returns 204", func() {
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq(testID("pl1")))
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.deletePlaylistID).To(Equal(testID("pl1")))
|
|
})
|
|
|
|
It("returns 403 when the user doesn't own the playlist", func() {
|
|
fp.deleteErr = model.ErrNotAuthorized
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq(testID("pl1")))
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
|
|
It("returns 404 for a missing playlist or non-playlist id", func() {
|
|
fp.deleteErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq(testID("al1")))
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 500 on an unexpected error", func() {
|
|
fp.deleteErr = errors.New("boom")
|
|
w := httptest.NewRecorder()
|
|
api.deleteItem(w, deleteReq(testID("pl1")))
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
})
|
|
|
|
Describe("addToPlaylist", func() {
|
|
It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addPlaylistID).To(Equal(testID("pl1")))
|
|
Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")}))
|
|
})
|
|
|
|
It("accepts a PascalCase Ids param (case-folded by the middleware)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")}))
|
|
})
|
|
|
|
It("returns 404 when the service rejects the request (not found/not owned)", func() {
|
|
fp.addErr = model.ErrNotAuthorized
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() {
|
|
fp.addErr = model.ErrPlaylistNotEditable
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
|
|
It("passes no ids (not a spurious empty string) when the ids param is absent", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.addToPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.addPlaylistID).To(Equal(testID("pl1")))
|
|
Expect(fp.addIds).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("removeFromPlaylist", func() {
|
|
It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removePlaylistID).To(Equal(testID("pl1")))
|
|
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
|
|
})
|
|
|
|
It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removeIds).To(Equal([]string{"1", "2"}))
|
|
})
|
|
|
|
It("404s an entryId that is a song id rather than a playlist-entry position", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(fp.removeIds).To(BeEmpty())
|
|
})
|
|
|
|
It("returns 404 when the service rejects the request (not found/not owned)", func() {
|
|
fp.removeErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() {
|
|
fp.removeErr = model.ErrPlaylistNotEditable
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
})
|
|
|
|
It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background())
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
invoke(api.removeFromPlaylist, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNoContent))
|
|
Expect(fp.removePlaylistID).To(Equal(testID("pl1")))
|
|
Expect(fp.removeIds).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistUsers", func() {
|
|
It("returns the current user with CanEdit true for an editable playlist", func() {
|
|
fp.getByIDPls = &model.Playlist{ID: testID("pl1")}
|
|
w := httptest.NewRecorder()
|
|
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx)
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
api.getPlaylistUsers(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res []dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}}))
|
|
})
|
|
|
|
It("reports CanEdit false for a synced playlist", func() {
|
|
fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true}
|
|
w := httptest.NewRecorder()
|
|
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx)
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
api.getPlaylistUsers(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res []dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res[0].CanEdit).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 when the playlist is not visible", func() {
|
|
fp.getByIDErr = model.ErrNotFound
|
|
w := httptest.NewRecorder()
|
|
ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"})
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx)
|
|
r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1")))
|
|
api.getPlaylistUsers(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("getPlaylistUser", func() {
|
|
requestUser := func() *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background())
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1")))
|
|
rctx.URLParams.Add("userId", testID("u1"))
|
|
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
|
api.getPlaylistUser(w, r)
|
|
return w
|
|
}
|
|
|
|
It("returns CanEdit true for an editable playlist", func() {
|
|
fp.getByIDPls = &model.Playlist{ID: testID("pl1")}
|
|
w := requestUser()
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true}))
|
|
})
|
|
|
|
It("reports CanEdit false for a synced playlist", func() {
|
|
fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true}
|
|
w := requestUser()
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var res dto.PlaylistUserPermissions
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
|
Expect(res.CanEdit).To(BeFalse())
|
|
})
|
|
})
|
|
})
|