mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-21 08:47:16 +02:00
* fix(jellyfin): honor likes on /Rating and add POST UserData endpoints
POST .../Rating only read a numeric `rating` param that upstream Jellyfin
does not define, so a request using Jellyfin's `likes` flag (or no value
at all, or a malformed one) silently cleared the user's rating. The
handler now reads `likes` like Jellyfin (true stores 5 stars, false 1
star, matching Jellyfin's 10 and 1), keeps the numeric `rating` param for
existing clients, and answers 400 without writing when the value is
missing, malformed or outside 0-10. Clearing is left to DELETE. The
current /UserItems/{itemId}/Rating spelling is now routed as well.
Adds POST /UserItems/{itemId}/UserData and the legacy
/Users/{userId}/Items/{itemId}/UserData. They write Rating, Likes and
IsFavorite with Jellyfin's precedence (Rating wins over Likes), leave
omitted or null fields untouched, and ignore play-state fields. The body
must be a single JSON object: trailing data, null, or out-of-range values
are rejected before anything is written.
Rating and UserData writes whose userId names a different user now get
403 instead of silently writing the caller's own data. Annotation
responses return the item's full UserData, including Jellyfin's derived
Likes flag (rating >= 6.5). The README compatibility notes are updated.
* refactor(jellyfin): simplify rating and UserData handlers
Moves the Jellyfin 0-10 to stars conversions (StarsFromRating,
StarsFromLikes) into the dto package next to the reverse mapping and the
Likes threshold, so both directions live together. The userId ownership
check is folded into a single applyCallerUserData helper instead of being
called by hand in three handlers, the setFavorite passthrough is dropped,
and decodeUserDataBody now writes its own 400 like the other (value, ok)
helpers.
Tests: withChiURLParam is now variadic, replacing the duplicate
withChiParams helper; the three other-user 403 specs and the
empty-object/no-event spec become table entries; and redundant specs are
removed. Behavior is unchanged.
96 lines
4.4 KiB
Go
96 lines
4.4 KiB
Go
package e2e
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Multi-user access control", func() {
|
|
BeforeEach(func() { setupTestDB() })
|
|
|
|
Describe("library scoping", func() {
|
|
It("lets a library member browse its content", func() {
|
|
q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
|
|
Expect(q.TotalRecordCount).To(Equal(5))
|
|
})
|
|
|
|
It("hides all content from a user with no library access", func() {
|
|
noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"}
|
|
Expect(ds.User().Put(ctx, &noAccess)).To(Succeed())
|
|
loaded, err := ds.User().FindByUsername(ctx, "noaccess")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true"))
|
|
Expect(q.TotalRecordCount).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Describe("personal ratings", func() {
|
|
userData := func(u model.User, id string) *dto.UserItemDataDto {
|
|
var data dto.UserItemDataDto
|
|
parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data)
|
|
return &data
|
|
}
|
|
|
|
It("keeps each user's rating separate", func() {
|
|
id := songID("So What")
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK))
|
|
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK))
|
|
|
|
Expect(*userData(adminUser, id).Rating).To(Equal(float64(10)))
|
|
Expect(*userData(regularUser, id).Rating).To(Equal(float64(2)))
|
|
})
|
|
|
|
It("refuses writes that name another user, even for an admin, and changes nothing", func() {
|
|
id := songID("So What")
|
|
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK))
|
|
|
|
Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
|
Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden))
|
|
Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden))
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
|
|
|
Expect(userData(adminUser, id).Rating).To(BeNil())
|
|
Expect(*userData(regularUser, id).Rating).To(Equal(float64(4)))
|
|
})
|
|
})
|
|
|
|
Describe("private playlists", func() {
|
|
It("does not expose another user's private playlist", func() {
|
|
adminPl := createPlaylist("Admin Private", nil)
|
|
|
|
// Owner sees it.
|
|
Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
|
|
// A different user does not.
|
|
Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0))
|
|
// And can't read its items.
|
|
Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("does not let a non-owner delete another user's private playlist", func() {
|
|
adminPl := createPlaylist("Admin Private", nil)
|
|
// The playlist is invisible to the regular user, so delete resolves to 404 (not 403) —
|
|
// the API never reveals that someone else's private playlist exists.
|
|
Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound))
|
|
// Still present for the owner.
|
|
Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
|
|
})
|
|
|
|
It("does not let a non-owner annotate another user's private playlist", func() {
|
|
adminPl := createPlaylist("Admin Private", nil)
|
|
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
|
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
|
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("lets each user manage their own playlist", func() {
|
|
regularPl := createPlaylistAs(regularUser, "Regular's Mix")
|
|
Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1))
|
|
Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent))
|
|
})
|
|
})
|
|
})
|