mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge 08be1e17b0 into 52135913d4
This commit is contained in:
commit
dcebbc3592
4 changed files with 151 additions and 5 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue