mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 23:37:09 +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.
269 lines
12 KiB
Go
269 lines
12 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/core"
|
|
"github.com/navidrome/navidrome/core/artwork"
|
|
"github.com/navidrome/navidrome/core/external"
|
|
"github.com/navidrome/navidrome/core/lyrics"
|
|
"github.com/navidrome/navidrome/core/playlists"
|
|
"github.com/navidrome/navidrome/core/quickconnect"
|
|
"github.com/navidrome/navidrome/core/scrobbler"
|
|
"github.com/navidrome/navidrome/core/sonic"
|
|
"github.com/navidrome/navidrome/core/stream"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/server"
|
|
"github.com/navidrome/navidrome/server/events"
|
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
|
"github.com/navidrome/navidrome/utils/cache"
|
|
)
|
|
|
|
type Router struct {
|
|
http.Handler
|
|
ds model.DataStore
|
|
artwork artwork.Artwork
|
|
streamer stream.MediaStreamer
|
|
transcodeDecider stream.TranscodeDecider
|
|
players core.Players
|
|
scrobbler scrobbler.PlayTracker
|
|
playlists playlists.Playlists
|
|
provider external.Provider
|
|
sonic sonic.Engine
|
|
lyrics lyrics.Lyrics
|
|
broker events.Broker
|
|
lyricsCache cache.SimpleCache[string, model.LyricList]
|
|
similarFlight singleflight.Group
|
|
quickConnect quickconnect.QuickConnect
|
|
serverIDVal string
|
|
}
|
|
|
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer,
|
|
transcodeDecider stream.TranscodeDecider, players core.Players,
|
|
scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider,
|
|
sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router {
|
|
r := &Router{
|
|
ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider,
|
|
players: players, scrobbler: scrobbler, playlists: playlists, provider: provider,
|
|
sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect,
|
|
lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{
|
|
SizeLimit: 1000,
|
|
DefaultTTL: 5 * time.Minute,
|
|
}),
|
|
}
|
|
r.Handler = r.routes()
|
|
return r
|
|
}
|
|
|
|
func (api *Router) routes() http.Handler {
|
|
inner := chi.NewRouter()
|
|
|
|
// Read query params case-insensitively, like real Jellyfin. Must precede all routes so every
|
|
// handler and the api_key check see folded keys.
|
|
inner.Use(normalizeQueryKeys)
|
|
|
|
// Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase.
|
|
|
|
// Public (no auth): handshake + login.
|
|
inner.Get("/system/info/public", api.getPublicSystemInfo)
|
|
inner.Get("/system/ping", api.ping)
|
|
inner.Post("/system/ping", api.ping)
|
|
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
|
|
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
|
|
// brute-force surface, so it must share the same per-client throttle when one is configured.
|
|
login := inner.With(server.LimitLoginBody)
|
|
if conf.Server.AuthRequestLimit > 0 {
|
|
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
|
|
}
|
|
login.Post("/users/authenticatebyname", api.authenticateByName)
|
|
quickConnectLogin := login.With(requireQuickConnect)
|
|
quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate)
|
|
quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect)
|
|
// Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown.
|
|
inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect)
|
|
inner.Get("/users/public", api.getPublicUsers)
|
|
|
|
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.
|
|
// Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy,
|
|
// and an unbounded burst (a client fetching artwork across a large library) can exhaust memory.
|
|
inner.Group(func(r chi.Router) {
|
|
r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit,
|
|
conf.Server.DevArtworkThrottleBacklogTimeout))
|
|
r.Get("/items/{itemId}/images/{type}", api.getItemImage)
|
|
r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage)
|
|
r.Head("/items/{itemId}/images/{type}", api.getItemImage)
|
|
r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage)
|
|
})
|
|
|
|
inner.Group(func(r chi.Router) {
|
|
r.Use(api.authenticate)
|
|
// Register/refresh the calling device as a player on every authenticated request, like
|
|
// Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a
|
|
// player) even before the first playback report.
|
|
r.Use(api.withPlayer)
|
|
r.Get("/system/info", api.getSystemInfo)
|
|
r.Get("/system/endpoint", api.getEndpointInfo)
|
|
r.Get("/userviews", api.getUserViews)
|
|
r.Get("/users/{userId}/views", api.getUserViews)
|
|
r.Get("/users/me", api.getCurrentUser)
|
|
r.Get("/users/{userId}", api.getCurrentUser)
|
|
// Throttled like login so a signed-in user cannot enumerate other people's pending codes.
|
|
approve := r.With(requireQuickConnect)
|
|
if conf.Server.AuthRequestLimit > 0 {
|
|
approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
|
|
}
|
|
approve.Post("/quickconnect/authorize", api.quickConnectAuthorize)
|
|
|
|
// Cursor-backed collections: each streams straight from the DB, holding a connection for the
|
|
// whole client-paced response, so enough slow clients would take the entire pool and stall the
|
|
// scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess
|
|
// requests queue rather than fail.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams))
|
|
r.Get("/items", api.getItems)
|
|
r.Get("/users/{userId}/items", api.getItems)
|
|
r.Get("/items/latest", api.getLatest)
|
|
r.Get("/users/{userId}/items/latest", api.getLatest)
|
|
r.Get("/artists", api.getArtists)
|
|
r.Get("/artists/albumartists", api.getAlbumArtists)
|
|
r.Get("/playlists/{playlistId}/items", api.getPlaylistItems)
|
|
})
|
|
|
|
r.Get("/items/{itemId}", api.getItem)
|
|
r.Get("/users/{userId}/items/{itemId}", api.getItem)
|
|
r.Delete("/items/{itemId}", api.deleteItem)
|
|
|
|
// /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the
|
|
// /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses.
|
|
r.Post("/userfavoriteitems/{itemId}", api.markFavorite)
|
|
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
|
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
|
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
|
r.Post("/useritems/{itemId}/rating", api.setRating)
|
|
r.Delete("/useritems/{itemId}/rating", api.removeRating)
|
|
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
|
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
|
|
|
// Per-item play/favorite/rating state. Jellify uses the /UserItems form;
|
|
// /Users/{userId}/Items is the legacy spelling.
|
|
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
|
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
|
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
|
|
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
|
|
|
|
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
|
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
|
r.Get("/albums/{itemId}/similar", api.getSimilarAlbums)
|
|
r.Get("/items/{itemId}/instantmix", api.getInstantMix)
|
|
r.Get("/songs/{itemId}/instantmix", api.getInstantMix)
|
|
r.Get("/albums/{itemId}/instantmix", api.getInstantMix)
|
|
r.Get("/artists/{itemId}/instantmix", api.getInstantMix)
|
|
r.Get("/playlists/{itemId}/instantmix", api.getInstantMix)
|
|
r.Get("/artists/instantmix", api.getInstantMixByQuery)
|
|
r.Get("/musicgenres/instantmix", api.getInstantMixByQuery)
|
|
r.Get("/genres", api.getGenres)
|
|
r.Get("/musicgenres", api.getGenres)
|
|
r.Get("/studios", api.getStudios)
|
|
r.Get("/items/filters", api.getQueryFiltersLegacy)
|
|
|
|
r.Post("/playlists", api.createPlaylist)
|
|
r.Get("/playlists/{playlistId}", api.getPlaylist)
|
|
r.Post("/playlists/{playlistId}", api.updatePlaylist)
|
|
r.Post("/playlists/{playlistId}/items", api.addToPlaylist)
|
|
r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist)
|
|
r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem)
|
|
r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers)
|
|
r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser)
|
|
|
|
// Cover upload/delete: only playlists are writable (see postItemImage); the GET routes
|
|
// above stay public.
|
|
r.Post("/items/{itemId}/images/{type}", api.postItemImage)
|
|
r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage)
|
|
|
|
r.Get("/audio/{itemId}/stream", api.streamAudio)
|
|
r.Get("/audio/{itemId}/stream.{container}", api.streamAudio)
|
|
r.Get("/audio/{itemId}/universal", api.streamUniversal)
|
|
// Fintunes probes these with HEAD for the content type before playing or downloading.
|
|
r.Head("/audio/{itemId}/stream", api.streamAudio)
|
|
r.Head("/audio/{itemId}/stream.{container}", api.streamAudio)
|
|
r.Head("/audio/{itemId}/universal", api.streamUniversal)
|
|
r.Get("/audio/{itemId}/main.m3u8", api.streamHls)
|
|
r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
|
|
r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo)
|
|
r.Get("/audio/{itemId}/lyrics", api.getLyrics)
|
|
// Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of
|
|
// /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file.
|
|
r.Get("/items/{itemId}/file", api.streamFile)
|
|
r.Get("/items/{itemId}/download", api.streamFile)
|
|
r.Head("/items/{itemId}/file", api.streamFile)
|
|
r.Head("/items/{itemId}/download", api.streamFile)
|
|
|
|
r.Post("/sessions/playing", api.reportPlaybackStart)
|
|
r.Post("/sessions/playing/progress", api.reportPlaybackProgress)
|
|
r.Post("/sessions/playing/stopped", api.reportPlaybackStopped)
|
|
r.Post("/sessions/playing/ping", api.acknowledge)
|
|
r.Post("/sessions/capabilities", api.acknowledge)
|
|
r.Post("/sessions/capabilities/full", api.acknowledge)
|
|
|
|
// Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect.
|
|
r.Get("/socket", api.handleSocket)
|
|
|
|
r.Get("/audiomuseai/info", api.audioMuseInfo)
|
|
r.Get("/audiomuseai/health", api.audioMuseHealth)
|
|
r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks)
|
|
r.Get("/audiomuseai/find_path", api.audioMuseFindPath)
|
|
})
|
|
|
|
// Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected
|
|
// traffic, and this just surfaces what's missing.
|
|
inner.NotFound(api.notFound)
|
|
inner.MethodNotAllowed(api.notFound)
|
|
|
|
// Real Jellyfin clients route case-insensitively; chi does not.
|
|
return caseInsensitivePaths(inner)
|
|
}
|
|
|
|
// ok writes payload as JSON — the single entry point for every handler. Collections are routed to
|
|
// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped
|
|
// on any item(s): real Jellyfin always sets it, and it's constant per request.
|
|
//
|
|
// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray).
|
|
func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) {
|
|
switch p := payload.(type) {
|
|
case itemsResult:
|
|
api.writeItems(w, r, p)
|
|
return
|
|
case dto.QueryResult:
|
|
api.writeItems(w, r, materialized(p))
|
|
return
|
|
case dto.BaseItemDto:
|
|
payload = stampItem(p, api.serverID(r.Context()), requestFields(r))
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
|
log.Error(r.Context(), "Jellyfin API: error encoding response", err)
|
|
}
|
|
}
|
|
|
|
// notFound handles unmatched routes and unsupported methods, logging them so unimplemented
|
|
// endpoints surface instead of returning chi's default plain-text 404/405.
|
|
func (api *Router) notFound(w http.ResponseWriter, r *http.Request) {
|
|
log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
|
|
// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output,
|
|
// file paths) never reaches the client.
|
|
func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
|
log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|