Files
hamlab/internal/api/play.go
T
Mitchell-JohnsonandClaude Fable 5.1 f3c547adfa Files: re-encode a video the browser can't play, on the machine's own GPU, and play that
The viewer tries the file as it is first. When the browser gives up — Safari and an MKV,
HEVC without a hardware decoder — or on a tap, a picker: codec (H.264, HEVC, AV1, or the
original codec repackaged) and size (original, 1080p, 720p, 480p), each codec labelled
with the engine this machine would use. POST /api/files/play runs ffmpeg into an HLS
event playlist of fragmented-MP4 segments under <data dir>/transcode/<id>/; Safari plays
HLS itself and everything else goes through hls.js, loaded only then. The recipes are
tried best first: a card on the bus before the integrated GPU, decoding on the same card
before decoding on the CPU and uploading, the software encoders last; a recipe that
produces no first segment is dropped for the next. The seekable range grows as ffmpeg
gets ahead; status says how far. Streams are reused for the same request, closed when
the player goes, swept after ten idle minutes, and the folder is emptied at start. Root
mode runs ffmpeg as root through a helper that ends when the daemon lets go of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 17:42:25 -07:00

163 lines
4.4 KiB
Go

package api
// Playing a video the browser cannot play as it is: POST /api/files/play starts an HLS
// re-encode on this machine (internal/media), GET …/play/{id}/index.m3u8 and the segments
// feed the player, GET …/play/{id} is its progress, DELETE ends it. /api/files/play-caps
// says which codecs this machine can make and on what. Root mode (?sudo=1) reads the file
// and runs ffmpeg as root, through the same sudo the file reads use.
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"path/filepath"
"strings"
"time"
"hamlab/internal/files"
"hamlab/internal/media"
)
const playLimit = 3 // re-encodes at once on one machine; a GPU does two or three, a CPU one
type playCaps struct {
Codecs []playCodec `json:"codecs"`
Qualities []media.Quality `json:"qualities"`
Error string `json:"error,omitempty"`
}
type playCodec struct {
media.Codec
Engine string `json:"engine,omitempty"` // "" when nothing here encodes it
}
// playCapsHandler: GET /api/files/play-caps[?sudo=1]
func (s *Server) playCapsHandler(w http.ResponseWriter, r *http.Request) {
root := r.URL.Query().Get("sudo") == "1"
if root && s.fsSudo == nil {
jsonErr(w, 403, "root access needs passwordless sudo on the host")
return
}
caps, err := s.thumbs.capsFor(r.Context(), root, false)
pc := playCaps{Codecs: []playCodec{}, Qualities: media.Qualities}
if err != nil {
pc.Error = err.Error()
} else if caps.Error != "" {
pc.Error = caps.Error
}
for _, c := range media.Codecs {
x := playCodec{Codec: c}
if pc.Error == "" {
x.Engine = media.EngineFor(caps, c.ID)
}
pc.Codecs = append(pc.Codecs, x)
}
jsonOK(w, 200, pc)
}
// playStart: POST /api/files/play[?sudo=1] {path, codec, quality}
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
root := r.URL.Query().Get("sudo") == "1"
var body struct{ Path, Codec, Quality string }
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
p, err := files.Clean(body.Path)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if media.Kind(p) != "video" {
jsonErr(w, 415, "not a video")
return
}
if _, err := fsys.Stat(r.Context(), p); err != nil {
fsErr(w, err)
return
}
caps, err := s.thumbs.capsFor(r.Context(), root, false)
if err != nil {
jsonErr(w, 503, err.Error())
return
}
if caps.Error != "" {
jsonErr(w, 503, caps.Error)
return
}
ictx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
src, err := media.Inspect(ictx, caps, p, root)
cancel()
if err != nil {
playErr(w, err)
return
}
st, err := s.plays.Open(r.Context(), caps, root, p, body.Codec, body.Quality, src)
if err != nil {
playErr(w, err)
return
}
log.Printf("files: play %s as %s %s on %s (from %s)", p, st.Codec, st.Quality, st.Engine, clientIP(r))
jsonOK(w, 200, map[string]any{"stream": st, "url": "/api/files/play/" + st.ID + "/index.m3u8"})
}
func playErr(w http.ResponseWriter, err error) {
var de *media.DecodeError
switch {
case errors.As(err, &de):
jsonErr(w, 422, de.Msg)
case strings.Contains(err.Error(), "already being re-encoded"):
jsonErr(w, 429, err.Error())
case errors.Is(err, context.DeadlineExceeded):
jsonErr(w, 504, "the file took too long to read")
default:
jsonErr(w, 500, err.Error())
}
}
// playStatus: GET /api/files/play/{id}
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
st, ok := s.plays.Find(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such stream")
return
}
jsonOK(w, 200, st.Status())
}
// playStop: DELETE /api/files/play/{id}
func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
s.plays.Close(r.PathValue("id"))
w.WriteHeader(204)
}
// playFile: GET /api/files/play/{id}/{file} — the playlist, the init segment, a segment.
func (s *Server) playFile(w http.ResponseWriter, r *http.Request) {
st, ok := s.plays.Find(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such stream")
return
}
p, ok := st.File(r.PathValue("file"))
if !ok {
jsonErr(w, 404, "not a stream file")
return
}
switch filepath.Ext(p) {
case ".m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
default:
w.Header().Set("Content-Type", "video/mp4")
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeFile(w, r, p)
}