Vellum/internal/jellyfin/jellyfin.go
mithral 6da194045b Waves 9-10: Jellyfin and Subsonic clients can browse and play
Vellum now speaks two foreign protocols, so the clients people already own can
use it. Both are translation layers over queries that already exist, and both go
through the same grant chokepoint as the web app — a token is a second way to be
a user, never a second set of permissions.

Shared foundation (coordinator, written before dispatch)
- migrations/0012_api_tokens.sql + internal/apitoken. Both protocols need a
  credential that is not the session cookie, and letting two agents each invent
  a token table would have produced two incompatible ones.
- The table stores two shapes for a real reason: a Jellyfin token is a bearer
  value so only its hash is kept, while a Subsonic key must be recoverable
  because verifying `t` means computing md5(key + salt) server-side. token_plain
  is therefore the one column in the schema holding a usable credential. It is a
  generated random secret, never a user password, and it is revocable.

Wave 9 - Jellyfin
- /System/Info/Public, /Users/AuthenticateByName, /Users/Me, /Users/{id},
  /Items, /Users/{id}/Items, /Items/{id}, /Items/{id}/Images/Primary,
  /Items/{id}/PlaybackInfo, /Videos/{id}/stream, /Audio/{id}/stream,
  /Sessions/Playing{,/Progress,/Stopped}.
- Auth accepts X-Emby-Token, X-MediaBrowser-Token, ?api_key=, and
  Authorization: MediaBrowser Token="...". The ?api_key= form is the one that
  matters: <video> elements cannot set headers.
- Honest about the one thing Vellum cannot do. PlaybackInfo reports
  SupportsDirectPlay/SupportsDirectStream true and SupportsTranscoding FALSE,
  with a DirectStreamUrl carrying the api_key. A client that disables both
  direct modes is demanding a transcode and is refused with ErrorCode
  "NoCompatibleStream" — rather than handed a TranscodingUrl that would never
  resolve, which is how a client hangs forever.

Wave 10 - Subsonic
- /rest/{method} dispatch with ping, getLicense, getMusicFolders, getIndexes,
  getArtists/getArtist/getAlbum, getMusicDirectory, getSong, search3, stream,
  getCoverArt, getAlbumList2, getRandomSongs, scrobble, getUser, getPlaylists,
  plus GET /api/v1/subsonic/credentials to hand a user their key.
- Auth via t=md5(key+salt), p=key, constant-time comparison. A bad credential
  returns HTTP 200 with the envelope status "failed" and error code 40 — NOT an
  HTTP 401. Subsonic clients read the envelope, and getting this wrong is the
  single most common integration failure in that protocol.
- stream supports format=raw and never invokes ffmpeg.

Coordinator wired both into main.go (the line agents were told to leave alone)
and verified against real requests.

Verified end to end, 18 packages pass, gofmt/vet clean, svelte-check 0/0:
- Jellyfin: discovery, AuthenticateByName minting a token, /Items reporting 18,
  PlaybackInfo showing SupportsTranscoding=false, and a Range request to
  /Videos/{id}/stream using ONLY ?api_key= returning 206 with Content-Range.
- Subsonic: ping as JSON and XML, md5(key+salt) auth succeeding, and a bad token
  returning the failed envelope with code 40.
- Scoping, both protocols, with a genuinely non-admin user: 0 items, 404 on an
  admin-only item, 404 on its stream, 0 music folders; admin still sees 18.
- The SPA is not shadowed by the root-mounted compat paths: /, /healthz and
  /api/v1/version all still answer correctly.

Both agents failed with an HTTP 400 at the REPORT stage, not during the work;
their files were complete and compile, so the work was salvaged and verified
here rather than redone.
2026-10-11 15:05:43 -04:00

157 lines
6.3 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.
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: <video>/<audio>
// elements cannot set request headers, so a Jellyfin client appends
// ?api_key=... to the URL it plays.
mux.Handle("GET /Videos/{itemId}/stream", h.require(h.handleVideoStream))
mux.Handle("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.
mux.Handle("GET /Videos/{itemId}/", h.require(h.handleVideoStream))
mux.Handle("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.
mux.Handle("POST /Sessions/Playing", h.require(h.handlePlaying))
mux.Handle("POST /Sessions/Playing/Progress", h.require(h.handlePlayingProgress))
mux.Handle("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/",
} {
mux.HandleFunc(prefix, 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")
}