fix(jellyfin): resolve library-view ids in getItem so clients can load the library

This commit is contained in:
Deluan 2026-07-05 20:55:09 -04:00
commit 4e277b1929
4 changed files with 80 additions and 12 deletions

View file

@ -3,6 +3,7 @@ package jellyfin
import (
"context"
"net/http"
"strconv"
"strings"
"github.com/Masterminds/squirrel"
@ -233,13 +234,30 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, f
return result(slice.Map(playlists, dto.PlaylistToBaseItem), int(total), opts.Offset), nil
}
// getItem fetches a single entity by id, trying album, artist and song in turn. For albums
// and songs (which each belong to exactly one library) it 404s if the current user lacks
// access to that library, so an id can't be used to probe content outside the user's libraries.
// getItem fetches a single entity by id, trying library view, album, artist and song in turn.
// For albums and songs (which each belong to exactly one library) it 404s if the current user
// lacks access to that library, so an id can't be used to probe content outside the user's
// libraries.
func (api *Router) getItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id := chi.URLParam(r, "itemId")
u, _ := request.UserFrom(ctx)
// Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without
// this, the home screen and every library tab 404 trying to probe it as an album/artist/song.
if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) {
for _, lib := range u.Libraries {
if lib.ID == libID {
api.ok(w, r, libraryView(lib))
return
}
}
// Admin bypass: Libraries is empty but access is granted to every library, so fetch the
// real one instead of returning a placeholder.
if lib, err := api.ds.Library(ctx).Get(libID); err == nil {
api.ok(w, r, libraryView(*lib))
return
}
}
if al, err := api.ds.Album(ctx).Get(id); err == nil {
if !u.HasLibraryAccess(al.LibraryID) {
http.Error(w, "Not Found", http.StatusNotFound)

View file

@ -400,6 +400,47 @@ var _ = Describe("Items", func() {
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal("a1"))
})
// Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the
// library node before it can load the home screen or any library tab.
It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() {
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 1, Name: "Music Library"}}
r := httptest.NewRequest("GET", "/Items/1", nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", "1")
api.getItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal("1"))
Expect(item.Name).To(Equal("Music Library"))
Expect(item.Type).To(Equal("CollectionFolder"))
Expect(item.CollectionType).To(Equal("music"))
Expect(item.IsFolder).To(BeTrue())
})
It("does not resolve a library-view id the user has no access to", func() {
w := httptest.NewRecorder()
libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1
r := httptest.NewRequest("GET", "/Items/1", nil).WithContext(ctxUserWithLibraries(libs))
r = withChiURLParam(r, "itemId", "1")
api.getItem(w, r)
Expect(w.Code).To(Equal(http.StatusNotFound))
})
It("resolves a library-view id for an admin even though their Libraries slice is empty", func() {
ds.Library(context.Background()).(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}})
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/Items/1", nil).WithContext(ctxAdmin())
r = withChiURLParam(r, "itemId", "1")
api.getItem(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
var item dto.BaseItemDto
Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed())
Expect(item.Id).To(Equal("1"))
Expect(item.Name).To(Equal("Music Library"))
Expect(item.Type).To(Equal("CollectionFolder"))
})
})
Describe("getLatest", func() {

View file

@ -4,7 +4,9 @@ import (
"context"
"strconv"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
)
// accessibleLibraryIDs returns the ids of the libraries the current user can access. An
@ -30,3 +32,18 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int,
}
return accessibleLibraryIDs(ctx), false
}
// libraryView builds the CollectionFolder BaseItemDto Jellyfin clients use to represent a
// library as a top-level browsing node. It's shared by getUserViews (which lists every
// accessible library) and getItem (which resolves a single one), since Finamp fetches a
// UserView's id as a plain item to load the library.
func libraryView(lib model.Library) dto.BaseItemDto {
return dto.BaseItemDto{
Id: strconv.Itoa(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
BackdropImageTags: []string{},
}
}

View file

@ -2,7 +2,6 @@ package jellyfin
import (
"net/http"
"strconv"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/jellyfin/dto"
@ -15,14 +14,7 @@ func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) {
u, _ := request.UserFrom(r.Context())
views := make([]dto.BaseItemDto, 0, len(u.Libraries))
for _, lib := range u.Libraries {
views = append(views, dto.BaseItemDto{
Id: strconv.Itoa(lib.ID),
Name: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
BackdropImageTags: []string{},
})
views = append(views, libraryView(lib))
}
api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0})
}