// 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. // // Every pattern is installed a second time under /emby, the legacy base path. // Many Jellyfin apps probe GET /emby/System/Info/Public during handshake and // give up on discovery when it 404s, showing their manual "enter your server // URL" screen. It is the same handler behind both paths: an alias, never a // second surface, so it cannot drift from the root. package jellyfin import ( "context" "crypto/rand" "database/sql" "encoding/hex" "log/slog" "net/http" "strings" "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} } // embyPrefix is the legacy Jellyfin base path. It is an alias for the root // mount, not a second surface: every pattern below is installed under both. const embyPrefix = "/emby" // 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) { // mount installs each pattern twice, bare and under /emby. Jellyfin's own // clients use the bare root paths, but many real apps probe // /emby/System/Info/Public during handshake and abandon discovery when it // 404s. Same handler either way, so the alias cannot drift from the root. mount := func(pattern string, handler http.Handler) { mux.Handle(pattern, handler) // The prefix goes on the path, not the whole pattern: "GET /Items" is // one string and "embyGET /Items" does not parse. method, path, hasMethod := strings.Cut(pattern, " ") if !hasMethod { method, path = "", pattern } mux.Handle(method+" "+embyPrefix+path, handler) } // Public discovery. mount("GET /System/Info/Public", http.HandlerFunc(h.handleSystemInfoPublic)) mount("POST /Users/AuthenticateByName", http.HandlerFunc(h.handleAuthenticateByName)) // Authenticated: users. mount("GET /Users/Me", h.require(h.handleUserMe)) mount("GET /Users/{userId}", h.require(h.handleUserByID)) // Authenticated: browsing. mount("GET /Items", h.require(h.handleItems)) mount("GET /Users/{userId}/Items", h.require(h.handleUserItems)) mount("GET /Items/{itemId}", h.require(h.handleItem)) mount("GET /Items/{itemId}/Images/Primary", h.require(h.handleImagePrimary)) mount("GET /Items/{itemId}/PlaybackInfo", h.require(h.handlePlaybackInfo)) // Media. These MUST accept api_key in the query string: