navidrome/server/jellyfin/e2e/annotations_test.go
Deluan Quintão 30663f7d4f
fix(jellyfin): honor likes on /Rating and add POST UserData endpoints
* 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.
2026-10-12 11:49:07 -07:00

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())
})
})
})