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 .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 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) }