🏡 index : quine-global/quintodrome.git

package jellyfin

import (
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/core/auth"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/request"
)

// throttleStreams bounds how many collection responses stream concurrently, so they can't take every
// connection in the shared DB pool: each holds a cursor, and its connection, for the whole
// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it.
//
// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire
// response to release its token early, which is right for artwork but would undo the streaming here.
// chi's panics on a non-positive limit, hence the guard.
func throttleStreams(limit int) func(http.Handler) http.Handler {
	if limit <= 0 {
		return func(next http.Handler) http.Handler { return next }
	}
	return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout)
}

// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the
// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param
// segments too, which is safe because every id the API emits — user ids included — is lowercase hex
// (dto.EncodeID).
func caseInsensitivePaths(r chi.Router) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		// Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path.
		if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" {
			rctx.RoutePath = strings.ToLower(rctx.RoutePath)
		} else {
			req.URL.Path = strings.ToLower(req.URL.Path)
		}
		r.ServeHTTP(w, req)
	})
}

// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params
// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase,
// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one
// client's filters, sort and paging. Only keys are folded — values keep their case. The original
// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing.
func normalizeQueryKeys(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		folded := make(url.Values, len(q))
		changed := false
		for k, vs := range q {
			lk := strings.ToLower(k)
			// Append, don't assign: two casings of the same key must merge, not overwrite.
			folded[lk] = append(folded[lk], vs...)
			if lk != k {
				changed = true
			}
		}
		if changed {
			r2 := *r
			u := *r.URL
			u.RawQuery = folded.Encode()
			r2.URL = &u
			r = &r2
		}
		next.ServeHTTP(w, r)
	})
}

type mediaBrowserAuth struct {
	Client, Device, DeviceId, Version, Token string
}

var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`)

// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g.
// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`.
// The recommended Authorization header is preferred, but only when it actually carries
// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client
// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk)
// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField
// leaves a raw value untouched.
func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth {
	if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok {
		return a
	}
	a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization"))
	return a
}

// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the
// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts).
func parseAuthHeader(h string) (mediaBrowserAuth, bool) {
	var a mediaBrowserAuth
	scheme, params, found := strings.Cut(h, " ")
	if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) {
		return a, false
	}
	for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) {
		switch m[1] {
		case "Client":
			a.Client = unescapeField(m[2])
		case "Device":
			a.Device = unescapeField(m[2])
		case "DeviceId":
			a.DeviceId = unescapeField(m[2])
		case "Version":
			a.Version = unescapeField(m[2])
		case "Token":
			a.Token = unescapeField(m[2])
		}
	}
	return a, true
}

// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't
// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not
// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space.
func unescapeField(v string) string {
	if decoded, err := url.PathUnescape(v); err == nil {
		return decoded
	}
	return v
}

// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings
// deprecated by Jellyfin but still sent by clients.
func tokenFromRequest(r *http.Request) string {
	if t := parseMediaBrowserAuth(r).Token; t != "" {
		return t
	}
	if t := r.Header.Get("X-Emby-Token"); t != "" {
		return t
	}
	if t := r.Header.Get("X-MediaBrowser-Token"); t != "" {
		return t
	}
	// api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't
	// merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=).
	if t := r.URL.Query().Get("api_key"); t != "" {
		return t
	}
	return r.URL.Query().Get("apikey")
}

// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token
// or unknown subject.
func (api *Router) userFromToken(r *http.Request) (model.User, bool) {
	token := tokenFromRequest(r)
	if token == "" {
		return model.User{}, false
	}
	claims, err := auth.Validate(token)
	if err != nil || claims.Subject == "" {
		return model.User{}, false
	}
	usr, err := api.ds.User().FindByUsername(r.Context(), claims.Subject)
	if err != nil {
		log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err)
		return model.User{}, false
	}
	if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil {
		log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err)
		return model.User{}, false
	}
	return *usr, true
}

func (api *Router) authenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		usr, ok := api.userFromToken(r)
		if !ok {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		ctx := request.WithUser(r.Context(), usr)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring
// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic),
// so it's used directly as the player id and reports from the same install share a player/scrobbling
// session.
func (api *Router) withPlayer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if api.players == nil { // fail open when players isn't wired (e.g. in unit tests)
			next.ServeHTTP(w, r)
			return
		}
		ctx := r.Context()
		a := parseMediaBrowserAuth(r)
		// Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g.
		// the /socket handshake that authenticates via ?api_key= only). Otherwise Register would
		// create a junk player with an empty name (" []").
		if a.Client == "" && a.DeviceId == "" {
			next.ServeHTTP(w, r)
			return
		}
		ip, _, _ := net.SplitHostPort(r.RemoteAddr)
		player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip)
		if err != nil {
			// Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers
			// degrade gracefully.
			log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err)
			next.ServeHTTP(w, r)
			return
		}
		ctx = request.WithPlayer(ctx, *player)
		// Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override.
		if trc != nil {
			ctx = request.WithTranscoding(ctx, *trc)
		}
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}