This commit is contained in:
Adrián Sánchez Zapico 2026-10-07 16:30:26 +02:00 • committed by GitHub
commit dcebbc3592
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 151 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,15 @@ 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
}
sortViews(views, q.sortBy, q.sortOrder)
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)
@ -645,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

@ -48,6 +48,88 @@ 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("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)))
})
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())
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) {