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