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