mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-15 13:57:09 +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.
565 lines
25 KiB
Go
565 lines
25 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Annotations", func() {
|
|
var api *Router
|
|
var ds *tests.MockDataStore
|
|
var broker *fakeEventBroker
|
|
// alice has access to library 1 only.
|
|
ctxUser := func() context.Context {
|
|
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{}
|
|
broker = &fakeEventBroker{}
|
|
api = &Router{ds: ds, broker: broker}
|
|
})
|
|
|
|
songWith := func(ann model.Annotations) *tests.MockMediaFileRepo {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}})
|
|
return mfRepo
|
|
}
|
|
songReq := func(method, target, body string, params ...string) *http.Request {
|
|
r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser())
|
|
return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...)
|
|
}
|
|
|
|
Describe("markFavorite / unmarkFavorite", func() {
|
|
It("stars a song and returns IsFavorite=true", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.IsFavorite).To(BeTrue())
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("stars an album and returns IsFavorite=true", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.IsFavorite).To(BeTrue())
|
|
Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("stars an artist without checking library access (artists span multiple libraries)", func() {
|
|
artistRepo := ds.Artist().(*tests.MockArtistRepo)
|
|
artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}})
|
|
w := httptest.NewRecorder()
|
|
// alice only has access to library 1, but artists aren't gated per-library.
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.IsFavorite).To(BeTrue())
|
|
Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("stars a visible playlist", func() {
|
|
playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo)
|
|
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue())
|
|
})
|
|
|
|
It("unstars a song and returns IsFavorite=false", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.unmarkFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.IsFavorite).To(BeFalse())
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 and does not star an album in a library the user can't access", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 and does not star a song in a library the user can't access", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 when the id doesn't match any entity", func() {
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
|
|
It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() {
|
|
ds.Album().(*tests.MockAlbumRepo).SetError(true)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
|
})
|
|
|
|
It("emits a refreshResource event when starring a song", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(broker.Events).To(HaveLen(1))
|
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
|
})
|
|
|
|
It("emits a refreshResource event when starring an album", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(broker.Events).To(HaveLen(1))
|
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`))
|
|
})
|
|
|
|
It("does not emit an event when the item is not accessible", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.markFavorite, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(broker.Events).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("setRating / removeRating", func() {
|
|
It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.Rating).NotTo(BeNil())
|
|
Expect(*d.Rating).To(Equal(8.0))
|
|
})
|
|
|
|
It("rates an album", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5))
|
|
})
|
|
|
|
It("rates a visible playlist", func() {
|
|
playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo)
|
|
playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4))
|
|
})
|
|
|
|
It("removes a rating", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.removeRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
Expect(d.Rating).To(BeNil())
|
|
})
|
|
|
|
It("returns 404 and does not rate an album in a library the user can't access", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
|
})
|
|
|
|
It("rounds an odd rating to the nearest star instead of truncating", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
})
|
|
|
|
It("stores the minimum star for Rating=1 instead of clearing the rating", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
|
})
|
|
|
|
It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() {
|
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
invoke(api.setRating, w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
})
|
|
|
|
It("emits a refreshResource event when rating a song", func() {
|
|
songWith(model.Annotations{})
|
|
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
|
|
Expect(broker.Events).To(HaveLen(1))
|
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
|
})
|
|
|
|
Context("Jellyfin likes semantics", func() {
|
|
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
|
|
mfRepo := songWith(model.Annotations{})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
d := parseUserData(w)
|
|
Expect(*d.Rating).To(Equal(10.0))
|
|
Expect(*d.Likes).To(BeTrue())
|
|
})
|
|
|
|
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
|
d := parseUserData(w)
|
|
Expect(*d.Rating).To(Equal(2.0))
|
|
Expect(*d.Likes).To(BeFalse())
|
|
})
|
|
|
|
It("prefers likes over the numeric rating extension when both are sent", func() {
|
|
mfRepo := songWith(model.Annotations{})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
|
})
|
|
|
|
It("still clears on an explicit rating=0 from numeric clients", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
|
Expect(parseUserData(w).Rating).To(BeNil())
|
|
})
|
|
|
|
It("keeps the favorite and play state and reports them in the response", func() {
|
|
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
song := mfRepo.Data[testID("s1")]
|
|
Expect(song.Starred).To(BeTrue())
|
|
Expect(song.PlayCount).To(Equal(int64(3)))
|
|
Expect(song.PlayDate).To(Equal(&playDate))
|
|
d := parseUserData(w)
|
|
Expect(d.IsFavorite).To(BeTrue())
|
|
Expect(d.PlayCount).To(Equal(3))
|
|
Expect(d.Played).To(BeTrue())
|
|
})
|
|
|
|
DescribeTable("rejects a missing or malformed value without touching the stored rating",
|
|
func(query string) {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
|
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
Expect(broker.Events).To(BeEmpty())
|
|
},
|
|
Entry("no params (Jellyfin would clear)", ""),
|
|
Entry("unrelated params only", "?userId=x&foo=1"),
|
|
Entry("empty likes", "?likes="),
|
|
Entry("non-boolean likes", "?likes=maybe"),
|
|
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
|
|
Entry("empty rating", "?rating="),
|
|
Entry("non-numeric rating", "?rating=abc"),
|
|
Entry("NaN rating", "?rating=NaN"),
|
|
Entry("infinite rating", "?rating=Inf"),
|
|
Entry("rating above 10", "?Rating=100"),
|
|
Entry("negative rating", "?Rating=-5"),
|
|
)
|
|
})
|
|
|
|
Context("authorization", func() {
|
|
DescribeTable("refuses a userId naming another user and leaves the rating",
|
|
func(method, target string, params ...string) {
|
|
handler := api.setRating
|
|
if method == "DELETE" {
|
|
handler = api.removeRating
|
|
}
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := httptest.NewRecorder()
|
|
invoke(handler, w, songReq(method, target, "", params...))
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
Expect(broker.Events).To(BeEmpty())
|
|
},
|
|
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
|
|
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
|
|
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
|
|
)
|
|
|
|
It("accepts the caller's own userId", func() {
|
|
mfRepo := songWith(model.Annotations{})
|
|
w := httptest.NewRecorder()
|
|
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("updateUserItemData", func() {
|
|
post := func(body string, params ...string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
|
|
return w
|
|
}
|
|
|
|
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
|
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
|
|
w := post(`{"Rating":8}`)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
d := parseUserData(w)
|
|
Expect(*d.Rating).To(Equal(8.0))
|
|
Expect(*d.Likes).To(BeTrue())
|
|
Expect(d.IsFavorite).To(BeTrue())
|
|
Expect(d.PlayCount).To(Equal(2))
|
|
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
|
|
Expect(broker.Events).To(HaveLen(1))
|
|
})
|
|
|
|
DescribeTable("stores the personal rating",
|
|
func(body string, before, expected int) {
|
|
mfRepo := songWith(model.Annotations{Rating: before})
|
|
Expect(post(body).Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
|
|
},
|
|
Entry("Likes true", `{"Likes":true}`, 0, 5),
|
|
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
|
|
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
|
|
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
|
|
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
|
|
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
|
|
)
|
|
|
|
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
|
|
func(body string, wantEvents int) {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := post(body)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
Expect(*parseUserData(w).Rating).To(Equal(8.0))
|
|
Expect(broker.Events).To(HaveLen(wantEvents))
|
|
},
|
|
Entry("empty object writes nothing", `{}`, 0),
|
|
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
|
|
Entry("favorite only", `{"IsFavorite":true}`, 1),
|
|
)
|
|
|
|
It("sets and unsets the favorite without touching the rating", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
|
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
})
|
|
|
|
It("ignores play-state fields, which only change through playback reporting", func() {
|
|
mfRepo := songWith(model.Annotations{PlayCount: 2})
|
|
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
song := mfRepo.Data[testID("s1")]
|
|
Expect(song.PlayCount).To(Equal(int64(2)))
|
|
Expect(song.PlayDate).To(BeNil())
|
|
Expect(broker.Events).To(BeEmpty())
|
|
})
|
|
|
|
It("leaves state unchanged when a client echoes back the UserData it read", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
|
|
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
|
|
song := mfRepo.Data[testID("s1")]
|
|
Expect(song.Rating).To(Equal(3))
|
|
Expect(song.Starred).To(BeTrue())
|
|
Expect(song.PlayCount).To(Equal(int64(1)))
|
|
})
|
|
|
|
DescribeTable("rejects an invalid body without writing anything",
|
|
func(body string) {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
|
|
song := mfRepo.Data[testID("s1")]
|
|
Expect(song.Rating).To(Equal(4))
|
|
Expect(song.Starred).To(BeFalse())
|
|
Expect(broker.Events).To(BeEmpty())
|
|
},
|
|
Entry("empty body", ""),
|
|
Entry("malformed JSON", `{"Rating":`),
|
|
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
|
|
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
|
|
Entry("Rating as a string", `{"Rating":"8"}`),
|
|
Entry("Likes as a string", `{"Likes":"yes"}`),
|
|
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
|
|
Entry("JSON null", `null`),
|
|
Entry("JSON array", `[]`),
|
|
Entry("trailing garbage after the object", `{"Rating":8} x`),
|
|
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
|
|
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
|
|
)
|
|
|
|
It("accepts trailing whitespace after the object", func() {
|
|
mfRepo := songWith(model.Annotations{})
|
|
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
})
|
|
|
|
It("rejects a body larger than the limit", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
|
|
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
})
|
|
|
|
Context("authorization", func() {
|
|
It("refuses a userId naming another user without writing", func() {
|
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
|
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
|
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
|
})
|
|
|
|
It("accepts the caller's own userId", func() {
|
|
songWith(model.Annotations{})
|
|
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
|
|
})
|
|
|
|
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
|
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
|
|
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
|
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
|
})
|
|
|
|
It("returns 404 for an unknown item", func() {
|
|
w := post(`{"Rating":10}`)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
|
|
var d dto.UserItemDataDto
|
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
|
return d
|
|
}
|
|
|
|
type fakeEventBroker struct {
|
|
http.Handler
|
|
Events []events.Event
|
|
}
|
|
|
|
func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) {
|
|
f.Events = append(f.Events, event)
|
|
}
|
|
|
|
func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) {
|
|
f.Events = append(f.Events, event)
|
|
}
|
|
|
|
var _ events.Broker = (*fakeEventBroker)(nil)
|