From 6e4a180febb21c998367bcadf8cb51441284cac8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 5 Jul 2026 16:20:39 -0400 Subject: [PATCH] feat(jellyfin): item image endpoint via artwork service --- server/jellyfin/api.go | 5 ++++ server/jellyfin/images.go | 42 +++++++++++++++++++++++++++++ server/jellyfin/images_test.go | 48 ++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 server/jellyfin/images.go create mode 100644 server/jellyfin/images_test.go diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 8ec57bb1f..baa0a25d0 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -50,6 +50,11 @@ func (api *Router) routes() http.Handler { r.Post("/Users/AuthenticateByName", api.authenticateByName) r.Get("/Users/Public", api.getPublicUsers) + // Images are intentionally public and not library-scoped: artwork isn't sensitive media + // content, and clients (e.g. Finamp) load it via tags with only ?api_key= in the URL. + r.Get("/Items/{itemId}/Images/{type}", api.getItemImage) + r.Get("/Items/{itemId}/Images/{type}/{index}", api.getItemImage) + r.Group(func(r chi.Router) { r.Use(api.authenticate) r.Get("/UserViews", api.getUserViews) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go new file mode 100644 index 000000000..fca002c07 --- /dev/null +++ b/server/jellyfin/images.go @@ -0,0 +1,42 @@ +package jellyfin + +import ( + "context" + "io" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/model" +) + +func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + itemId := chi.URLParam(r, "itemId") + size, _ := strconv.Atoi(r.URL.Query().Get("maxWidth")) + + artID := api.resolveArtworkID(ctx, itemId) + reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer reader.Close() + w.Header().Set("Content-Type", "image/jpeg") + _, _ = io.Copy(w, reader) +} + +// resolveArtworkID maps a bare Jellyfin item id to a Navidrome ArtworkID string +// by probing album -> artist -> media file (music items only). +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() + } + if ar, err := api.ds.Artist(ctx).Get(itemId); err == nil { + return ar.CoverArtID().String() + } + if mf, err := api.ds.MediaFile(ctx).Get(itemId); err == nil { + return mf.CoverArtID().String() + } + return (model.ArtworkID{}).String() +} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go new file mode 100644 index 000000000..3ec8dc8cc --- /dev/null +++ b/server/jellyfin/images_test.go @@ -0,0 +1,48 @@ +package jellyfin + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeArtwork struct { + artwork.Artwork + recvId string +} + +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + f.recvId = id + return io.NopCloser(strings.NewReader("IMG")), time.Now(), nil +} + +var _ = Describe("Images", func() { + It("streams album artwork", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/a1/Images/Primary", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("itemId", "a1") + rctx.URLParams.Add("type", "Primary") + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("IMG")) + Expect(fa.recvId).To(ContainSubstring("a1")) + }) +})