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 := media.ValidChoice(body.Codec, body.Quality); err != nil { jsonErr(w, 400, err.Error()) 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 strings.HasPrefix(err.Error(), "unknown ") || strings.HasPrefix(err.Error(), "nothing on this machine encodes"): jsonErr(w, 400, 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}, or POST …/close — the form a page can send on its // way out (navigator.sendBeacon), so a phone closing the tab releases the machine at once. 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. // A seekable stream's playlist is written whole, up front; a segment of it that isn't on disk // yet is waited for, or re-encoded from there if the encode isn't heading that way (a seek). 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 } name := r.PathValue("file") p, ok := st.File(name) if !ok { jsonErr(w, 404, "not a stream file") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") if st.Seekable { if name == "index.m3u8" { w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") _, _ = w.Write(st.Playlist()) return } if i, isSeg := media.SegmentIndex(name); isSeg { sp, err := st.Segment(r.Context(), i) switch { case errors.Is(err, media.ErrNotInFilm): jsonErr(w, 404, err.Error()) return case r.Context().Err() != nil: return // the player moved on case err != nil: jsonErr(w, 503, err.Error()) return } p = sp } } switch filepath.Ext(p) { case ".m3u8": w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") default: w.Header().Set("Content-Type", "video/mp4") } http.ServeFile(w, r, p) }