fix(jellyfin): list the user's libraries at the /Items user root

An unfiltered, non-recursive /Items query with no ParentId is answered by
Jellyfin with the user's libraries (the user root folder's children), not
their contents. Navidrome defaulted to albums there, so Symfonium found no
music library and synced nothing.

Fixes #6199

Signed-off-by: zapisanchez <zapisanchez@gmail.com>
This commit is contained in:
zapisanchez 2026-09-28 13:31:34 +02:00
commit 03308a18bd
4 changed files with 84 additions and 5 deletions

View file

@ -162,7 +162,9 @@ in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` ha
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
unrecognized keys are skipped, and `Random` always sorts alone);
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
returns direct children only (no tracks — no track is a library's direct child).
returns direct children only (no tracks — no track is a library's direct child). With no `ParentId`,
no `Recursive=true` and none of the filters above, `/Items` lists the user's libraries (the same
views as `UserViews`), as Jellyfin does at the user root.
## Implemented endpoints

View file

@ -302,6 +302,16 @@ type itemsQuery struct {
albumIds []string
years []int
studioIds []string
// userRoot is an unfiltered, non-recursive query with no ParentId: Jellyfin answers it with the
// user's libraries, not their contents.
userRoot bool
}
// hasFilters mirrors Jellyfin's InternalItemsQuery.HasFilters for the params Navidrome understands.
func (q itemsQuery) hasFilters() bool {
return q.rawTypes != "" || len(q.ids) > 0 || q.search != "" || q.artistId != "" ||
len(q.genreIds) > 0 || len(q.albumIds) > 0 || len(q.studioIds) > 0 || len(q.years) > 0 ||
q.filters.favorite != nil || q.filters.played != nil
}
// listParams reads the itemsQuery fields that come straight from query params.
@ -366,6 +376,8 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ
q.artistId = artistId
q.contributingOnly = albumArtistScope == "" && contributingScope != ""
q.userRoot = q.parentId == "" && !p.BoolOr("recursive", false) && !q.hasFilters()
q.types = parseTypes(q.rawTypes)
q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId)
@ -410,6 +422,13 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult
// A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items.
case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"):
return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil
// Symfonium's sync reads the libraries this way; answering with albums makes it sync nothing.
case q.userRoot:
views, err := api.userViews(ctx)
if err != nil {
return itemsResult{}, err
}
return materialized(result(views, len(views), 0)), nil
}
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit)

View file

@ -48,6 +48,56 @@ var _ = Describe("Items", func() {
})
Describe("getItems", func() {
// Symfonium syncs nothing unless the user root lists the libraries, as Jellyfin does.
Describe("at the user root", func() {
BeforeEach(func() {
ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Other"}})
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}})
})
It("lists the user's libraries for an unfiltered, non-recursive query", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?UserId="+testID("u1"), 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.TotalRecordCount).To(Equal(1))
Expect(res.Items).To(HaveLen(1))
Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1)))
Expect(res.Items[0].Type).To(Equal("CollectionFolder"))
Expect(res.Items[0].CollectionType).To(Equal("music"))
})
It("treats an unknown IncludeItemTypes as absent", func() {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=music", 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("CollectionFolder"))
})
DescribeTable("queries the library contents when the request is recursive or filtered",
func(query string) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items?"+query, 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(HaveLen(1))
Expect(res.Items[0].Type).To(Equal("MusicAlbum"))
},
Entry("Recursive=true", "Recursive=true"),
Entry("IncludeItemTypes", "IncludeItemTypes=MusicAlbum"),
Entry("SearchTerm", "SearchTerm=one"),
Entry("Filters", "Filters=IsUnplayed"),
Entry("Years", "Years=2020"),
)
})
It("lists albums when IncludeItemTypes=MusicAlbum", func() {
ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}})
w := httptest.NewRecorder()

View file

@ -1,6 +1,7 @@
package jellyfin
import (
"context"
"net/http"
"strings"
@ -13,14 +14,21 @@ import (
// getUserViews returns one CollectionFolder view per accessible library, so clients browse each
// library as its own top-level view rather than one aggregate.
func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
views, err := api.userViews(r.Context())
if err != nil {
api.internalError(w, r, err)
return
}
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0})
}
func (api *Router) userViews(ctx context.Context) ([]dto.BaseItemDto, error) {
u, _ := request.UserFrom(ctx)
// u.Libraries comes from a projection without counts or stats, and clients hide a library that
// looks empty, so the rows are re-read in full here.
libs, err := api.ds.Library().GetAll(ctx)
if err != nil {
api.internalError(w, r, err)
return
return nil, err
}
views := make([]dto.BaseItemDto, 0, len(libs))
for _, lib := range libs {
@ -28,7 +36,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
views = append(views, dto.LibraryToBaseItem(lib))
}
}
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0})
return views, nil
}
func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) {