mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
Like Subsonic's setStar/setRating, the Jellyfin favorite and rating endpoints now broadcast a refreshResource event, so the web UI updates immediately when a Jellyfin client changes an annotation. Also fixes model.GetEntityByID to propagate unexpected repository errors instead of reporting them as not-found, preserving the 500-vs-404 distinction for all its callers.
122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"net/http"
|
|
|
|
"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"
|
|
"github.com/navidrome/navidrome/utils/req"
|
|
)
|
|
|
|
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
|
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
|
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
|
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
|
// already been written, so callers must return without writing the annotation.
|
|
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
|
|
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 nil, ""
|
|
}
|
|
u, _ := request.UserFrom(ctx)
|
|
switch e := entity.(type) {
|
|
case *model.Album:
|
|
if u.HasLibraryAccess(e.LibraryID) {
|
|
return api.ds.Album(ctx), "album"
|
|
}
|
|
case *model.Artist:
|
|
return api.ds.Artist(ctx), "artist"
|
|
case *model.MediaFile:
|
|
if u.HasLibraryAccess(e.LibraryID) {
|
|
return api.ds.MediaFile(ctx), "song"
|
|
}
|
|
case *model.Playlist:
|
|
return api.ds.Playlist(ctx), "playlist"
|
|
}
|
|
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
|
http.Error(w, "Not Found", http.StatusNotFound)
|
|
return nil, ""
|
|
}
|
|
|
|
// 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 := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
|
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)
|
|
}
|
|
|
|
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
|
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
|
repo, resource := api.resolveAnnotated(w, r, id)
|
|
if repo == nil {
|
|
return
|
|
}
|
|
if err := repo.SetStar(starred, id); err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
|
encodedID := dto.EncodeID(id)
|
|
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
|
}
|
|
|
|
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
|
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
|
api.setFavorite(w, r, false)
|
|
}
|
|
|
|
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
|
id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId")))
|
|
repo, resource := api.resolveAnnotated(w, r, id)
|
|
if repo == nil {
|
|
return
|
|
}
|
|
if err := repo.SetRating(rating, id); err != nil {
|
|
api.internalError(w, r, err)
|
|
return
|
|
}
|
|
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
|
encodedID := dto.EncodeID(id)
|
|
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
|
|
if rating > 0 {
|
|
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
|
|
d.Rating = &jfRating
|
|
}
|
|
api.ok(w, r, d)
|
|
}
|
|
|
|
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
|
|
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
|
|
// SetRating(0) is the delete path.
|
|
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
|
jfRating := req.Params(r).Float64Or("rating", 0)
|
|
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
|
rating := int(math.Round(jfRating / 2))
|
|
if jfRating > 0 {
|
|
rating = max(rating, 1)
|
|
}
|
|
api.setItemRating(w, r, rating)
|
|
}
|
|
|
|
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
|
api.setItemRating(w, r, 0)
|
|
}
|