Vellum/internal/jellyfin/jellyfin.go
mithral 2bdfc6d139 Serve the Jellyfin surface under /emby as well as at the root
Many Jellyfin clients probe GET /emby/System/Info/Public during their
handshake. There was no /emby prefix anywhere in the tree, so that 404ed
and the client abandoned discovery and fell back to its manual "enter your
server URL" screen - the exact experience mDNS exists to remove.

Register installs every pattern twice through a mount helper, with the same
handler value both times, so the alias cannot drift from the root surface.
The JSON 404 fallbacks get /emby forms too, plus one subtree handler,
because static.go's isAPIPath knows only /healthz and /api/ and an unknown
/emby/... path would otherwise be served the SPA shell.

No new endpoint, no handler behaviour changed, and the bare root paths are
untouched: Jellyfin's own clients use them.
2026-10-12 01:04:33 -04:00

186 lines
7.6 KiB
Go

// 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: <video>/<audio>
// elements cannot set request headers, so a Jellyfin client appends
// ?api_key=... to the URL it plays.
mount("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
mount("GET /Audio/{itemId}/stream", h.require(h.handleAudioStream))
// Real clients ask for stream.<container> (stream.mp4, stream.mkv,
// stream.mp3). ServeMux wildcards are whole segments only, so a suffix
// pattern is impossible; a subtree handler dispatches those instead. The
// exact /stream pattern above is more specific and still wins.
mount("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
mount("GET /Audio/{itemId}/", h.require(h.handleAudioStream))
// Playback reporting. This is how a Jellyfin client tells us its resume
// position; it is written through the existing progress store.
mount("POST /Sessions/Playing", h.require(h.handlePlaying))
mount("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
mount("POST /Sessions/Playing/Stopped", h.require(h.handlePlayingStopped))
// JSON 404s for the rest of each Jellyfin subtree. Without these an
// unknown /Items/... falls through to the SPA shell, because static.go's
// isAPIPath only recognises /healthz and /api/.
for _, prefix := range []string{
"/System/", "/Users/", "/Items/", "/Videos/", "/Audio/", "/Sessions/",
} {
mount(prefix, http.HandlerFunc(h.handleNotFound))
}
// One subtree covers the whole alias: nothing outside the Jellyfin API
// lives under /emby, so anything unmounted there is a 404, not the shell.
mux.HandleFunc(embyPrefix+"/", h.handleNotFound)
}
// serverID returns a stable, per-installation server id, created on first use
// and stored in the existing settings table so it survives restarts. Jellyfin
// clients cache it for discovery and treat a changing id as a different server.
func (h *Handlers) serverIDFor(ctx context.Context) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.serverID != "" {
return h.serverID
}
var v string
err := h.db.QueryRowContext(ctx,
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v)
if err == nil && v != "" {
h.serverID = v
return v
}
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
// A random id is a nicety; fall back to a fixed one rather than fail
// a discovery request.
h.serverID = "vellum0000000000000000000000000000"
return h.serverID
}
v = hex.EncodeToString(raw[:])
_, _ = h.db.ExecContext(ctx,
`INSERT INTO settings (k, v) VALUES ('jellyfin.server_id', ?)
ON CONFLICT (k) DO NOTHING`, v)
// Re-read: a concurrent first request may have won the insert.
if err := h.db.QueryRowContext(ctx,
`SELECT v FROM settings WHERE k = 'jellyfin.server_id'`).Scan(&v); err != nil || v == "" {
v = "vellum0000000000000000000000000000"
}
h.serverID = v
return v
}
func (h *Handlers) handleNotFound(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusNotFound, "not found")
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}