files: Ham Share's player — Auto quality, any by hand, or the file; 360 view; Transcode device setting

Every video plays YouTube's way: hls.js reads the connection and picks the
quality, or one is picked by hand, or Original plays the file as it is. The
qualities (a ladder by size, H.264) are made on the fly in 3 s pieces from
wherever the viewer seeks, one ffmpeg per play, paused when far enough ahead.
360 videos open in a look-around view (drag, pinch or scroll to zoom from
8 to 300 degrees, phone motion).

Settings -> Transcode device picks the machine and device that makes them,
for every machine's files: a machine transcoding another's file reads it
through the hub by a token for that one file. Replaces the codec/size picker
and the single-quality re-encoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-23 14:52:35 -07:00
co-authored by Claude Opus 5.5
parent f5f1cd79ba
commit fc4126a0ed
19 changed files with 2218 additions and 1500 deletions
+463 -126
View File
@@ -1,141 +1,162 @@
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.
// Playing a video in the Files viewer, YouTube's way: Auto (the player reads the connection),
// any quality by hand, or the file itself. The qualities are made on the fly as HLS by
// internal/media, on the device the Transcode device setting names — which may be on another
// machine than the file.
//
// On every machine:
// POST /api/files/play[?sudo=1] {path | url, name, device} open a play
// GET /api/files/play/{id}/master.m3u8, /{rung}.m3u8, /{rung}/{n}.ts
// GET /api/files/play/{id} what it is doing
// DELETE /api/files/play/{id} (or POST …/close, for a beacon) end it
// GET /api/files/play-devices what it can encode on
// On the hub:
// POST /api/play {node, path, sudo} open a play of a file on node, wherever the setting
// says, answering which machine the play is on
// GET|PUT /api/transcode the setting, and every machine's devices
// GET /api/play-src/{token} a file, for a machine transcoding it, by a token
// that names that one file; ranges pass through
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/files"
"hamlab/internal/media"
"hamlab/internal/store"
)
const playLimit = 3 // re-encodes at once on one machine; a GPU does two or three, a CPU one
// playRuns is how many plays may be encoding at once on one machine; a fourth ends the one
// asked of least lately. The A380 makes about two 8K-sourced streams in real time.
const playRuns = 3
type playCaps struct {
Codecs []playCodec `json:"codecs"`
Qualities []media.Quality `json:"qualities"`
Error string `json:"error,omitempty"`
type playReq struct {
Path string `json:"path,omitempty"`
URL string `json:"url,omitempty"` // a file on another machine, as the hub serves it
Name string `json:"name,omitempty"`
Device string `json:"device,omitempty"`
}
type playCodec struct {
media.Codec
Engine string `json:"engine,omitempty"` // "" when nothing here encodes it
type playOut struct {
Node string `json:"node"` // the machine the play is on: "" for the hub; filled in by the hub
Play *media.Play `json:"play"`
Master string `json:"master"` // the playlist, under that machine's /api
Engine string `json:"engine"`
Host string `json:"host"` // that machine's name, for the menu
Note string `json:"note,omitempty"`
Status media.PlayStatus `json:"status"`
}
// playCapsHandler: GET /api/files/play-caps[?sudo=1]
func (s *Server) playCapsHandler(w http.ResponseWriter, r *http.Request) {
// playDevices: GET /api/files/play-devices — what this machine could make the player's H.264
// on, probed as the daemon's own user (a play from another machine never runs as root).
func (s *Server) playDevices(w http.ResponseWriter, r *http.Request) {
caps, err := s.thumbs.capsFor(r.Context(), false, false)
if err != nil {
caps.Error = err.Error()
}
jsonOK(w, 200, map[string]any{"host": s.hostName(), "devices": media.Devices(caps)})
}
// playStart: POST /api/files/play[?sudo=1] — open a play of a file here, or of a URL the hub
// serves a file on another machine at.
func (s *Server) playStart(w http.ResponseWriter, r *http.Request) {
var body playReq
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
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}
out, code, err := s.openPlay(r.Context(), body, root)
if err != nil {
pc.Error = err.Error()
} else if caps.Error != "" {
pc.Error = caps.Error
jsonErr(w, code, err.Error())
return
}
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)
jsonOK(w, 200, out)
}
// 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
func (s *Server) openPlay(ctx context.Context, body playReq, root bool) (playOut, int, error) {
if root && s.fsSudo == nil && body.URL == "" {
return playOut{}, 403, errors.New("root access needs passwordless sudo on the host")
}
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
in, name := body.Path, body.Name
if body.URL != "" {
u, err := url.Parse(body.URL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || body.Path != "" {
return playOut{}, 400, errors.New("a play is of a path or of an http URL")
}
in, root = body.URL, false
} else {
p, err := files.Clean(body.Path)
if err != nil {
return playOut{}, 400, err
}
if media.Kind(p) != "video" {
return playOut{}, 415, errors.New("not a video")
}
fsys := s.fsLocal
if root {
fsys = s.fsSudo
}
if _, err := fsys.Stat(ctx, p); err != nil {
return playOut{}, 404, err
}
in = p
if name == "" {
name = filepath.Base(p)
}
}
caps, err := s.thumbs.capsFor(ctx, root, false)
if err == nil && caps.Error != "" {
err = errors.New(caps.Error)
}
p, err := files.Clean(body.Path)
if err != nil {
jsonErr(w, 400, err.Error())
return
return playOut{}, 503, err
}
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)
ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
src, err := media.Inspect(ictx, caps, in, root)
cancel()
if err != nil {
playErr(w, err)
return
var de *media.DecodeError
if errors.As(err, &de) {
return playOut{}, 422, errors.New(de.Msg)
}
return playOut{}, 504, err
}
st, err := s.plays.Open(r.Context(), caps, root, p, body.Codec, body.Quality, src)
p, err := s.plays.Open(caps, root, in, name, src, body.Device)
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())
return playOut{}, 422, err
}
log.Printf("files: play %s on %s (%d qualities, %s)", name, p.Engine(), len(p.Rungs), map[bool]string{true: "from another machine", false: "here"}[p.Remote])
return playOut{Play: p, Master: "/api/files/play/" + p.ID + "/master.m3u8", Engine: p.Engine(), Host: s.hostName(), Status: p.Status()}, 200, nil
}
// playStatus: GET /api/files/play/{id}
func (s *Server) playStatus(w http.ResponseWriter, r *http.Request) {
st, ok := s.plays.Find(r.PathValue("id"))
p, ok := s.plays.Find(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such stream")
jsonErr(w, 404, "no such play")
return
}
jsonOK(w, 200, st.Status())
jsonOK(w, 200, p.Status())
}
// playStop: DELETE /api/files/play/{id}, or POST …/close — the form a page can send on its
@@ -145,49 +166,365 @@ func (s *Server) playStop(w http.ResponseWriter, r *http.Request) {
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"))
// playPlaylist: GET /api/files/play/{id}/{file} — master.m3u8, or one quality's <name>.m3u8.
func (s *Server) playPlaylist(w http.ResponseWriter, r *http.Request) {
p, ok := s.plays.Find(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such stream")
jsonErr(w, 404, "no such play")
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())
f := r.PathValue("file")
var b []byte
if f == "master.m3u8" {
b = p.Master()
} else if name, isList := strings.CutSuffix(f, ".m3u8"); isList {
if b, ok = p.Media(name); !ok {
jsonErr(w, 404, "no such quality")
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
} else {
jsonErr(w, 404, "not a play file")
return
}
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
}
// playPiece: GET /api/files/play/{id}/{rung}/{seg} — a piece, made first if need be.
func (s *Server) playPiece(w http.ResponseWriter, r *http.Request) {
p, ok := s.plays.Find(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such play")
return
}
ns, isTS := strings.CutSuffix(r.PathValue("seg"), ".ts")
n, err := strconv.Atoi(ns)
if !isTS || err != nil || strconv.Itoa(n) != ns {
jsonErr(w, 404, "not a piece")
return
}
file, err := p.Segment(r.Context().Done(), r.PathValue("rung"), n)
switch {
case errors.Is(err, media.ErrNotFound):
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
}
w.Header().Set("Content-Type", "video/mp2t")
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeFile(w, r, file)
}
// ---- on the hub: where plays are made ----
// transcodeChoice is the setting; nil is the machine the file is on.
func (s *Server) transcodeChoice() *store.Transcode {
var t *store.Transcode
s.store.View(func(st *store.State) {
if st.Transcode != nil {
c := *st.Transcode
t = &c
}
})
return t
}
type transcodeMachine struct {
Node string `json:"node"` // "" is the hub
Host string `json:"host"`
Devices []media.Device `json:"devices"`
Error string `json:"error,omitempty"` // the machine didn't answer
}
// getTranscode: GET /api/transcode — the setting, and what every machine can encode on,
// asked of each now.
func (s *Server) getTranscode(w http.ResponseWriter, r *http.Request) {
names := []string{""}
if s.nodes != nil {
names = append(names, s.nodes.Names()...)
}
out := make([]transcodeMachine, len(names))
var wg sync.WaitGroup
for i, n := range names {
wg.Add(1)
go func() {
defer wg.Done()
m := transcodeMachine{Node: n, Host: n}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
if n == "" {
caps, err := s.thumbs.capsFor(ctx, false, false)
if err != nil {
caps.Error = err.Error()
}
m.Host, m.Devices = s.hostName(), media.Devices(caps)
} else {
code, b, err := s.nodes.Do(ctx, n, "GET", "/api/files/play-devices", nil, nil)
var d struct {
Host string `json:"host"`
Devices []media.Device `json:"devices"`
}
switch {
case err != nil:
m.Error = err.Error()
case code == 404:
m.Error = "runs an older Ham Lab"
case code != 200 || json.Unmarshal(b, &d) != nil:
m.Error = fmt.Sprintf("answered %d", code)
default:
m.Devices = d.Devices
}
}
p = sp
out[i] = m
}()
}
wg.Wait()
jsonOK(w, 200, map[string]any{"selected": s.transcodeChoice(), "machines": out})
}
// putTranscode: PUT /api/transcode {node, device}, or {} for the machine the file is on.
func (s *Server) putTranscode(w http.ResponseWriter, r *http.Request) {
var body *store.Transcode
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "expected {\"node\": …, \"device\": …}")
return
}
if body != nil && body.Device == "" {
body = nil
}
if body != nil {
known := body.Node == ""
if s.nodes != nil {
for _, n := range s.nodes.Names() {
known = known || n == body.Node
}
}
if !known {
jsonErr(w, 400, "no such machine")
return
}
if body.Device != media.CPU && !strings.HasPrefix(body.Device, "/dev/dri/") {
jsonErr(w, 400, "a device is a render node or \"cpu\"")
return
}
}
switch filepath.Ext(p) {
case ".m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
default:
w.Header().Set("Content-Type", "video/mp4")
if err := s.store.Update(func(st *store.State) { st.Transcode = body }); err != nil {
jsonErr(w, 500, err.Error())
return
}
http.ServeFile(w, r, p)
s.getTranscode(w, r)
}
// hubPlay: POST /api/play {node, path, sudo} — open a play of a file on node ("" the hub) on
// the device the setting names. When that is on another machine, that machine reads the file
// through this hub, by a token good for that one file. If it can't be reached, the file's own
// machine makes it instead, and the answer says so.
func (s *Server) hubPlay(w http.ResponseWriter, r *http.Request) {
var body struct {
Node string `json:"node"`
Path string `json:"path"`
Sudo bool `json:"sudo"`
}
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 body.Node != "" && (s.nodes == nil || !has(s.nodes.Names(), body.Node)) {
jsonErr(w, 404, "no such machine")
return
}
choice := s.transcodeChoice()
var note string
if choice != nil && choice.Node != body.Node {
if s.nodes != nil && choice.Node != "" && !has(s.nodes.Names(), choice.Node) {
note = "The transcode machine " + choice.Node + " is no longer managed here; made where the file is."
} else {
src, err := s.srcURL(choice.Node, body.Node, p, body.Sudo)
if err == nil {
req := playReq{URL: src, Name: filepath.Base(p), Device: choice.Device}
out, code, err := s.playOn(r.Context(), choice.Node, req, false)
if err == nil {
jsonOK(w, 200, out)
return
}
if code == 422 || code == 415 {
jsonErr(w, code, err.Error())
return
}
}
log.Printf("files: play on %s failed (%v); making it where the file is", nodeLabel(choice.Node), err)
note = "Couldn't make it on " + nodeLabel(choice.Node) + " (" + err.Error() + "), so it is made where the file is."
}
}
device := ""
if choice != nil && choice.Node == body.Node {
device = choice.Device
}
out, code, err := s.playOn(r.Context(), body.Node, playReq{Path: p, Device: device}, body.Sudo)
if err != nil {
jsonErr(w, code, err.Error())
return
}
out.Note = note
jsonOK(w, 200, out)
}
func nodeLabel(n string) string {
if n == "" {
return "the hub"
}
return n
}
func has(list []string, x string) bool {
for _, v := range list {
if v == x {
return true
}
}
return false
}
// playOn opens a play on a machine: here, or through that node's API.
func (s *Server) playOn(ctx context.Context, node string, req playReq, sudo bool) (playOut, int, error) {
if node == "" {
out, code, err := s.openPlay(ctx, req, sudo)
return out, code, err
}
b, _ := json.Marshal(req)
path := "/api/files/play"
if sudo {
path += "?sudo=1"
}
code, resp, err := s.nodes.Do(ctx, node, "POST", path, b, http.Header{"Content-Type": []string{"application/json"}})
if err != nil {
return playOut{}, 502, err
}
if code != 200 {
var e struct{ Error string }
_ = json.Unmarshal(resp, &e)
if e.Error == "" {
e.Error = fmt.Sprintf("%s answered %d", node, code)
}
return playOut{}, code, errors.New(e.Error)
}
var out playOut
if err := json.Unmarshal(resp, &out); err != nil {
return playOut{}, 502, err
}
out.Node = node
return out, 200, nil
}
// ---- the file, for the machine transcoding it ----
type srcGrant struct {
node, path string
sudo bool
until time.Time
}
type srcGrants struct {
mu sync.Mutex
m map[string]*srcGrant
}
const srcLife = 12 * time.Hour // from its last use: a film paused overnight still plays
var grants = &srcGrants{m: map[string]*srcGrant{}}
func (g *srcGrants) mint(node, path string, sudo bool) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
tok := hex.EncodeToString(b)
now := time.Now()
g.mu.Lock()
defer g.mu.Unlock()
for k, v := range g.m {
if now.After(v.until) {
delete(g.m, k)
}
}
g.m[tok] = &srcGrant{node: node, path: path, sudo: sudo, until: now.Add(srcLife)}
return tok
}
func (g *srcGrants) use(tok string) (srcGrant, bool) {
g.mu.Lock()
defer g.mu.Unlock()
v, ok := g.m[tok]
if !ok || time.Now().After(v.until) {
return srcGrant{}, false
}
v.until = time.Now().Add(srcLife)
return *v, true
}
// srcURL is where the machine `to` can read a file on machine `from` through this hub: this
// hub's own address as `to` reaches it, and a token for that one file.
func (s *Server) srcURL(to, from, path string, sudo bool) (string, error) {
host, port, err := net.SplitHostPort(s.cfg.Listen)
if err != nil {
return "", err
}
ip := net.ParseIP(host)
if host == "" || ip == nil || ip.IsUnspecified() || ip.IsLoopback() {
// Listening everywhere: the address this machine reaches `to` from is one it answers on.
target := "127.0.0.1:1"
if to != "" {
if u, err := url.Parse(s.nodes.URL(to)); err == nil && u.Host != "" {
target = u.Host
}
}
c, err := net.Dial("udp", target)
if err != nil {
return "", err
}
host = c.LocalAddr().(*net.UDPAddr).IP.String()
c.Close()
}
scheme := "http"
if s.fingerprint != "" {
scheme = "https"
}
return scheme + "://" + net.JoinHostPort(host, port) + "/api/play-src/" + grants.mint(from, path, sudo), nil
}
// playSrc: GET /api/play-src/{token} — the one file a token names, ranges and all, read from
// its machine. The token is the only key: it is minted for a machine transcoding that file.
func (s *Server) playSrc(w http.ResponseWriter, r *http.Request) {
g, ok := grants.use(r.PathValue("token"))
if !ok {
jsonErr(w, 404, "no such file")
return
}
q := url.Values{"path": {g.path}}
if g.sudo {
q.Set("sudo", "1")
}
r2 := r.Clone(r.Context())
r2.URL.RawQuery = q.Encode()
r2.Header.Del("Cookie")
if g.node == "" {
r2.URL.Path = "/api/files/read"
s.filesRead(w, r2)
return
}
p, ok := s.nodes.Proxy(g.node)
if !ok {
jsonErr(w, 404, "no such machine")
return
}
r2.URL.Path = "/api/nodes/" + g.node + "/files/read"
p.ServeHTTP(w, r2)
}