mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-13 21:58:42 +01:00
* feat(jellyfin): advertise Jellyfin server version 12.1.0
Streamyfin, jellyfin-android and jellyfin-androidtv refuse servers older than
10.10, Swiftfin warns below 12.0, and @jellyfin/sdk flags anything below its
minimum as unsupported, so 10.9.11 locked those clients out. 12.1.0 is the
current Jellyfin release (after 10.11 Jellyfin renumbered to 12.0). No client
checked has an upper bound or assumes the major is 10, and the value keeps
three parts because the Kotlin SDK and Swiftfin reject two-part versions.
* feat(jellyfin): acknowledge POST /Sessions/Playing/Ping
Jellyfin clients ping this endpoint to keep a transcode job alive while
paused. Navidrome ties transcodes to the stream request, so there is nothing
to keep alive; answer 204 like Jellyfin instead of a 404. It shares one no-op
handler with Sessions/Capabilities, renamed to acknowledge.
* feat(jellyfin): reorder playlist entries via Items/{entryId}/Move
Adds POST /Playlists/{id}/Items/{entryId}/Move/{newIndex}, which clients use
to reorder playlists. It maps the entry's PlaylistItemId (its position) and
Jellyfin's zero-based newIndex onto the existing core ReorderTrack, which
enforces ownership. As in Jellyfin, an index past the end appends and an
unknown entry is a no-op; out-of-range positions never reach Reorder, which
would otherwise shift unrelated rows.
* feat(jellyfin): honor position when adding items to a playlist
POST /Playlists/{id}/Items takes an optional zero-based position (added to
the Jellyfin spec in 12.0). Match Jellyfin: zero or negative prepends, past
the end appends, otherwise the new items are inserted at that index in the
order they were added.
Adds PlaylistTrackRepository.Insert and core playlists.InsertTracks, which
shift the following entries and insert in one transaction, instead of
appending and moving each new track with its own ReorderTrack call.
* feat(jellyfin): add type-specific InstantMix routes
Jellyfin exposes InstantMix under Songs/, Albums/, Artists/ and Playlists/
as well as Items/, plus the legacy Artists/InstantMix and
MusicGenres/InstantMix forms that take the seed as ?id=. Clients generated
from the Jellyfin SDKs call the type-specific routes, which 404ed. All of them
now share the existing Items/{id}/InstantMix handler; the external provider
already builds mixes from song, album, artist, playlist and genre seeds.
* feat(jellyfin): honor Width, Height and Fill* image size params
The image endpoint only read MaxWidth/MaxHeight, so clients that size covers
with fillWidth/fillHeight (Manet, Finamp) or width/height got the full-size
original on a cold artwork cache: a 578 KB PNG instead of a 13 KB resize.
Jellyfin applies Width/Height, caps them with MaxWidth/MaxHeight, then
shrinks to the smallest size that still covers the Fill box. Navidrome
resizes on one dimension, so the tightest bound wins and a fill box counts
as its larger side.
* feat(jellyfin): answer HEAD on audio, file and image routes
Fintunes sends HEAD to /Audio/{id}/universal to read the content type and
detect direct play (a Content-Length means direct play), and to the audio and
image URLs before a download, aborting the download when it fails. Those routes
were GET-only, so HEAD got a 404. HEAD now reuses the GET handlers. Direct
play goes through Stream.Serve, which already answers HEAD; a transcode
answers with the target content type and no length without starting ffmpeg,
so a probe never costs a transcode.
* fix(jellyfin): clamp playlist move and insert positions before adding one
movePlaylistItem computed min(newIndex+1, SongCount): newIndex=MaxInt wrapped
to a negative position, and Reorder then left the playlist with a gap
(positions 2, 3, 999998), making the moved entry unmovable. Clamp against the
playlist length first. addToPlaylist's min(position, MaxInt32)+1 wrapped on
32-bit builds and req.Int truncated large values there, so a far-past-the-end
position prepended; parse as int64 and clamp before converting.
* fix(playlists): validate reorder positions inside the write transaction
Reorder never checked its positions, so a source outside the playlist or a
destination past its end shifted rows around a missing entry and left a gap
(e.g. ids 1 and 3), which made the moved entry unmovable afterwards. The
Jellyfin Move handler guarded this with a SongCount read before ReorderTrack,
but a concurrent removal between the two reopened it, and the native API
reorder endpoint passed client positions through unchecked.
Reorder now reads the last position in the same transaction, returns
ErrNotFound for a source outside the playlist and clamps the destination.
ReorderTrack uses an immediate transaction so that read and the updates are
atomic against other writers. The Jellyfin handler drops its pre-check and
maps ErrNotFound to Jellyfin's no-op 204; the native API now answers 404 for
an unknown track instead of corrupting the order.
542 lines
22 KiB
Go
542 lines
22 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
|
|
}
|
|
|
|
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))
|
|
})
|
|
})
|
|
|
|
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())
|
|
})
|
|
})
|
|
})
|