fix(jellyfin): support playlist deletion via DELETE /Items/{id}

Finamp deletes a playlist with DELETE /Items/{id}, which we didn't route,
so deletion silently failed. Implement it via core/playlists.Delete (which
enforces ownership and removes the cover file). Only playlists are
deletable through this API; non-playlist ids return 404, non-owners 403.
This commit is contained in:
Deluan 2026-07-06 00:03:35 -04:00
commit 03d5ff12e5
3 changed files with 64 additions and 0 deletions

View file

@ -67,6 +67,7 @@ func (api *Router) routes() http.Handler {
r.Get("/Users/{userId}/Items", api.getItems)
r.Get("/Items/{itemId}", api.getItem)
r.Get("/Users/{userId}/Items/{itemId}", api.getItem)
r.Delete("/Items/{itemId}", api.deleteItem)
r.Get("/Users/{userId}/Items/Latest", api.getLatest)
r.Post("/Users/{userId}/FavoriteItems/{itemId}", api.markFavorite)

View file

@ -2,6 +2,7 @@ package jellyfin
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
@ -302,6 +303,25 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Not Found", http.StatusNotFound)
}
// deleteItem handles DELETE /Items/{id}. Only playlists are deletable through this API — albums and
// songs come from library scanning, not the client — so any non-playlist id resolves to 404.
// core/playlists.Delete enforces ownership (checkWritable) and removes the cover file.
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)
}
}
func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
opts := filter.AlbumsByNewest()

View file

@ -50,6 +50,14 @@ type fakePlaylists struct {
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) {
@ -269,6 +277,41 @@ var _ = Describe("Playlists", func() {
})
})
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("pl1"))
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.deletePlaylistID).To(Equal("pl1"))
})
It("returns 403 when the user doesn't own the playlist", func() {
fp.deleteErr = model.ErrNotAuthorized
w := httptest.NewRecorder()
api.deleteItem(w, deleteReq("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("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("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()