Found by walking every handler and the UI with odd inputs, then a battery of requests against the live hub. Fixed: - A save over the 8 MiB cap was cut short in place: the file was truncated, then the first 8 MiB written, and the caller told 204. Now the body is read whole first and a body over the cap is a 413 with the file untouched. - A link to a file showed the link's own time; now the file's, so the row reads right and the preview key follows the file. - Reading a link to nothing answered 200 with an empty body (the open failed after the headers in root mode); now a 404 that names the target. - A play request with an unknown codec or size was answered by inspecting the file first, so an audio-only file said "no video stream" instead of what was wrong; the choice is checked before any work, and is a 400. - A stream that ended in failure was handed back to the next identical request for ten minutes; a failed one is now dropped and a fresh one made. - A player that left while its stream was still finding a first segment made the loop try the next recipe; it stops. A stream closed mid-start is not started again. - Ending a root-mode ffmpeg could wait forever: the daemon cannot signal root's processes, so the wait is bounded and the helper's own grace does the rest. - A page going away (a phone closing the tab) releases its stream by a beacon, rather than ten minutes later by the sweeper. - The path box resolves like a shell — relative to the folder shown, . and .. applied, trailing slashes dropped — in the tab and in the Move picker, with a test. A typed path that is a file opens it; a remembered folder that is gone falls back to / once. - A dropped folder arrived as a nameless empty file; it is refused with a word. The upload toast counts what was actually uploaded. - The viewer names a device, pipe or socket, and a dangling link, instead of calling them binary files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
170 lines
4.8 KiB
Go
170 lines
4.8 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 := 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.
|
|
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)
|
|
}
|