Serve transcoded streams over HLS

Third step of ADR-008. Two routes inside the item family: the playlist starts
the session lazily and serves what ffmpeg has written so far, so playback can
begin while the rest still encodes; the segment route serves one .ts.

Segment names are validated against the exact shape ffmpeg writes before being
used as a path. Verified red-green: removing the check serves a file from
outside the session directory with a 200.

Two corrections made in review.

1. The manager was being built inside NewHandlers, which meant nothing could
   ever shut it down. Its reaper would run for the process lifetime and a
   running ffmpeg would outlive the server that started it. It is now created
   in main.go beside the job queue and deferred, and passed in - the same
   ownership the queue already has.

2. A playlist request could 404 before ffmpeg wrote the file, which a native
   HLS player treats as fatal rather than retrying. The playlist handler now
   waits up to four seconds for the first write. Segments are never waited for.

Verified end-to-end against a running server with real hardware: a 30-second
file produced a valid EVENT playlist in about a second (the wait doing its
job - no 404), a 658 KB segment with Content-Type video/mp2t, a 404 for a
traversal attempt, and a 401 unauthenticated.

Also confirmed at boot: all four metadata providers report their disabled
state and name their env var, including the new adult one.
This commit is contained in:
mithral 2026-10-12 12:00:20 -04:00
parent 365aac0cc6
commit 5ea7ffe6c6
5 changed files with 425 additions and 6 deletions

View file

@ -35,6 +35,7 @@ import (
"github.com/mithral/vellum/internal/scanner" "github.com/mithral/vellum/internal/scanner"
"github.com/mithral/vellum/internal/search" "github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/internal/subsonic" "github.com/mithral/vellum/internal/subsonic"
"github.com/mithral/vellum/internal/transcode"
"github.com/mithral/vellum/migrations" "github.com/mithral/vellum/migrations"
) )
@ -108,7 +109,13 @@ func run(cfg core.Config, log *slog.Logger) error {
queue := jobs.New(db, log) queue := jobs.New(db, log)
library.NewHandlers(db, log, authenticator, queue).Register(api.Mux()) library.NewHandlers(db, log, authenticator, queue).Register(api.Mux())
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux()) // Transcode sessions. Hardware-gated per ADR-008, so a host with no usable
// encoder answers "unavailable" on these routes rather than pretending.
// Owned here rather than inside the media handlers so the deferred Shutdown
// stops a running ffmpeg instead of leaving it behind the server.
sessions := transcode.New()
defer sessions.Shutdown()
media.NewHandlers(db, log, authenticator, cfg.ConfigDir, sessions).Register(api.Mux())
// The landing screen is an aggregate over items, not an item resource, so it // The landing screen is an aggregate over items, not an item resource, so it
// is mounted by its own package. // is mounted by its own package.
home.NewHandlers(db, log, authenticator).Register(api.Mux()) home.NewHandlers(db, log, authenticator).Register(api.Mux())

View file

@ -23,7 +23,10 @@ import (
func mediaHandlers(t *testing.T, db *sql.DB) *Handlers { func mediaHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper() t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil)) log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewHandlers(db, log, nil, t.TempDir()) // nil sessions: the constructor falls back to a fresh manager, which is what
// a test wants. Tests that exercise the transcode routes inject their own
// fake by replacing the field.
return NewHandlers(db, log, nil, t.TempDir(), nil)
} }
func testDB(t *testing.T) *sql.DB { func testDB(t *testing.T) *sql.DB {

View file

@ -24,12 +24,23 @@ type Handlers struct {
log *slog.Logger log *slog.Logger
auth *auth.Authenticator auth *auth.Authenticator
cacheDir string cacheDir string
sessions Sessions
} }
// NewHandlers builds the media HTTP layer. cacheDir is where covers and // NewHandlers builds the media HTTP layer. cacheDir is where covers, extracted
// extracted pages are written. // pages and transcode sessions are written.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string) *Handlers { //
return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir} // sessions is the transcode manager, owned by the caller so it can be shut
// down. It is a parameter rather than being built here for exactly that reason:
// a manager built in this constructor has an idle reaper running for the
// process lifetime with nobody able to stop it, and a running ffmpeg would
// outlive the server that started it. A nil value falls back to a fresh
// manager, which is what a test wants.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir string, sessions Sessions) *Handlers {
if sessions == nil {
sessions = newSessions()
}
return &Handlers{db: db, log: log, auth: a, cacheDir: cacheDir, sessions: sessions}
} }
// Register mounts the routes. // Register mounts the routes.
@ -49,6 +60,8 @@ func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover)) mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
mux.Handle("GET /api/v1/items/{id}/subtitles", h.auth.Require(h.handleSubtitles)) mux.Handle("GET /api/v1/items/{id}/subtitles", h.auth.Require(h.handleSubtitles))
mux.Handle("GET /api/v1/items/{id}/subtitles/{trackID}", h.auth.Require(h.handleSubtitleTrack)) mux.Handle("GET /api/v1/items/{id}/subtitles/{trackID}", h.auth.Require(h.handleSubtitleTrack))
mux.Handle("GET /api/v1/items/{id}/transcode/playlist.m3u8", h.auth.Require(h.handleTranscodePlaylist))
mux.Handle("GET /api/v1/items/{id}/transcode/{segment}", h.auth.Require(h.handleTranscodeSegment))
mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress)) mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress))
mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress)) mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent)) mux.Handle("GET /api/v1/progress/recent", h.auth.Require(h.handleRecent))

