mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-29 14:37:49 +01:00
fix(jellyfin): support playlist cover upload/delete and display via item image endpoints
This commit is contained in:
parent
f80ee0e41b
commit
b3247591cc
4 changed files with 258 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue