From c534aedd0c3e00579fd90c7adabed4a5fb742935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 5 Sep 2026 22:57:36 -0400 Subject: [PATCH] fix(jellyfin): add the /Items/Latest route, and scope it by ParentId (#6090) Jellify's Discover tab calls GET /Items/Latest and got a 404: we only routed the /Users/{userId}/Items/Latest form, which real Jellyfin marks [Obsolete] and hides from its OpenAPI spec, so SDK-generated clients never see it. Add the current route alongside the legacy one, both served by the same handler. getLatest also ignored ParentId, so browsing a library or an artist returned the newest albums across everything the user can see. It now scopes to the library when ParentId names one, and filters to that artist's albums otherwise, which also makes a stale id return nothing instead of silently widening back to the full library set. A malformed ParentId 404s, matching /Items and the contract decodeFilterParam documents. --- server/jellyfin/api.go | 1 + server/jellyfin/e2e/browsing_test.go | 37 ++++++++++++++++++++++++++++ server/jellyfin/items.go | 13 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d64366a63..6484a3bb4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -118,6 +118,7 @@ func (api *Router) routes() http.Handler { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) r.Get("/items", api.getItems) r.Get("/users/{userId}/items", api.getItems) + r.Get("/items/latest", api.getLatest) r.Get("/users/{userId}/items/latest", api.getLatest) r.Get("/artists", api.getArtists) r.Get("/artists/albumartists", api.getAlbumArtists) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 004090de6..832b69f25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -563,6 +563,43 @@ var _ = Describe("Browsing", func() { }) }) + Describe("GET /Items/Latest", func() { + // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so + // SDK-generated clients (Jellify) only ever call this one. + It("serves the same response as the legacy /Users/{userId} route", func() { + Expect(get("/Items/Latest?Limit=3").Body.String()). + To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) + }) + + It("scopes to ParentId when it names a library", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("scopes to ParentId when it names an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("returns nothing for a library the user cannot access", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) + Expect(items).To(BeEmpty()) + }) + + It("returns nothing for an id that is neither a library nor an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) + Expect(items).To(BeEmpty()) + }) + + It("404s a malformed ParentId, like every other filtered endpoint", func() { + Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) + Describe("GET /Artists and /Genres", func() { It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { names := names(queryResult(get("/Artists/AlbumArtists")).Items) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index eb5cd2a1a..d16c5e57e 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -938,7 +938,18 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() opts.Max = p.IntOr("limit", 20) - opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing + // rather than widening back to every library. + scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) + if parentID != "" && !isLibrary { + opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} + } + opts = filter.ApplyLibraryFilter(opts, scopeIDs) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts)