mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-02 08:27:56 +01:00
* feat(jellyfin): compute the address advertised by auto-discovery * fix(jellyfin): use TLSEnabled and path.Join for the auto-discovery address * feat(jellyfin): answer LAN auto-discovery broadcasts * test(jellyfin): e2e check that auto-discovery matches the public server identity * feat(jellyfin): add opt-in AutoDiscovery option and start the listener * fix(jellyfin): close the auto-discovery socket on read errors and quiet reply failures * refactor(jellyfin): build the auto-discovery address with publicurl and log bind failures in place discoveryAddress now hands only the discovery-specific part (the requester-facing host) to publicurl.AbsoluteURL, so the BaseURL, scheme and BasePath rules live in one place. ServeDiscovery logs its own bind failure and returns nothing, so the caller cannot route the error into the server errgroup. Tests use a DescribeTable and no longer wait on a fixed timeout to prove a packet was ignored. * refactor(jellyfin): run auto-discovery as its own service in the main errgroup Discovery is now a small type that needs only a DataStore, started by startJellyfinDiscovery like the other background services, so the errgroup waits for it on shutdown and startServer is back to a one-line mount. The server id resolution moved to resolveServerID with a package-level lock: the Router and Discovery are separate objects, and a per-Router lock would let them persist two different ids on first boot. * refactor(jellyfin): build Discovery through wire and drop the serverName forwarder startJellyfinDiscovery now follows its siblings: negative guard with a DISABLED debug log, and the service comes from a CreateJellyfinDiscovery wire injector instead of an inline constructor. The Router.serverName method only forwarded to the package function, so its four call sites call the function directly. * fix(jellyfin): skip auto-discovery for unix socket servers without a BaseURL host With Address set to a unix socket nothing listens on Port, so the route-facing IP plus Port pointed clients at a dead URL. Discovery now logs a warning and does not start in that mode unless BaseURL names the proxy host, which AbsoluteURL already advertises as-is. * docs(jellyfin): note which address auto-discovery advertises on restricted binds Also stop using a hostname Address in the fallback spec: a hostname like localhost binds a single interface, so it is not an example of the route-facing fallback being right. An empty Address is.
252 lines
11 KiB
Go
252 lines
11 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/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
|
|
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) *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,
|
|
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)
|
|
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)
|
|
|
|
// 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("/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.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)
|
|
}
|