feat(jellyfin): implement POST /Playlists/{id} to update name, visibility, tracks

Finamp edits a playlist (make public, rename, reorder) via POST
/Playlists/{id}, which we didn't route, so every edit 404'd. Implement it:
Ids present -> replace track list (Create with existing id, preserving
name); otherwise update Name/IsPublic via core/playlists.Update. Adds a
shared playlistError helper (403/404/500) reused by deleteItem, plus e2e
coverage.
This commit is contained in:
Deluan 2026-07-06 07:55:18 -04:00
commit 13fc4bc9cf
4 changed files with 95 additions and 11 deletions

View file

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

View file

@ -122,6 +122,41 @@ var _ = Describe("Playlists", func() {
})
})
Describe("update", func() {
It("makes a playlist public", func() {
plID := createPlaylist("Make Public", nil)
Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent))
var info dto.PlaylistInfo
parseInto(get("/Playlists/"+enc(plID)), &info)
Expect(info.OpenAccess).To(BeTrue())
// Now visible to other users.
Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
})
It("renames a playlist", func() {
plID := createPlaylist("Old Name", nil)
Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent))
pls, _ := ds.Playlist(ctx).Get(plID)
Expect(pls.Name).To(Equal("New Name"))
})
It("replaces the track list when Ids are provided", func() {
plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))})
// Replace with a single different track.
Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent))
q := playlistItems(plID)
Expect(q.TotalRecordCount).To(Equal(1))
Expect(q.Items[0].Name).To(Equal("So What"))
})
It("forbids a non-owner from updating a public playlist", func() {
plID := createPlaylist("Owned", nil)
post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user
Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden))
})
})
Describe("delete", func() {
It("deletes a playlist", func() {
plID := createPlaylist("ToDelete", nil)

View file

@ -2,7 +2,6 @@ package jellyfin
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@ -319,17 +318,11 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
err := api.playlists.Delete(ctx, id)
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, model.ErrNotAuthorized):
http.Error(w, "Forbidden", http.StatusForbidden)
case errors.Is(err, model.ErrNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
default:
api.internalError(w, r, err)
if err := api.playlists.Delete(ctx, id); err != nil {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {

View file

@ -3,6 +3,7 @@ package jellyfin
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
@ -15,6 +16,20 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
// playlistError maps core/playlists write errors to an HTTP status: ownership -> 403, missing or
// invisible -> 404 (never revealing another user's private playlist), anything else -> 500.
// Shared by the playlist mutation handlers.
func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, model.ErrNotAuthorized):
http.Error(w, "Forbidden", http.StatusForbidden)
case errors.Is(err, model.ErrNotFound):
http.Error(w, "Not Found", http.StatusNotFound)
default:
api.internalError(w, r, err)
}
}
type createPlaylistRequest struct {
Name string `json:"Name"`
Ids []string `json:"Ids"`
@ -38,6 +53,46 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) {
api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)})
}
// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body reused for updates. Name/IsPublic are
// pointers so an absent field means "leave unchanged"; Finamp sends name/visibility edits and
// track-order edits as two separate requests, never combined.
type updatePlaylistRequest struct {
Name *string `json:"Name"`
Ids []string `json:"Ids"`
IsPublic *bool `json:"IsPublic"`
}
// updatePlaylist handles POST /Playlists/{id}. When Ids are provided it replaces the playlist's
// track list (Jellyfin's semantics, used by Finamp for reordering); otherwise it updates the
// name and/or public visibility. Ownership is enforced by core/playlists.
func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "playlistId"))
var body updatePlaylistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
// Create with an existing id replaces the tracks and keeps the name; expandContainerIDs lets a
// client send containers here too, consistent with create/add.
if len(body.Ids) > 0 {
ids := api.expandContainerIDs(ctx, slice.Map(body.Ids, dto.DecodeID))
if _, err := api.playlists.Create(ctx, id, "", ids); err != nil {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil {
api.playlistError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId — the
// entry's position within the playlist (model.PlaylistTrack.ID) — rather than only the underlying
// song id. Jellyfin clients read this list, then echo PlaylistItemId back via