// Package jellyfin translates Vellum's existing storage into the subset of the // Jellyfin HTTP API that real clients (iOS, Android, Roku, TV apps) use to // browse and play. // // It is a translation layer, not a second media server. Every query goes // through the existing media_items / media_files / progress / assets tables, // and every request resolves to an auth.User through the same grant chokepoint // the web app uses (auth.VisibleLibraryIDs / auth.CanAccessLibrary). A Jellyfin // token minted here is an apitoken.KindJellyfin token, so revoking it in the // web UI revokes Jellyfin access too. // // # No transcoding // // Vellum runs on a headless, GPU-less host. PlaybackInfo therefore honestly // reports SupportsTranscoding=false and hands out a direct stream URL. // A client that demands a transcode (it disabled both direct play and direct // stream) is told so via PlaybackInfo's ErrorCode "NoCompatibleStream" rather // than being handed a TranscodingUrl that would 404 or burn CPU the host does // not have. // // # Root mounting // // The routes are mounted at the ROOT because that is where Jellyfin clients // look (/System/Info/Public, /Users/AuthenticateByName, /Items, ...). They do // not collide with the web app: internal/httpapi/static.go owns "/", "/healthz" // and "/api/", and every Jellyfin path is a more specific ServeMux pattern, so // requests route here first. isAPIPath in static.go does not know these // prefixes, so each Jellyfin subtree installs its own JSON 404 handler; an // unknown /Items/... path returns JSON rather than the SPA shell. package jellyfin import ( "context" "crypto/rand" "database/sql" "encoding/hex" "log/slog" "net/http" "sync" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" ) // Handlers serves the Jellyfin-compatible subset. type Handlers struct { db *sql.DB log *slog.Logger // a is kept for parity with the other HTTP layers and for ClientIP, but // token resolution is done here: the authenticator's middleware only knows // session cookies, which Jellyfin clients do not send. a *auth.Authenticator mu sync.Mutex serverID string } // NewHandlers builds the Jellyfin HTTP layer. It deliberately takes no cache // directory: cover bytes are served from the assets table the scanner already // populated, rather than triggering a fresh extraction. func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers { return &Handlers{db: db, log: log, a: a} } // Register mounts the routes. See the package comment for why they live at the // root and why each subtree has a JSON 404 fallback. func (h *Handlers) Register(mux *http.ServeMux) { // Public discovery. mux.HandleFunc("GET /System/Info/Public", h.handleSystemInfoPublic) mux.HandleFunc("POST /Users/AuthenticateByName", h.handleAuthenticateByName) // Authenticated: users. mux.Handle("GET /Users/Me", h.require(h.handleUserMe)) mux.Handle("GET /Users/{userId}", h.require(h.handleUserByID)) // Authenticated: browsing. mux.Handle("GET /Items", h.require(h.handleItems)) mux.Handle("GET /Users/{userId}/Items", h.require(h.handleUserItems)) mux.Handle("GET /Items/{itemId}", h.require(h.handleItem)) mux.Handle("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary)) mux.Handle("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo)) // Media. These MUST accept api_key in the query string: