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()
inner.Use(normalizeQueryKeys)
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)
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)
inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect)
inner.Get("/users/public", api.getPublicUsers)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
})
inner.NotFound(api.notFound)
inner.MethodNotAllowed(api.notFound)
return caseInsensitivePaths(inner)
}
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)
}
}
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(`{}`))
}
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)
}