mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-19 07:47:16 +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.
204 lines
6.6 KiB
Go
204 lines
6.6 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
)
|
|
|
|
type annotatedItem struct {
|
|
repo model.AnnotatedRepository
|
|
resource string // name used in refreshResource events
|
|
ann model.Annotations
|
|
}
|
|
|
|
// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated
|
|
// (artists span libraries; playlists gate in Get). When !ok, the response has already been written.
|
|
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) {
|
|
ctx := r.Context()
|
|
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
|
api.internalError(w, r, err)
|
|
return annotatedItem{}, false
|
|
}
|
|
u, _ := request.UserFrom(ctx)
|
|
switch e := entity.(type) {
|
|
case *model.Album:
|
|
if u.HasLibraryAccess(e.LibraryID) {
|
|
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
|
|
}
|
|
case *model.Artist:
|
|
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
|
|
case *model.MediaFile:
|
|
if u.HasLibraryAccess(e.LibraryID) {
|
|
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
|
|
}
|
|
case *model.Playlist:
|
|
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
|
|
}
|
|
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return annotatedItem{}, false
|
|
}
|
|
|
|
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
|
// fetches this per item to render played/favourite indicators; resolveItemByID enforces the
|
|
// library-access gate.
|
|
func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := itemIDParam(w, r, "itemId")
|
|
if !ok {
|
|
return
|
|
}
|
|
item, ok := api.resolveItemByID(r.Context(), id, nil)
|
|
if !ok {
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
data := item.UserData
|
|
if data == nil {
|
|
// Items without annotations still return a valid empty UserData.
|
|
data = dto.UserData(model.Annotations{}, id)
|
|
}
|
|
api.ok(w, r, data)
|
|
}
|
|
|
|
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
|
|
type userDataChange struct {
|
|
rating *int // Navidrome stars; 0 clears
|
|
favorite *bool
|
|
}
|
|
|
|
// applyUserData writes change for the itemId path param and answers with the item's resulting
|
|
// UserData, as Jellyfin does.
|
|
func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
|
id, ok := itemIDParam(w, r, "itemId")
|
|
if !ok {
|
|
return
|
|
}
|
|
item, ok := api.resolveAnnotated(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
if change.rating != nil {
|
|
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
item.ann.Rating = *change.rating
|
|
}
|
|
if change.favorite != nil {
|
|
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
item.ann.Starred = *change.favorite
|
|
}
|
|
if change.rating != nil || change.favorite != nil {
|
|
api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id))
|
|
}
|
|
api.ok(w, r, dto.UserData(item.ann, id))
|
|
}
|
|
|
|
// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own
|
|
// annotations can be written, so naming another user is refused. Undecodable ids are ignored.
|
|
func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
|
raw := chi.URLParam(r, "userId")
|
|
if raw == "" {
|
|
raw = r.URL.Query().Get("userid")
|
|
}
|
|
if target, ok := dto.DecodeID(raw); ok {
|
|
if u, _ := request.UserFrom(r.Context()); target != u.ID {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
api.applyUserData(w, r, change)
|
|
}
|
|
|
|
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) {
|
|
api.applyUserData(w, r, userDataChange{favorite: new(true)})
|
|
}
|
|
|
|
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
|
api.applyUserData(w, r, userDataChange{favorite: new(false)})
|
|
}
|
|
|
|
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
|
|
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
|
|
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
var stars int
|
|
switch {
|
|
case q.Has("likes"):
|
|
likes, err := strconv.ParseBool(q.Get("likes"))
|
|
if err != nil {
|
|
http.Error(w, "likes must be true or false", http.StatusBadRequest)
|
|
return
|
|
}
|
|
stars = dto.StarsFromLikes(likes)
|
|
case q.Has("rating"):
|
|
rating, err := strconv.ParseFloat(q.Get("rating"), 64)
|
|
s, ok := dto.StarsFromRating(rating)
|
|
if err != nil || !ok {
|
|
http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest)
|
|
return
|
|
}
|
|
stars = s
|
|
default:
|
|
http.Error(w, "likes is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
|
|
}
|
|
|
|
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
|
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
|
|
}
|
|
|
|
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
|
|
// Play state is ignored because it only changes through playback reporting.
|
|
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
|
|
body, ok := decodeUserDataBody(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
change := userDataChange{favorite: body.IsFavorite}
|
|
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
|
|
switch {
|
|
case body.Rating != nil:
|
|
stars, ok := dto.StarsFromRating(*body.Rating)
|
|
if !ok {
|
|
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
|
|
return
|
|
}
|
|
change.rating = &stars
|
|
case body.Likes != nil:
|
|
change.rating = new(dto.StarsFromLikes(*body.Likes))
|
|
}
|
|
api.applyCallerUserData(w, r, change)
|
|
}
|
|
|
|
const maxUserDataBody = 64 << 10
|
|
|
|
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
|
|
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
|
|
var body *dto.UpdateUserItemDataDto
|
|
err := dec.Decode(&body)
|
|
if err == nil && body != nil {
|
|
if _, err = dec.Token(); errors.Is(err, io.EOF) {
|
|
return body, true
|
|
}
|
|
}
|
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
return nil, false
|
|
}
|