The setting's device makes every video preview: on the file's own machine when it is there (the hub tells it by header), else on the device's machine, which reads the file through the hub by a one-file token, and the preview is kept in the file's machine's own cache (PUT /api/files/thumb, refused if the file changed). Images and covers stay on the file's machine's CPU. If the device's machine can't, the file's makes it as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
534 lines
16 KiB
Go
534 lines
16 KiB
Go
package api
|
|
|
|
// 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"
|
|
)
|
|
|
|
// 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 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 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
out, code, err := s.openPlay(r.Context(), body, root)
|
|
if err != nil {
|
|
jsonErr(w, code, err.Error())
|
|
return
|
|
}
|
|
jsonOK(w, 200, out)
|
|
}
|
|
|
|
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")
|
|
}
|
|
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)
|
|
}
|
|
if err != nil {
|
|
return playOut{}, 503, err
|
|
}
|
|
ictx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
src, err := media.Inspect(ictx, caps, in, root)
|
|
cancel()
|
|
if err != nil {
|
|
var de *media.DecodeError
|
|
if errors.As(err, &de) {
|
|
return playOut{}, 422, errors.New(de.Msg)
|
|
}
|
|
return playOut{}, 504, err
|
|
}
|
|
p, err := s.plays.Open(caps, root, in, name, src, body.Device)
|
|
if err != nil {
|
|
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) {
|
|
p, ok := s.plays.Find(r.PathValue("id"))
|
|
if !ok {
|
|
jsonErr(w, 404, "no such play")
|
|
return
|
|
}
|
|
jsonOK(w, 200, p.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)
|
|
}
|
|
|
|
// 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 play")
|
|
return
|
|
}
|
|
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
|
|
}
|
|
} 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
|
|
if s.store == nil {
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if err := s.store.Update(func(st *store.State) { st.Transcode = body }); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
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)
|
|
}
|