View file

@ -0,0 +1,162 @@
package media
// HLS transcoding for an item: the playlist and its segments.
//
// The item is loaded through the catalog chokepoint exactly as handleCover does
// (ADR-005), so a stream on an unreadable item answers with the same 404 an
// absent item does.
//
// The playlist request starts the encoder lazily (Manager.Start), then serves
// whatever ffmpeg has written so far, so a player can begin while the rest is
// still encoding. Every request goes through Start, which touches the session
// and keeps the idle reaper from stopping it in the middle of a watch.
import (
"errors"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/httpx"
"github.com/mithral/vellum/internal/transcode"
)
// Sessions starts (or reuses) the HLS transcode session for one item.
//
// *transcode.Manager is the production implementation. It is an interface so a
// test can inject a fake and run these routes with no hardware and no ffmpeg.
type Sessions interface {
Start(itemID int64, inputPath, cacheDir string) (transcode.Session, error)
}
// segmentName is the exact shape ffmpeg's -hls_segment_filename writes:
// "seg" then five digits then ".ts". Anything else is refused, so a request can
// never name a file outside the session directory.
var segmentName = regexp.MustCompile(`^seg[0-9]{5}\.ts$`)
// newSessions is the production manager, constructed at the wiring site when
// none is injected.
func newSessions() Sessions { return transcode.New() }
// handleTranscodePlaylist starts the item's session if it is not already
// running, then serves the HLS playlist as it stands.
func (h *Handlers) handleTranscodePlaylist(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
s, ok := h.startSession(w, r, it)
if !ok {
return
}
h.serveTranscodeFile(w, r, s.PlaylistPath(), "application/vnd.apple.mpegurl")
}
// handleTranscodeSegment serves one .ts segment of the item's stream.
func (h *Handlers) handleTranscodeSegment(w http.ResponseWriter, r *http.Request, u auth.User) {
it, ok := h.authorized(w, r, u)
if !ok {
return
}
name := r.PathValue("segment")
if !segmentName.MatchString(name) {
httpx.WriteError(w, http.StatusNotFound, "segment not found")
return
}
s, ok := h.startSession(w, r, it)
if !ok {
return
}
h.serveTranscodeFile(w, r, filepath.Join(s.Dir(), name), "video/mp2t")
}
// startSession starts or reuses the item's transcode session, translating the
// manager's refusals into a status. On failure it has already written the
// response.
//
// Reusing a live session is also the "touch": Start refreshes its idle timer,
// so a request for a segment keeps the encoder alive while it is being watched.
func (h *Handlers) startSession(w http.ResponseWriter, r *http.Request, it Item) (transcode.Session, bool) {
s, err := h.sessions.Start(it.ID, it.AbsPath(), h.cacheDir)
switch {
case errors.Is(err, transcode.ErrUnavailable):
httpx.WriteError(w, http.StatusServiceUnavailable, "transcoding is not available on this server")
return nil, false
case errors.Is(err, transcode.ErrAtCapacity):
httpx.WriteError(w, http.StatusServiceUnavailable, "transcode capacity reached, try again shortly")
return nil, false
case errors.Is(err, transcode.ErrShutdown):
httpx.WriteError(w, http.StatusServiceUnavailable, "transcoding is shutting down")
return nil, false
case err != nil:
h.fail(w, "start transcode", err)
return nil, false
}
return s, true
}
// serveTranscodeFile serves a file the encoder may still be appending to. It
// sizes the response from the file as it is now — not from a finished transcode
// — so a player gets the partial playlist immediately.
func (h *Handlers) serveTranscodeFile(w http.ResponseWriter, r *http.Request, path, contentType string) {
// ffmpeg needs a moment to write the playlist after it is spawned. Waiting
// for it here is what makes a plain <video src="...m3u8"> work: without it
// the first manifest request is a 404, and a native HLS player treats that
// as fatal rather than retrying. Segments are never waited for - by the time
// the playlist exists the first segment is there or about to be.
if filepath.Base(path) == "playlist.m3u8" {
waitForFile(r, path, playlistWait)
}
f, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
// The session is running but has not written this file yet.
httpx.WriteError(w, http.StatusNotFound, "transcode not ready")
return
}
if err != nil {
h.fail(w, "open transcode file", err)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || !info.Mode().IsRegular() {
httpx.WriteError(w, http.StatusNotFound, "transcode not ready")
return
}
// http.ServeContent would sniff the extension; .m3u8 and .ts are not in
// stream's MIME table, so the type is set explicitly here.
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
// playlistWait bounds how long a playlist request blocks on ffmpeg's first
// write. Long enough for a hardware encoder to open the input, short enough
// that a request for something that will never appear still answers.
const playlistWait = 4 * time.Second
// waitForFile blocks until path exists or the request is cancelled or the
// deadline passes. It returns either way: the caller still has to handle the
// file being absent.
func waitForFile(r *http.Request, path string, limit time.Duration) {
deadline := time.Now().Add(limit)
for {
if _, err := os.Stat(path); err == nil {
return
}
if time.Now().After(deadline) {
return
}
select {
case <-r.Context().Done():
return
case <-time.After(50 * time.Millisecond):
}
}
}

View file

@ -0,0 +1,234 @@
package media
import (
"database/sql"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/transcode"
)
// fakeSessions stands in for *transcode.Manager: it records Start calls and
// hands back a session rooted at a directory the test controls, with no
// hardware and no ffmpeg anywhere.
type fakeSessions struct {
dir string
startErr error
starts int
}
func (f *fakeSessions) Start(itemID int64, inputPath, cacheDir string) (transcode.Session, error) {
f.starts++
if f.startErr != nil {
return nil, f.startErr
}
return fakeSession{dir: f.dir}, nil
}
type fakeSession struct{ dir string }
func (s fakeSession) PlaylistPath() string { return filepath.Join(s.dir, "playlist.m3u8") }
func (s fakeSession) Dir() string { return s.dir }
func (s fakeSession) Close() error { return nil }
// transcodeFixture is a readable video item, an item in a library the caller may
// not read, and a granted user.
type transcodeFixture struct {
h *Handlers
db *sql.DB
base string
fake *fakeSessions
user int64
item int64
forbidden int64
}
func newTranscodeFixture(t *testing.T) transcodeFixture {
t.Helper()
db := testDB(t)
lib := newLibrary(t, db, "Video", "video", t.TempDir())
other := newLibrary(t, db, "Other", "video", t.TempDir())
// The session dir sits two levels below base, so "../../etc/passwd" from it
// resolves to base/etc/passwd — a file a traversal test can actually create.
base := t.TempDir()
dir := filepath.Join(base, "transcode", "1-1")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir session: %v", err)
}
f := transcodeFixture{db: db, base: base, fake: &fakeSessions{dir: dir}}
f.user = newUser(t, db, "watcher")
grantRead(t, db, f.user, lib)
const at = "2026-01-01T00:00:00.000Z"
f.item = newItem(t, db, lib, "video", "movie.mkv", "Movie", "movie", false, at)
f.forbidden = newItem(t, db, other, "video", "secret.mkv", "Secret", "secret", false, at)
f.h = mediaHandlers(t, db)
f.h.sessions = f.fake
return f
}
func getPlaylist(t *testing.T, h *Handlers, u auth.User, itemID int64) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/transcode/playlist.m3u8", nil)
req.SetPathValue("id", itoa(itemID))
rec := httptest.NewRecorder()
h.handleTranscodePlaylist(rec, req, u)
return rec
}
func getSegment(t *testing.T, h *Handlers, u auth.User, itemID int64, segment string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v1/items/1/transcode/"+segment, nil)
req.SetPathValue("id", itoa(itemID))
req.SetPathValue("segment", segment)
rec := httptest.NewRecorder()
h.handleTranscodeSegment(rec, req, u)
return rec
}
// TestTranscodePlaylistStartsAndServes pins the lazy start: the request begins
// the session and returns the playlist bytes exactly as they are on disk.
func TestTranscodePlaylistStartsAndServes(t *testing.T) {
f := newTranscodeFixture(t)
const playlist = "#EXTM3U\n#EXT-X-VERSION:3\n#EXTINF:6.0,\nseg00001.ts\n"
if err := os.WriteFile(filepath.Join(f.fake.dir, "playlist.m3u8"), []byte(playlist), 0o640); err != nil {
t.Fatalf("write playlist: %v", err)
}
rec := getPlaylist(t, f.h, auth.User{ID: f.user}, f.item)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" {
t.Errorf("content-type = %q, want application/vnd.apple.mpegurl", ct)
}
if got := rec.Body.String(); got != playlist {
t.Errorf("body = %q, want %q", got, playlist)
}
if f.fake.starts != 1 {
t.Errorf("Start calls = %d, want 1", f.fake.starts)
}
// A second request touches the session again, so the idle reaper leaves a
// stream being watched alone.
_ = getPlaylist(t, f.h, auth.User{ID: f.user}, f.item)
if f.fake.starts != 2 {
t.Errorf("Start calls after second request = %d, want 2", f.fake.starts)
}
}
// TestTranscodeSegmentServedAndTraversalRefused pins the segment name check: the
// exact shape ffmpeg writes is served, and everything else — a traversal above
// all — is a 404.
func TestTranscodeSegmentServedAndTraversalRefused(t *testing.T) {
f := newTranscodeFixture(t)
const segment = "\x47@\x00\x10payload"
if err := os.WriteFile(filepath.Join(f.fake.dir, "seg00001.ts"), []byte(segment), 0o640); err != nil {
t.Fatalf("write segment: %v", err)
}
rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, "seg00001.ts")
if rec.Code != http.StatusOK {
t.Fatalf("valid segment status = %d, body=%s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "video/mp2t" {
t.Errorf("content-type = %q, want video/mp2t", ct)
}
if got := rec.Body.String(); got != segment {
t.Errorf("body = %q, want %q", got, segment)
}
for _, name := range []string{
"../../etc/passwd",
"..%2f..%2fetc%2fpasswd",
"seg1.ts",
"seg0001.ts",
"seg000001.ts",
"seg00001.ts.bak",
"playlist.m3u8",
"",
} {
if rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, name); rec.Code != http.StatusNotFound {
t.Errorf("segment %q status = %d, want 404", name, rec.Code)
}
}
}
// TestTranscodeSegmentTraversalCannotEscapeOnce pins the traversal case with a
// file that genuinely exists at the joined path: without the name check the
// handler would serve it, so a green run proves the check is load-bearing.
func TestTranscodeSegmentTraversalCannotEscapeOnce(t *testing.T) {
f := newTranscodeFixture(t)
if err := os.MkdirAll(filepath.Join(f.base, "etc"), 0o755); err != nil {
t.Fatalf("mkdir etc: %v", err)
}
if err := os.WriteFile(filepath.Join(f.base, "etc", "passwd"), []byte("SECRET"), 0o640); err != nil {
t.Fatalf("write passwd: %v", err)
}
rec := getSegment(t, f.h, auth.User{ID: f.user}, f.item, "../../etc/passwd")
if rec.Code != http.StatusNotFound {
t.Fatalf("traversal status = %d, want 404 (body=%s)", rec.Code, rec.Body.String())
}
if rec.Body.String() == "SECRET" {
t.Fatal("traversal served a file outside the session directory")
}
}
// TestTranscodeRoutesAreGrantScoped pins the access chokepoint on both routes:
// an item in a library the caller cannot read answers with the same 404, byte
// for byte, as an item that does not exist — and does not start an encoder.
func TestTranscodeRoutesAreGrantScoped(t *testing.T) {
f := newTranscodeFixture(t)
u := auth.User{ID: f.user}
fb := getPlaylist(t, f.h, u, f.forbidden)
ab := getPlaylist(t, f.h, u, 999999)
if fb.Code != http.StatusNotFound || ab.Code != http.StatusNotFound {
t.Fatalf("playlist statuses = %d/%d, want 404/404", fb.Code, ab.Code)
}
if fb.Body.String() != ab.Body.String() {
t.Errorf("playlist forbidden body %q != absent body %q", fb.Body.String(), ab.Body.String())
}
fs := getSegment(t, f.h, u, f.forbidden, "seg00001.ts")
as := getSegment(t, f.h, u, 999999, "seg00001.ts")
if fs.Code != http.StatusNotFound || as.Code != http.StatusNotFound {
t.Fatalf("segment statuses = %d/%d, want 404/404", fs.Code, as.Code)
}
if fs.Body.String() != as.Body.String() {
t.Errorf("segment forbidden body %q != absent body %q", fs.Body.String(), as.Body.String())
}
// The item is rejected before Start, so a forbidden request never pins the
// GPU.
if f.fake.starts != 0 {
t.Errorf("Start calls on forbidden requests = %d, want 0", f.fake.starts)
}
none := getPlaylist(t, f.h, auth.User{ID: newUser(t, f.db, "nobody")}, f.item)
if none.Code != http.StatusNotFound {
t.Errorf("no-grant status = %d, want 404", none.Code)
}
}
// TestTranscodeRefusalsAreNot500 pins that a host with no encoder and a full
// server both get a clear 503 rather than a 500.
func TestTranscodeRefusalsAreNot500(t *testing.T) {
for _, err := range []error{transcode.ErrUnavailable, transcode.ErrAtCapacity, transcode.ErrShutdown} {
f := newTranscodeFixture(t)
f.fake.startErr = err
rec := getPlaylist(t, f.h, auth.User{ID: f.user}, f.item)
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("start error %v status = %d, want 503", err, rec.Code)
}
}
}