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
ann model.Annotations
}
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
}
http.Error(w, "Not Found", http.StatusNotFound)
return annotatedItem{}, false
}
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 {
data = dto.UserData(model.Annotations{}, id)
}
api.ok(w, r, data)
}
type userDataChange struct {
rating *int
favorite *bool
}
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))
}
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)})
}
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)})
}
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
body, ok := decodeUserDataBody(w, r)
if !ok {
return
}
change := userDataChange{favorite: body.IsFavorite}
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
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
}