mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-22 01:07:27 +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.
207 lines
8.2 KiB
Go
207 lines
8.2 KiB
Go
package e2e
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Annotations", func() {
|
|
BeforeEach(func() { setupTestDB() })
|
|
|
|
itemUserData := func(id string) *dto.UserItemDataDto {
|
|
var item dto.BaseItemDto
|
|
parseInto(get("/Items/"+enc(id)), &item)
|
|
return item.UserData
|
|
}
|
|
|
|
Describe("favorites", func() {
|
|
It("marks and unmarks an album as favorite", func() {
|
|
id := albumID("Abbey Road")
|
|
|
|
var marked dto.UserItemDataDto
|
|
parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked)
|
|
Expect(marked.IsFavorite).To(BeTrue())
|
|
Expect(itemUserData(id).IsFavorite).To(BeTrue())
|
|
|
|
var unmarked dto.UserItemDataDto
|
|
parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked)
|
|
Expect(unmarked.IsFavorite).To(BeFalse())
|
|
Expect(itemUserData(id).IsFavorite).To(BeFalse())
|
|
})
|
|
|
|
It("marks a song as favorite", func() {
|
|
id := songID("So What")
|
|
var data dto.UserItemDataDto
|
|
parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data)
|
|
Expect(itemUserData(id).IsFavorite).To(BeTrue())
|
|
})
|
|
|
|
It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() {
|
|
id := songID("Come Together")
|
|
|
|
var marked dto.UserItemDataDto
|
|
parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked)
|
|
Expect(marked.IsFavorite).To(BeTrue())
|
|
Expect(itemUserData(id).IsFavorite).To(BeTrue())
|
|
|
|
var unmarked dto.UserItemDataDto
|
|
parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked)
|
|
Expect(unmarked.IsFavorite).To(BeFalse())
|
|
Expect(itemUserData(id).IsFavorite).To(BeFalse())
|
|
})
|
|
|
|
It("filters items to favorites only", func() {
|
|
post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "")
|
|
q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite"))
|
|
Expect(q.TotalRecordCount).To(Equal(1))
|
|
Expect(q.Items[0].Name).To(Equal("Abbey Road"))
|
|
})
|
|
|
|
It("marks and lists a playlist as favorite", func() {
|
|
id := createPlaylist("Favorite Mix", nil)
|
|
Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK))
|
|
Expect(itemUserData(id).IsFavorite).To(BeTrue())
|
|
|
|
q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite"))
|
|
Expect(q.TotalRecordCount).To(Equal(1))
|
|
Expect(q.Items[0].Name).To(Equal("Favorite Mix"))
|
|
})
|
|
|
|
It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() {
|
|
// Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not
|
|
// Filters=IsFavorite), combined with ArtistIds.
|
|
post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "")
|
|
q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true"))
|
|
Expect(names(q.Items)).To(ConsistOf("Help!"))
|
|
})
|
|
|
|
It("returns 404 when favoriting an unknown item", func() {
|
|
Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("GET /UserItems/{id}/UserData", func() {
|
|
It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() {
|
|
id := songID("So What")
|
|
post("/Users/admin-1/FavoriteItems/"+enc(id), "")
|
|
|
|
var data dto.UserItemDataDto
|
|
parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data)
|
|
Expect(data.IsFavorite).To(BeTrue())
|
|
Expect(data.ItemId).To(Equal(enc(id)))
|
|
})
|
|
|
|
It("returns a valid (unfavorited) UserData for an item with no annotations", func() {
|
|
var data dto.UserItemDataDto
|
|
parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data)
|
|
Expect(data.IsFavorite).To(BeFalse())
|
|
Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue"))))
|
|
})
|
|
|
|
It("returns 404 for an unknown item", func() {
|
|
Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("ratings", func() {
|
|
It("sets and clears an album rating (Jellyfin 0-10 scale)", func() {
|
|
id := albumID("IV")
|
|
|
|
var set dto.UserItemDataDto
|
|
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set)
|
|
Expect(set.Rating).ToNot(BeNil())
|
|
Expect(*set.Rating).To(Equal(float64(10)))
|
|
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
|
|
|
|
// Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set`
|
|
// would leave the stale value.
|
|
var cleared dto.UserItemDataDto
|
|
parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared)
|
|
Expect(cleared.Rating).To(BeNil())
|
|
Expect(itemUserData(id).Rating).To(BeNil())
|
|
})
|
|
|
|
It("sets and reads a playlist rating", func() {
|
|
id := createPlaylist("Rated Mix", nil)
|
|
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
|
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
|
})
|
|
|
|
It("rejects an out-of-range rating and keeps the stored one", func() {
|
|
id := albumID("Help!")
|
|
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
|
|
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest))
|
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
|
})
|
|
|
|
It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() {
|
|
id := songID("So What")
|
|
var liked dto.UserItemDataDto
|
|
parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked)
|
|
Expect(*liked.Rating).To(Equal(float64(10)))
|
|
Expect(*liked.Likes).To(BeTrue())
|
|
|
|
var disliked dto.UserItemDataDto
|
|
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked)
|
|
Expect(*disliked.Rating).To(Equal(float64(2)))
|
|
Expect(*disliked.Likes).To(BeFalse())
|
|
Expect(*itemUserData(id).Likes).To(BeFalse())
|
|
|
|
Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK))
|
|
Expect(itemUserData(id).Rating).To(BeNil())
|
|
Expect(itemUserData(id).Likes).To(BeNil())
|
|
})
|
|
|
|
It("does not clear a rating when the request has no value", func() {
|
|
id := songID("So What")
|
|
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK))
|
|
Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest))
|
|
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest))
|
|
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
|
|
})
|
|
})
|
|
|
|
Describe("POST /UserItems/{id}/UserData", func() {
|
|
It("sets Rating, Likes and IsFavorite on both route spellings", func() {
|
|
id := songID("Come Together")
|
|
var data dto.UserItemDataDto
|
|
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
|
|
Expect(*data.Rating).To(Equal(float64(6)))
|
|
Expect(*data.Likes).To(BeFalse())
|
|
|
|
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
|
|
stored := itemUserData(id)
|
|
Expect(*stored.Rating).To(Equal(float64(10)))
|
|
Expect(*stored.Likes).To(BeTrue())
|
|
Expect(stored.IsFavorite).To(BeTrue())
|
|
})
|
|
|
|
It("leaves omitted fields and play state untouched", func() {
|
|
id := songID("Come Together")
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
|
before := itemUserData(id)
|
|
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
|
|
|
after := itemUserData(id)
|
|
Expect(*after.Rating).To(Equal(float64(8)))
|
|
Expect(after.IsFavorite).To(BeFalse())
|
|
Expect(after.PlayCount).To(Equal(before.PlayCount))
|
|
Expect(after.Played).To(Equal(before.Played))
|
|
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
|
|
})
|
|
|
|
It("rejects invalid bodies without writing", func() {
|
|
id := songID("Come Together")
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
|
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
|
|
stored := itemUserData(id)
|
|
Expect(stored.Rating).To(BeNil())
|
|
Expect(stored.IsFavorite).To(BeFalse())
|
|
})
|
|
})
|
|
})
|