fix(jellyfin): support playlist cover upload/delete and display via item image endpoints

This commit is contained in:
Deluan 2026-07-05 22:44:45 -04:00
commit b3247591cc
4 changed files with 258 additions and 1 deletions

View file

@ -86,6 +86,11 @@ func (api *Router) routes() http.Handler {
r.Get("/Playlists/{playlistId}/Users", api.getPlaylistUsers)
r.Get("/Playlists/{playlistId}/Users/{userId}", api.getPlaylistUser)
// Cover upload/delete: only playlists are writable through this API (see
// postItemImage); the GET routes above stay public and unauthenticated.
r.Post("/Items/{itemId}/Images/{type}", api.postItemImage)
r.Delete("/Items/{itemId}/Images/{type}", api.deleteItemImage)
r.Get("/Audio/{itemId}/stream", api.streamAudio)
r.Get("/Audio/{itemId}/stream.{container}", api.streamAudio)
r.Get("/Audio/{itemId}/universal", api.streamAudio)

View file

@ -1,11 +1,14 @@
package jellyfin
import (
"bytes"
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/log"
@ -35,7 +38,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) {
}
// resolveArtworkID maps a bare Jellyfin item id to a Navidrome ArtworkID string
// by probing album -> artist -> media file (music items only).
// by probing album -> artist -> media file -> playlist.
func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string {
if al, err := api.ds.Album(ctx).Get(itemId); err == nil {
return al.CoverArtID().String()
@ -46,5 +49,97 @@ func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string {
if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil {
return mf.CoverArtID().String()
}
if pl, err := api.ds.Playlist(ctx).Get(itemId); err == nil {
return pl.CoverArtID().String()
}
return (model.ArtworkID{}).String()
}
// postItemImage handles cover image upload. Only playlists support upload through this API;
// album/artist covers come from tag/sidecar scanning. The body is always drained first —
// even on the not-implemented path — because Jellyfin clients (e.g. Finamp) write it
// synchronously and see a broken pipe if we respond before reading it.
func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if _, err := api.playlists.Get(ctx, id); err != nil {
http.Error(w, "Not Implemented", http.StatusNotImplemented)
return
}
imgBytes, err := decodeImageBody(body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ext := extFromContentType(r.Header.Get("Content-Type"))
if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil {
api.internalError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteItemImage removes a playlist's uploaded cover. Like postItemImage, only playlists
// are supported.
func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := dto.DecodeID(chi.URLParam(r, "itemId"))
if _, err := api.playlists.Get(ctx, id); err != nil {
http.Error(w, "Not Implemented", http.StatusNotImplemented)
return
}
if err := api.playlists.RemoveImage(ctx, id); err != nil {
api.internalError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// decodeImageBody returns the raw image bytes. Jellyfin sends the image base64-encoded in the
// request body; a few clients send raw bytes instead, so bytes already starting with a known
// image magic number are passed through as-is.
func decodeImageBody(body []byte) ([]byte, error) {
if isImageMagic(body) {
return body, nil
}
trimmed := bytes.TrimSpace(body)
return base64.StdEncoding.DecodeString(string(trimmed))
}
func isImageMagic(b []byte) bool {
switch {
case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG
return true
case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG
return true
default:
return false
}
}
// extFromContentType maps the client-supplied Content-Type to a file extension for storage;
// defaults to .jpg (Jellyfin's own default cover format) for anything unrecognized.
func extFromContentType(contentType string) string {
ct, _, _ := strings.Cut(contentType, ";")
switch strings.ToLower(strings.TrimSpace(ct)) {
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
default:
return ".jpg"
}
}

View file

@ -3,6 +3,8 @@ package jellyfin
import (
"bytes"
"context"
"encoding/base64"
"errors"
"io"
"net/http"
"net/http/httptest"
@ -71,4 +73,136 @@ var _ = Describe("Images", func() {
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("image/png"))
})
It("resolves a playlist id to its cover artwork", func() {
ds := &tests.MockDataStore{}
ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix"}})
fa := &fakeArtwork{}
api := &Router{ds: ds, artwork: fa}
w, r := newImageRequest(dto.EncodeID("pl1"))
api.getItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(fa.recvId).To(ContainSubstring("pl1"))
})
})
var _ = Describe("postItemImage", func() {
var api *Router
var fp *fakePlaylists
BeforeEach(func() {
fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}}
api = &Router{playlists: fp}
})
It("uploads a raw JPEG body and returns 204", func() {
body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.setImagePlaylistID).To(Equal("pl1"))
Expect(fp.setImageBytes).To(Equal(body))
Expect(fp.setImageExt).To(Equal(".jpg"))
})
It("base64-decodes the body when it isn't raw image bytes", func() {
raw := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
encoded := base64.StdEncoding.EncodeToString(raw)
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader([]byte(encoded)))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.setImageBytes).To(Equal(raw))
})
It("maps Content-Type to the right extension", func() {
body := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/png")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.setImageExt).To(Equal(".png"))
})
It("returns 501 for a non-playlist item, draining the body first", func() {
fp.getByIDPls = nil
fp.getByIDErr = model.ErrNotFound
bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained"))
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", bodyReader)
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("al1"))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNotImplemented))
Expect(bodyReader.Len()).To(Equal(0))
})
It("returns 500 when the service fails", func() {
fp.setImageErr = errors.New("boom")
body := []byte{0xFF, 0xD8, 0xFF, 0xE0}
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", bytes.NewReader(body))
r.Header.Set("Content-Type", "image/jpeg")
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.postItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})
var _ = Describe("deleteItemImage", func() {
It("removes the playlist image and returns 204", func() {
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.deleteItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNoContent))
Expect(fp.removeImagePlaylistID).To(Equal("pl1"))
})
It("returns 501 for a non-playlist item", func() {
fp := &fakePlaylists{getByIDErr: model.ErrNotFound}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("al1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("al1"))
api.deleteItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusNotImplemented))
})
It("returns 500 when the service fails", func() {
fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")}
api := &Router{playlists: fp}
w := httptest.NewRecorder()
r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID("pl1")+"/Images/Primary", nil)
r = withChiURLParam(r, "itemId", dto.EncodeID("pl1"))
api.deleteItemImage(w, r)
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
})

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
@ -39,6 +40,14 @@ type fakePlaylists struct {
removePlaylistID string
removeIds []string
removeErr error
setImagePlaylistID string
setImageBytes []byte
setImageExt string
setImageErr error
removeImagePlaylistID string
removeImageErr error
}
func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) {
@ -82,6 +91,20 @@ func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, track
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 _ = Describe("Playlists", func() {
var api *Router
var fp *fakePlaylists