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:
co-authored by
Claude Opus 5.5
parent
f5f1cd79ba
commit
fc4126a0ed
+463
-126
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user