fix(jellyfin): honor Recursive=false for library parents (#5788)

* fix(jellyfin): honor Recursive=false for library parents

/Items ignored the Recursive parameter entirely — it appeared only in tests,
never in production code. Finamp's download sync sends exactly one
Recursive=false request per library (ParentId=<library>&IncludeItemTypes=Audio)
to pick up tracks outside any album, supplementing its recursive per-album
fetch. We answered with every song in the library: 208MB and 21s per sync
measured on a real library, against 20KB for the album queries. Finamp then
required every track twice, once via its album and once under the library node.

In Jellyfin 10.10 Recursive never reaches SQL. It selects between an in-memory
walk of a folder's direct Children (false) and a DB ancestor query (true), with
IncludeItemTypes applied as a post-filter over that direct-child list
(ItemsController.cs:308, Folder.cs:949-994). The default is false, and neither
IncludeItemTypes nor SearchTerm forces recursion, so Recursive=false with
IncludeItemTypes=Audio on a music library returns an empty list — which is what
Finamp expects and codes for.

Filter the requested types to those nested directly under a library when the
parent is a library and Recursive is not true; the hierarchy this API exposes is
library -> album -> track, so no track is ever a library's direct child. Album
and playlist parents are untouched, as their tracks are real direct children in
Jellyfin (MusicAlbum.cs:86-89, Playlist.cs:140-167) and Jellify opens playlists
with Recursive=false. A library parent is the only case handled: /Items with no
ParentId still returns every song where Jellyfin returns the root's children,
but no observed client sends that, and honoring it would surprise any client
that simply omits Recursive.

* refactor(jellyfin): narrow the Recursive=false filter to Audio

Replace the libraryChildTypes allowlist with a direct Audio check. The two are
behaviorally identical — parseTypes only ever yields Audio, MusicArtist,
MusicAlbum, MusicGenre or Playlist, and the allowlist held the latter four, so
it excluded exactly Audio and nothing else.

The allowlist claimed those four are a library's direct children. That isn't
true of MusicGenre or Playlist: listGenres is deliberately unscoped because
genres are global tags, and listPlaylists ignores scopeIDs entirely, so neither
is nested under a library at all. They were on the list only to leave their
behavior untouched. The one verified invariant is that no track is a library's
direct child, so state just that — it is also more conservative, as a type added
later keeps its current behavior instead of being filtered by a stale list.

Add a test pinning the omitted-Recursive default: ItemsController binds
`bool? recursive` and reads it as `recursive ?? false`, so omitting the param is
a non-recursive request and filters Audio for a library parent.
This commit is contained in:
Deluan Quintão 2026-07-15 19:02:12 -04:00 committed by GitHub
parent 3d438b08ef
commit adeaa93e7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 103 additions and 0 deletions

View File

@ -118,6 +118,28 @@ var _ = Describe("Browsing", func() {
})
})
// Finamp's download sync asks a library for the tracks outside any album this way; answering
// with every track would stream the whole library.
Describe("Recursive=false", func() {
lib1 := enc("1")
It("returns no songs for a library parent", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false"))
Expect(q.Items).To(BeEmpty())
Expect(q.TotalRecordCount).To(BeZero())
})
It("still lists the library's albums", func() {
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false"))
Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles"))
})
It("still lists an album's tracks", func() {
q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false"))
Expect(names(q.Items)).To(ConsistOf("Come Together", "Something"))
})
})
// Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds
// for the actual artist filter, not ParentId=<artistId>.
Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() {

View File

@ -254,6 +254,12 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu
q.types = parseTypes(q.rawTypes)
q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId)
// Recursive=false asks for direct children only, and no track is a library's direct child.
// Finamp's sync probes a library this way, and every track is a wrong, unbounded answer.
if q.isLibraryParent && !p.BoolOr("recursive", false) {
q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" })
}
// With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks
// (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse
// its albums).

View File

@ -133,6 +133,81 @@ var _ = Describe("Items", func() {
Expect(w.Code).To(Equal(http.StatusInternalServerError))
})
// Recursive=false asks for direct children only. Finamp's sync probes a library this way
// looking for tracks outside any album; answering with every track streams the whole library.
Describe("Recursive=false", func() {
BeforeEach(func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}})
ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", AlbumID: "a1"}})
})
It("returns no songs for a library parent, as tracks are never its direct children", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(BeEmpty())
Expect(res.TotalRecordCount).To(BeZero())
})
It("drops only Audio from a multi-type library query", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
})
It("still lists albums for a library parent, as they are its direct children", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
})
It("still lists an album's tracks, as they are its direct children", func() {
fp.getErr = model.ErrNotFound
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio&Recursive=false", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeID("s1")))
})
It("keeps returning every song when no parent scopes the query", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(HaveLen(1))
})
// Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as
// `recursive ?? false`, so an omitted Recursive is a non-recursive request.
It("treats an omitted Recursive as false, like Jellyfin", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("1")+"&IncludeItemTypes=Audio", nil).
WithContext(ctxUser())
invoke(api.getItems, w, r)
var res dto.QueryResult
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
Expect(res.Items).To(BeEmpty())
})
})
It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() {
ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One", AlbumArtistID: "ar1"}})
w := httptest.NewRecorder()