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..0e4c93944 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,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 { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 18789a34b..a18ff4f52 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -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() 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) {