From 03308a18bde708e30bd620bceebb41f43f66605e Mon Sep 17 00:00:00 2001 From: zapisanchez Date: Mon, 28 Sep 2026 13:31:34 +0200 Subject: [PATCH 1/3] 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 --- server/jellyfin/README.md | 4 ++- server/jellyfin/items.go | 19 +++++++++++++ server/jellyfin/items_test.go | 50 +++++++++++++++++++++++++++++++++++ server/jellyfin/users.go | 16 ++++++++--- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 9315ac4f9..cf965bc51 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -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 diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 28de4c31e..91aa3a768 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -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) diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 18789a34b..6507df0b0 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -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() diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go index d0a54ee63..160e9f2ac 100644 --- a/server/jellyfin/users.go +++ b/server/jellyfin/users.go @@ -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) { From c9ba02777a7ae55d077b0d9506f2c91f92f5a92f Mon Sep 17 00:00:00 2001 From: zapisanchez Date: Tue, 6 Oct 2026 15:13:19 +0200 Subject: [PATCH 2/3] fix(jellyfin): page the user-root /Items libraries by StartIndex and Limit --- server/jellyfin/items.go | 3 ++- server/jellyfin/items_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 91aa3a768..db0f5a430 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -428,7 +428,8 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult if err != nil { return itemsResult{}, err } - return materialized(result(views, len(views), 0)), nil + offset := max(q.offset, 0) + return materialized(result(paginate(views, offset, q.limit), len(views), offset)), nil } if repo, ok := api.playlistTracksRepo(ctx, q); ok { return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit) diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 6507df0b0..8dff20c71 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -69,6 +69,19 @@ var _ = Describe("Items", func() { Expect(res.Items[0].CollectionType).To(Equal("music")) }) + It("pages the libraries by StartIndex and Limit", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?StartIndex=1&Limit=1", nil). + WithContext(ctxUserWithLibraries(model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Other"}})) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.StartIndex).To(Equal(1)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(2))) + }) + It("treats an unknown IncludeItemTypes as absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=music", nil).WithContext(ctxUser()) From de4e15c4f1355844fd3879316dc954cea7fce2ba Mon Sep 17 00:00:00 2001 From: zapisanchez Date: Tue, 6 Oct 2026 15:28:39 +0200 Subject: [PATCH 3/3] fix(jellyfin): sort the user-root /Items libraries by SortBy and SortOrder before paging --- server/jellyfin/items.go | 34 ++++++++++++++++++++++++++++++++++ server/jellyfin/items_test.go | 19 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index db0f5a430..0e4c93944 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -428,6 +428,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult if err != nil { return itemsResult{}, err } + sortViews(views, q.sortBy, q.sortOrder) offset := max(q.offset, 0) return materialized(result(paginate(views, offset, q.limit), len(views), offset)), nil } @@ -665,6 +666,39 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// sortViews applies SortBy/SortOrder to the user-root libraries, sorted in memory since a user only +// has a handful; without a usable key they keep the repository order. +func sortViews(views []dto.BaseItemDto, sortBy, order string) { + var cmps []func(a, b dto.BaseItemDto) int + for key := range strings.SplitSeq(sortBy, ",") { + switch strings.ToLower(strings.TrimSpace(key)) { + case "sortname", "name": + cmps = append(cmps, func(a, b dto.BaseItemDto) int { + return strings.Compare(strings.ToLower(a.SortName), strings.ToLower(b.SortName)) + }) + case "datecreated": + cmps = append(cmps, func(a, b dto.BaseItemDto) int { return strings.Compare(a.DateCreated, b.DateCreated) }) + } + } + if len(cmps) == 0 { + return + } + // As in applySort, the first SortOrder value applies to every key. + first, _, _ := strings.Cut(order, ",") + desc := strings.EqualFold(first, "Descending") + slices.SortStableFunc(views, func(a, b dto.BaseItemDto) int { + for _, c := range cmps { + if r := c(a, b); r != 0 { + if desc { + return -r + } + return r + } + } + return 0 + }) +} + // interleave merges per-type item lists round-robin: one item from each list in turn, preserving // each list's own order, so no single type dominates the head of a mixed-type result. func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 8dff20c71..a18ff4f52 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -82,6 +82,25 @@ var _ = Describe("Items", func() { Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(2))) }) + DescribeTable("sorts the libraries before paging them", + func(query string, wantID int) { + ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Audiobooks"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?"+query, nil). + WithContext(ctxUserWithLibraries(model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Audiobooks"}})) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.TotalRecordCount).To(Equal(2)) + Expect(res.Items).To(HaveLen(1)) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(wantID))) + }, + Entry("by name ascending", "SortBy=SortName&Limit=1", 2), + Entry("by name descending", "SortBy=SortName&SortOrder=Descending&Limit=1", 1), + Entry("descending, second page", "SortBy=Name&SortOrder=Descending&StartIndex=1&Limit=1", 2), + Entry("unknown key keeps the repository order", "SortBy=Bogus&Limit=1", 1), + ) + It("treats an unknown IncludeItemTypes as absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=music", nil).WithContext(ctxUser())