feat(jellyfin): implement GET /Playlists/{id} for playlist visibility

Finamp's playlist edit screen calls GET /Playlists/{id} to read the
OpenAccess (public visibility) flag; we only had the sub-routes, so it
404'd and the edit screen failed to load. Return the Jellyfin PlaylistDto
shape (OpenAccess from Public, empty Shares, media item ids).
This commit is contained in:
Deluan 2026-07-05 23:22:37 -04:00
commit ead911a9e1
4 changed files with 61 additions and 0 deletions

View file

@ -80,6 +80,7 @@ func (api *Router) routes() http.Handler {
r.Get("/MusicGenres", api.getGenres)
r.Post("/Playlists", api.createPlaylist)
r.Get("/Playlists/{playlistId}", api.getPlaylist)
r.Get("/Playlists/{playlistId}/Items", api.getPlaylistItems)
r.Post("/Playlists/{playlistId}/Items", api.addToPlaylist)
r.Delete("/Playlists/{playlistId}/Items", api.removeFromPlaylist)

View file

@ -83,6 +83,15 @@ type PlaylistUserPermissions struct {
CanEdit bool `json:"CanEdit"`
}
// PlaylistInfo is the response shape for GET /Playlists/{id}. Finamp reads OpenAccess to show a
// playlist's public-visibility toggle; ItemIds are the playlist's media item ids (matching real
// Jellyfin, which returns item ids here — not playlist-entry ids).
type PlaylistInfo struct {
OpenAccess bool `json:"OpenAccess"`
Shares []PlaylistUserPermissions `json:"Shares"`
ItemIds []string `json:"ItemIds"`
}
type QueryResult struct {
Items []BaseItemDto `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`

View file

@ -47,6 +47,25 @@ func trackToBaseItem(t model.PlaylistTrack) dto.BaseItemDto {
return item
}
// getPlaylist returns a playlist's public-visibility flag and item ids. Finamp calls this before
// opening a playlist's edit screen to read OpenAccess. Visibility is enforced by GetWithTracks
// (public or owned by the current user); any error maps to 404 so private playlists can't be probed.
func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
pls, err := api.playlists.GetWithTracks(ctx, id)
if err != nil {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
itemIds := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return dto.EncodeID(t.MediaFileID) })
api.ok(w, r, dto.PlaylistInfo{
OpenAccess: pls.Public,
Shares: []dto.PlaylistUserPermissions{},
ItemIds: itemIds,
})
}
// getPlaylistItems relies on core/playlists.GetWithTracks to enforce playlist visibility
// (public or owned by the current user); any error — not found or not visible — is reported as
// a generic 404 so a playlist id can't be used to probe for the existence of private playlists.

View file

@ -181,6 +181,38 @@ var _ = Describe("Playlists", func() {
})
})
Describe("getPlaylist", func() {
It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() {
fp.getPls = &model.Playlist{
ID: "pl1",
Public: true,
Tracks: model.PlaylistTracks{
{ID: "1", MediaFileID: "s1", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s1"}},
{ID: "2", MediaFileID: "s2", PlaylistID: "pl1", MediaFile: model.MediaFile{ID: "s2"}},
},
}
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Playlists/pl1", nil).WithContext(context.Background())
r = withChiURLParam(r, "playlistId", "pl1")
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("s1"), dto.EncodeID("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", "missing")
api.getPlaylist(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
})
Describe("addToPlaylist", func() {
It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() {
w := httptest.NewRecorder()