From de4e15c4f1355844fd3879316dc954cea7fce2ba Mon Sep 17 00:00:00 2001 From: zapisanchez Date: Tue, 6 Oct 2026 15:28:39 +0200 Subject: [PATCH] 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())