fix(jellyfin): sort the user-root /Items libraries by SortBy and SortOrder before paging

This commit is contained in:
zapisanchez 2026-10-06 15:28:39 +02:00
commit de4e15c4f1
2 changed files with 53 additions and 0 deletions

View file

@ -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 {

View file

@ -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())