mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 20:07:11 +02:00
fix(jellyfin): list the user's libraries at the /Items user root
An unfiltered, non-recursive /Items query with no ParentId is answered by Jellyfin with the user's libraries (the user root folder's children), not their contents. Navidrome defaulted to albums there, so Symfonium found no music library and synced nothing. Fixes #6199 Signed-off-by: zapisanchez <zapisanchez@gmail.com>
This commit is contained in:
parent
4cdffd5633
commit
03308a18bd
4 changed files with 84 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,13 @@ 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
|
||||
}
|
||||
return materialized(result(views, len(views), 0)), nil
|
||||
}
|
||||
if repo, ok := api.playlistTracksRepo(ctx, q); ok {
|
||||
return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,56 @@ 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("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