mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-01 16:07:47 +01:00
Adds POST/DELETE handlers for /Users/{userId}/FavoriteItems/{itemId} and
/Users/{userId}/Items/{itemId}/Rating. A shared resolveAnnotated helper
probes album/artist/media file (mirroring getItem's order) and 404s before
writing if the user lacks access to the album/song's library; artists are
exempt since they span multiple libraries. Ratings are halved coming in
(Jellyfin 0-10 -> Navidrome 0-5) to match the doubling in dto.UserData.
Also teaches MockMediaFileRepo and MockArtistRepo's SetStar/SetRating (and
MockAlbumRepo's, previously a no-op) to actually mutate the backing data so
write-back can be asserted in tests.
171 lines
7.2 KiB
Go
171 lines
7.2 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"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
|
|
// alice has access to library 1 only.
|
|
ctxUser := func() context.Context {
|
|
return request.WithUser(context.Background(), model.User{ID: "u1", UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
|
}
|
|
|
|
BeforeEach(func() {
|
|
ds = &tests.MockDataStore{}
|
|
api = &Router{ds: ds}
|
|
})
|
|
|
|
Describe("markFavorite / unmarkFavorite", func() {
|
|
It("stars a song and returns IsFavorite=true", func() {
|
|
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", "s1")
|
|
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["s1"].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("stars an album and returns IsFavorite=true", func() {
|
|
albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/a1", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", "a1")
|
|
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["a1"].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("stars an artist without checking library access (artists span multiple libraries)", func() {
|
|
artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo)
|
|
artistRepo.SetData(model.Artists{{ID: "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", "ar1")
|
|
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["ar1"].Starred).To(BeTrue())
|
|
})
|
|
|
|
It("unstars a song and returns IsFavorite=false", func() {
|
|
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: "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", "s1")
|
|
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["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(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/a1", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", "a1")
|
|
api.markFavorite(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(albumRepo.Data["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(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: "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", "s1")
|
|
api.markFavorite(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(mfRepo.Data["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", "missing")
|
|
api.markFavorite(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
})
|
|
})
|
|
|
|
Describe("setRating / removeRating", func() {
|
|
It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() {
|
|
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: "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", "s1")
|
|
api.setRating(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data["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(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/a1/Rating?Rating=10", nil).WithContext(ctxUser())
|
|
r = withChiURLParam(r, "itemId", "a1")
|
|
api.setRating(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(albumRepo.Data["a1"].Rating).To(Equal(5))
|
|
})
|
|
|
|
It("removes a rating", func() {
|
|
mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo)
|
|
mfRepo.SetData(model.MediaFiles{{ID: "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", "s1")
|
|
api.removeRating(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusOK))
|
|
Expect(mfRepo.Data["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(context.Background()).(*tests.MockAlbumRepo)
|
|
albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}})
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest("POST", "/Users/u1/Items/a1/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1
|
|
r = withChiURLParam(r, "itemId", "a1")
|
|
api.setRating(w, r)
|
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
|
Expect(albumRepo.Data["a1"].Rating).To(Equal(0))
|
|
})
|
|
})
|
|
})
|