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.
162 lines
5.6 KiB
Go
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):
|
|
}
|
|
}
|
|
}
|