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 }