package jellyfin import ( "bytes" "context" "encoding/base64" "errors" "image" "image/gif" "image/jpeg" "image/png" "io" "net/http" "net/http/httptest" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) type fakeArtwork struct { artwork.Artwork recvId string recvSize int recvCtx context.Context data []byte hash string } func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id f.recvSize = size f.recvCtx = ctx data := f.data if data == nil { data = []byte("IMG") } return &artwork.Image{ ReadCloser: io.NopCloser(bytes.NewReader(data)), Hash: f.hash, LastUpdated: time.Now(), }, nil } func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("itemId", itemId) rctx.URLParams.Add("type", "Primary") r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) return w, r } var _ = Describe("Images", func() { // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must // still get a resized image rather than the full-size original. DescribeTable("derives the requested size from the Jellyfin size params", func(query string, wantSize int) { ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) r.URL.RawQuery = query api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvSize).To(Equal(wantSize)) }, Entry("MaxWidth only", "maxwidth=300", 300), Entry("MaxHeight only", "maxheight=300", 300), Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), Entry("neither", "", 0), Entry("Width only", "width=300", 300), Entry("Height only", "height=300", 300), Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), Entry("FillWidth only", "fillwidth=300", 300), Entry("FillHeight only", "fillheight=300", 300), Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), ) It("streams album artwork", func() { ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Body.String()).To(Equal("IMG")) Expect(fa.recvId).To(ContainSubstring(testID("a1"))) }) // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. It("404s a malformed itemId instead of serving a placeholder", func() { ds := &tests.MockDataStore{} fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest("not-a-valid-id") api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") }) // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. It("asks for no artwork once the item is deleted, rather than its lingering state", func() { ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) api.getItemImage(w, r) // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") }) It("sniffs the Content-Type instead of hardcoding it", func() { ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) fa := &fakeArtwork{data: png} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) }) It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("pl1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) }) // This endpoint is public (no user in the request), so artwork must be resolved under an // elevated context; otherwise a private playlist's cover fails its visibility filter and // silently falls back to the placeholder. It("resolves artwork under an elevated admin context", func() { ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) u, ok := request.UserFrom(fa.recvCtx) Expect(ok).To(BeTrue()) Expect(u.IsAdmin).To(BeTrue()) }) It("serves immutable when the tag param asserts the current hash", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) q := r.URL.Query() q.Set("tag", hash) r.URL.RawQuery = q.Encode() api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) }) It("revalidates via no-cache when no tag is provided", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) }) }) // Real image fixtures: postItemImage validates uploads by decoding them. func pngBytes() []byte { var b bytes.Buffer Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) return b.Bytes() } func jpegBytes() []byte { var b bytes.Buffer Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) return b.Bytes() } func gifBytes() []byte { var b bytes.Buffer Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) return b.Bytes() } // 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). func webpBytes() []byte { b, err := base64.StdEncoding.DecodeString( "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") Expect(err).ToNot(HaveOccurred()) return b } var _ = Describe("postItemImage", func() { var api *Router var fp *fakePlaylists BeforeEach(func() { fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api = &Router{playlists: fp} }) It("uploads a raw JPEG body and returns 204", func() { body := jpegBytes() w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".jpeg")) }) It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { raw := pngBytes() encoded := base64.StdEncoding.EncodeToString(raw) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded))) r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.setImageBytes).To(Equal(raw)) 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(testID("al1"))+"/Images/Primary", bodyReader) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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") w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) It("accepts a raw WebP body", func() { body := webpBytes() w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/webp") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".webp")) }) It("accepts a raw GIF body", func() { body := gifBytes() w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/gif") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".gif")) }) It("rejects an oversized body with 400, like the native endpoint", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.MaxImageUploadSize = "16" // 16 bytes w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") }) It("applies the size limit to the decoded image, not the base64 body", func() { DeferCleanup(configtest.SetupConfig()) img := pngBytes() // The raw image is exactly at the limit; its base64 form is 4/3 bigger. conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) body := []byte(base64.StdEncoding.EncodeToString(img)) Expect(len(body)).To(BeNumerically(">", len(img))) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.setImageBytes).To(Equal(img)) }) It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { DeferCleanup(configtest.SetupConfig()) img := pngBytes() conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) body := []byte(base64.StdEncoding.EncodeToString(img)) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(fp.setImagePlaylistID).To(BeEmpty()) }) It("rejects a body that is neither an image nor base64 with 400", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(fp.setImagePlaylistID).To(BeEmpty()) }) It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusBadRequest)) Expect(fp.setImagePlaylistID).To(BeEmpty()) }) It("forbids a non-admin upload when artwork upload is disabled", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusForbidden)) Expect(fp.setImagePlaylistID).To(BeEmpty()) }) It("still allows an admin upload when artwork upload is disabled", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableArtworkUpload = false w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) }) }) var _ = Describe("deleteItemImage", func() { It("removes the playlist image and returns 204", func() { fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} api := &Router{playlists: fp} w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.removeImagePlaylistID).To(Equal(testID("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(testID("al1"))+"/Images/Primary", nil) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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: testID("pl1")}, removeImageErr: errors.New("boom")} api := &Router{playlists: fp} w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) })