Vellum/internal/media/transcodes.go
mithral 5ea7ffe6c6 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.
2026-10-12 12:00:20 -04:00

162 lines
5.6 KiB
Go

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):
}
}
}