🏡 index : quine-global/quintodrome.git

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)
}