I got this wrong first time. "Head office cannot show live video cheaply" conflated TRUE VIDEO with SEEING THE CAMERA NOW, and only the first needs WebRTC and a TURN server. The shop PC is behind a router with no inbound route, so head office cannot pull the engine's MJPEG. It can answer the agent's outbound requests, which is the shape of everything else here: the server holds a poll open, the agent asks "is anyone watching?", and pushes JPEGs up for exactly as long as somebody is. Measured on the office camera: 98 KB full frame, 20.8 KB re-encoded at 640/q60, so one watcher costs ~83 KB/s. 47 frames arrived in 12 seconds - 4 fps, as configured. The UI says "about 4 frames a second" rather than letting anyone conclude the camera stutters. Nothing is uploaded when nobody is looking, which is the whole cost argument: Publish returns false once the last viewer goes, interest lapses on a timer each viewer refreshes as it reads (so a closed tab stops the upload within seconds), one push is capped at five minutes, and the UI streams one camera at a time. LiveHub is deliberately the opposite of the arrivals Hub. There a doorbell pushes nothing because nothing may be lost; here a dropped frame is the correct outcome, so each viewer has a one-slot buffer that is overwritten - the only frame worth having is the newest, and a queue would show an ever-growing delay behind the shop instead of dropping back to live. Ownership is proved once, before anything streams: the relay is keyed on a camera id, a hub does not know whose camera it holds, and a camera id is not a secret. Verified: another tenant gets 404, no session gets 401, and an agent cannot push into another site's camera. Also fixes a bug I introduced with it - the Live button was gated on `connected`, which is head office's last report and up to two minutes stale, so it hid itself during every reconnect. "Is that camera really down?" is exactly when somebody wants to look, and a hidden control says "you cannot" where the honest answer is "here is why". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
174 lines
5.7 KiB
Go
174 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// liveMaxFrame bounds one frame. The agent re-encodes to ~640 px before
|
|
// sending, so a frame is tens of KB; 1 MB is a ceiling, not a target.
|
|
liveMaxFrame = 1 << 20
|
|
// liveSession caps one push. A browser tab left open for a week must not
|
|
// leave a shop uploading for a week; the agent simply asks again while
|
|
// anyone is still watching, so the cap costs a reconnect, not the stream.
|
|
liveSession = 5 * time.Minute
|
|
// liveWaitForWork is how long the agent's poll is held open. Long enough
|
|
// that an idle site makes ~2 requests a minute; short enough to sit well
|
|
// inside any proxy's idle timeout.
|
|
liveWaitForWork = 25 * time.Second
|
|
)
|
|
|
|
// handleWatchLive streams one camera's frames to a signed-in user over SSE.
|
|
//
|
|
// SSE rather than serving MJPEG directly, for the same reason the snapshot is
|
|
// not a signed link: an <img> cannot send an Authorization header, and minting
|
|
// a URL that works without a session - for LIVE video of a shop floor, no less
|
|
// - would be a much worse trade than the 33% base64 costs.
|
|
func (s *Server) handleWatchLive(w http.ResponseWriter, r *http.Request) {
|
|
p := PrincipalFrom(r.Context())
|
|
id := r.PathValue("id")
|
|
if !looksLikeUUID(id) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
|
return
|
|
}
|
|
// Ownership is checked HERE, once, before anything is streamed. Everything
|
|
// after this point is keyed on a camera id, and a hub does not know whose
|
|
// camera it is holding.
|
|
if _, _, err := s.Store.CameraRef(r.Context(), p.ClientID, id); err != nil {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
|
return
|
|
}
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
s.serverError(w, "live", errors.New("this server cannot stream"))
|
|
return
|
|
}
|
|
|
|
frames, release := s.live().Watch(id)
|
|
defer release()
|
|
|
|
h := w.Header()
|
|
h.Set("Content-Type", "text/event-stream")
|
|
h.Set("Cache-Control", "no-store")
|
|
h.Set("Connection", "keep-alive")
|
|
// Without this a proxy buffers the stream into one response that arrives
|
|
// when the connection closes - which for live video means never.
|
|
h.Set("X-Accel-Buffering", "no")
|
|
w.WriteHeader(http.StatusOK)
|
|
// Told up front, so a viewer can say "waiting for the shop PC" rather than
|
|
// showing an empty box while the agent is still being asked.
|
|
fmt.Fprint(w, "event: waiting\ndata: {}\n\n")
|
|
flusher.Flush()
|
|
|
|
ctx := r.Context()
|
|
// Refreshed as we go rather than once at the start: this is what tells the
|
|
// agent somebody is still there, and a viewer that has gone away stops a
|
|
// shop uploading within seconds without having to announce anything.
|
|
keep := time.NewTicker(liveIdle / 3)
|
|
defer keep.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-keep.C:
|
|
s.live().Keep(id)
|
|
case frame, ok := <-frames:
|
|
if !ok {
|
|
return
|
|
}
|
|
s.live().Keep(id)
|
|
if _, err := fmt.Fprintf(w, "event: frame\ndata: %s\n\n",
|
|
base64.StdEncoding.EncodeToString(frame)); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAgentLiveWanted is the shop PC asking whether anyone is watching.
|
|
//
|
|
// Held open rather than answered immediately: an agent polling every few
|
|
// seconds would put a floor under how quickly a live view can start, and one
|
|
// polling slowly would put a ceiling on it. Holding the request means pressing
|
|
// "Live" reaches the shop PC at once, and an idle site costs about two requests
|
|
// a minute.
|
|
func (s *Server) handleAgentLiveWanted(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
|
ids, err := s.Store.SiteCameraIDs(r.Context(), ap.SiteID)
|
|
if err != nil {
|
|
s.serverError(w, "live wanted", err)
|
|
return
|
|
}
|
|
if wanted := s.live().WantedAmong(ids); len(wanted) > 0 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"cameras": wanted})
|
|
return
|
|
}
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-s.live().Bell(ids):
|
|
case <-time.After(liveWaitForWork):
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"cameras": s.live().WantedAmong(ids)})
|
|
}
|
|
|
|
// handleAgentPushLive receives frames for as long as somebody is watching.
|
|
//
|
|
// One request carrying many frames, each prefixed with its length, rather than
|
|
// a request per frame: at a few frames a second the per-request overhead and
|
|
// the TLS handshakes would cost more than the pictures.
|
|
func (s *Server) handleAgentPushLive(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) {
|
|
id := r.PathValue("camera")
|
|
if !looksLikeUUID(id) {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
|
return
|
|
}
|
|
// The camera must belong to the AGENT's own site. Without this an agent
|
|
// could push its own pictures into another site's live view - a camera id
|
|
// is not a secret, and the agent supplies this one.
|
|
siteID, _, err := s.Store.CameraRefBySite(r.Context(), ap.SiteID, id)
|
|
if err != nil || siteID != ap.SiteID {
|
|
writeErr(w, http.StatusNotFound, "not_found", "No such camera.")
|
|
return
|
|
}
|
|
|
|
deadline := time.Now().Add(liveSession)
|
|
body := r.Body
|
|
var header [4]byte
|
|
frames := 0
|
|
for {
|
|
if time.Now().After(deadline) {
|
|
break
|
|
}
|
|
if _, err := io.ReadFull(body, header[:]); err != nil {
|
|
break
|
|
}
|
|
n := binary.BigEndian.Uint32(header[:])
|
|
if n == 0 || n > liveMaxFrame {
|
|
// A length this side cannot trust ends the stream rather than
|
|
// allocating what it was told to.
|
|
writeErr(w, http.StatusBadRequest, "bad_frame", "Frame size out of range.")
|
|
return
|
|
}
|
|
frame := make([]byte, n)
|
|
if _, err := io.ReadFull(body, frame); err != nil {
|
|
break
|
|
}
|
|
frames++
|
|
if !s.live().Publish(id, frame) {
|
|
// Nobody is watching any more. Saying so in the response is what
|
|
// stops the shop uploading; the agent goes back to waiting.
|
|
break
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"frames": frames})
|
|
}
|