Smoother daemon: no-op reloads, batched placement, muted audio, graceful teardown
- Skip stream restarts when a reload resolves to the identical wall (same URLs, tile geometry, player settings) — saving an unrelated config edit no longer blanks the screen. - Replace per-tile placement polling (a swaymsg fork per tile per second, plus 150ms get_tree polling per tile at startup) with one shared loop: a single get_tree snapshot per tick, re-placing only drifted windows, relaxing to a 2s tick once settled. - Mute streams by default (--no-audio) to skip an audio decoder per stream; opt back in with player.audio: true. - Graceful, parallel mpv teardown via cmd.Cancel/WaitDelay, fixing the double-Wait race between Stop and Supervise and cutting worst-case layout switches from ~2s x N streams to ~2s total. - status: probe mpv IPC outside the daemon mutex so a slow probe can't block layout switches. - View sync: reuse the Protect session across ticks (re-login only on expiry) and pick up view_refresh_seconds changes without a restart. - Health strikes keyed by player, not slot, so they never carry across layout switches; prune stale entries. - New `rtsp-streamer reload` CLI; fix dead sort in `layout ls`; back off on persistent control-socket accept errors; fsync config before the atomic rename (SD-card power-cut safety); bump IPC client deadline; gofmt stragglers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
README.md
13
README.md
@@ -143,9 +143,15 @@ rtsp-streamer daemon # normally launched by sway, not by hand
|
||||
rtsp-streamer status # slot health from the running daemon
|
||||
rtsp-streamer layout ls # list layouts (* marks active)
|
||||
rtsp-streamer layout set quad # switch live (persists the choice)
|
||||
rtsp-streamer reload # apply hand-edits to the config live
|
||||
rtsp-streamer version # baked-in git commit / build date
|
||||
```
|
||||
|
||||
Reloads (and TUI saves) are minimal-impact: the daemon compares the resolved
|
||||
wall — stream URLs, tile geometry, player settings — against what's already
|
||||
running and leaves the streams untouched when nothing material changed, so
|
||||
saving an unrelated edit never blanks the screen.
|
||||
|
||||
## UniFi Protect live views
|
||||
|
||||
Copy a saved Protect "Live View" (its cameras and grid) straight into a layout:
|
||||
@@ -251,6 +257,8 @@ pegged, work through:
|
||||
- **Latency drift** is handled by `--framedrop=decoder+vo` + low-delay demuxer
|
||||
flags (built in), so a briefly-behind stream drops frames to catch up instead
|
||||
of accumulating a backlog. Capping `player.max_fps` trims render load too.
|
||||
- **Audio is off by default** (`--no-audio`), which skips one audio decoder per
|
||||
stream. Set `player.audio: true` if you actually want camera sound.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -293,13 +301,12 @@ pegged, work through:
|
||||
- Layout rotation / cycling on a timer (the daemon already re-tiles on demand).
|
||||
- TUI create / delete / rename layouts (today the TUI only *edits* existing
|
||||
ones; new layouts are added in YAML).
|
||||
- `rtsp-streamer reload` CLI (so hand-edits apply live without the TUI or a
|
||||
restart); optional git tags so `version` shows a release rather than a hash.
|
||||
- Optional git tags so `version` shows a release rather than a hash.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, status, tui, config, version
|
||||
cmd/rtsp-streamer/ CLI (cobra): daemon, discover, layout, reload, status, tui, config, version
|
||||
internal/config/ YAML load/save/validate, schema (tiles, streams), XDG paths
|
||||
internal/protect/ UniFi Protect client (login, bootstrap, enable RTSP, URLs)
|
||||
internal/player/ one supervised mpv per stream, JSON IPC, restart/backoff
|
||||
|
||||
@@ -328,7 +328,8 @@ func layoutCmd() *cobra.Command {
|
||||
names = append(names, l.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, l := range cfg.Layouts {
|
||||
for _, name := range names {
|
||||
l := cfg.LayoutByName(name)
|
||||
marker := " "
|
||||
if l.Name == cfg.ActiveLayout {
|
||||
marker = "* "
|
||||
@@ -374,6 +375,27 @@ func layoutCmd() *cobra.Command {
|
||||
return c
|
||||
}
|
||||
|
||||
// reloadCmd tells the running daemon to re-read its config and re-apply the
|
||||
// active layout. The daemon leaves streams untouched when nothing material
|
||||
// changed, so this is safe to run casually.
|
||||
func reloadCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "reload",
|
||||
Short: "Reload the running daemon's config and re-apply the layout",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
resp, err := ipc.Send(ipc.Request{Cmd: "reload"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !resp.OK {
|
||||
return fmt.Errorf("%s", resp.Error)
|
||||
}
|
||||
fmt.Printf("Reloaded. Active layout: %s (%d streams)\n", resp.ActiveLayout, len(resp.Slots))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// statusCmd asks the running daemon for slot health.
|
||||
func statusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
|
||||
@@ -39,6 +39,7 @@ func main() {
|
||||
daemonCmd(),
|
||||
discoverCmd(),
|
||||
layoutCmd(),
|
||||
reloadCmd(),
|
||||
statusCmd(),
|
||||
tuiCmd(),
|
||||
configCmd(),
|
||||
|
||||
@@ -20,6 +20,8 @@ player:
|
||||
# won't engage it; "no" forces software.
|
||||
profile: low-latency
|
||||
max_fps: 0 # cap rendered fps (0 = uncapped); trims render load
|
||||
audio: false # streams are muted by default (saves CPU too);
|
||||
# set true to decode and play camera audio
|
||||
restart_backoff_seconds: 3
|
||||
extra_args: []
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rect is a pixel rectangle on the output.
|
||||
// Rect is a pixel rectangle on the output. The JSON tags match sway's
|
||||
// get_tree/get_outputs "rect" objects so it can be decoded directly.
|
||||
type Rect struct {
|
||||
X, Y, W, H int
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
W int `json:"width"`
|
||||
H int `json:"height"`
|
||||
}
|
||||
|
||||
// GridRects splits a WxH area into cols*rows cells in row-major order. The
|
||||
@@ -130,6 +133,7 @@ type treeNode struct {
|
||||
PID int `json:"pid"`
|
||||
Name string `json:"name"`
|
||||
AppID string `json:"app_id"`
|
||||
Rect Rect `json:"rect"`
|
||||
Nodes []treeNode `json:"nodes"`
|
||||
Float []treeNode `json:"floating_nodes"`
|
||||
}
|
||||
@@ -144,44 +148,25 @@ func (n *treeNode) walk(fn func(*treeNode)) {
|
||||
}
|
||||
}
|
||||
|
||||
// hasPID reports whether a window with the given pid currently exists.
|
||||
func (c *Client) hasPID(ctx context.Context, pid int) (bool, error) {
|
||||
// WindowRects returns the geometry of every mapped window keyed by owning
|
||||
// pid, from a single get_tree round trip. The daemon checks all tiles against
|
||||
// one snapshot instead of issuing a swaymsg per window.
|
||||
func (c *Client) WindowRects(ctx context.Context) (map[int]Rect, error) {
|
||||
out, err := exec.CommandContext(ctx, c.bin, "-t", "get_tree", "-r").Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get_tree: %w", err)
|
||||
return nil, fmt.Errorf("get_tree: %w", err)
|
||||
}
|
||||
var root treeNode
|
||||
if err := json.Unmarshal(out, &root); err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
found := false
|
||||
rects := map[int]Rect{}
|
||||
root.walk(func(n *treeNode) {
|
||||
if n.PID == pid && (n.AppID != "" || n.Name != "") {
|
||||
found = true
|
||||
if n.PID != 0 && (n.AppID != "" || n.Name != "") {
|
||||
rects[n.PID] = n.Rect
|
||||
}
|
||||
})
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// WaitForWindow blocks until a window owned by pid maps, or ctx/timeout fires.
|
||||
func (c *Client) WaitForWindow(ctx context.Context, pid int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(150 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
ok, err := c.hasPID(ctx, pid)
|
||||
if err == nil && ok {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("window for pid %d did not appear within %s", pid, timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
return rects, nil
|
||||
}
|
||||
|
||||
// Place floats and positions the window owned by pid at the given rect. Using
|
||||
|
||||
@@ -88,6 +88,10 @@ type Player struct {
|
||||
// MaxFPS caps the rendered frame rate (mpv --vf=fps). 0 = uncapped. Trims
|
||||
// render/scale load; the bigger decode lever is using substreams.
|
||||
MaxFPS int `yaml:"max_fps,omitempty"`
|
||||
// Audio enables stream sound. Off by default: a wall of simultaneous
|
||||
// feeds is unwatchable with sound, and skipping the audio decoder saves
|
||||
// CPU per stream.
|
||||
Audio bool `yaml:"audio,omitempty"`
|
||||
// RestartBackoffSeconds is how long to wait before relaunching a stream
|
||||
// that exited or stalled.
|
||||
RestartBackoffSeconds int `yaml:"restart_backoff_seconds,omitempty"`
|
||||
@@ -436,6 +440,12 @@ func Save(path string, c *Config) error {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
// Flush to stable storage before the rename: on SD cards a power cut
|
||||
// between rename and writeback can otherwise leave an empty config.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ type Daemon struct {
|
||||
cfg *config.Config
|
||||
players []*player.Player
|
||||
layout string
|
||||
wallSig string // signature of the running wall, to skip no-op reloads
|
||||
cancelLo context.CancelFunc // cancels the current layout's supervisors
|
||||
wg sync.WaitGroup
|
||||
pcl *protect.Client // cached Protect session for view sync
|
||||
}
|
||||
|
||||
// New constructs a daemon bound to a config path.
|
||||
@@ -99,29 +101,31 @@ func (d *Daemon) reload(ctx context.Context) error {
|
||||
return d.applyLayout(ctx, name)
|
||||
}
|
||||
|
||||
// applyLayout tears down the current wall and builds the named layout.
|
||||
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
|
||||
d.mu.Lock()
|
||||
cfg := d.cfg
|
||||
d.mu.Unlock()
|
||||
// tileSpec is one resolved tile: which stream goes where. It doubles as the
|
||||
// unit of the wall signature — if the specs (plus resolution and player
|
||||
// settings) are unchanged, a reload need not touch the running streams.
|
||||
type tileSpec struct {
|
||||
slot int
|
||||
name string
|
||||
url string
|
||||
rect compositor.Rect
|
||||
}
|
||||
|
||||
// resolveTiles turns the named layout into concrete tile specs and the wall
|
||||
// signature for change detection.
|
||||
func (d *Daemon) resolveTiles(ctx context.Context, cfg *config.Config, name string) ([]tileSpec, string, error) {
|
||||
layout := cfg.LayoutByName(name)
|
||||
if layout == nil {
|
||||
return fmt.Errorf("layout %q not found", name)
|
||||
return nil, "", fmt.Errorf("layout %q not found", name)
|
||||
}
|
||||
cols, rows, err := layout.Dimensions()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, "", err
|
||||
}
|
||||
w, h := d.resolution(ctx, cfg)
|
||||
tiles := layout.EffectiveTiles()
|
||||
|
||||
d.stopLayout()
|
||||
|
||||
loCtx, cancel := context.WithCancel(ctx)
|
||||
var players []*player.Player
|
||||
|
||||
for slot, tile := range tiles {
|
||||
var specs []tileSpec
|
||||
for slot, tile := range layout.EffectiveTiles() {
|
||||
if tile.Camera == "" {
|
||||
continue
|
||||
}
|
||||
@@ -137,83 +141,169 @@ func (d *Daemon) applyLayout(ctx context.Context, name string) error {
|
||||
}
|
||||
cs, rs := tile.Span()
|
||||
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
|
||||
p := player.New(slot, cam.Name, url, cfg.Player, d.runDir, d.log)
|
||||
specs = append(specs, tileSpec{slot: slot, name: cam.Name, url: url, rect: rect})
|
||||
}
|
||||
sig := fmt.Sprintf("%s|%dx%d|%+v|%v", name, w, h, cfg.Player, specs)
|
||||
return specs, sig, nil
|
||||
}
|
||||
|
||||
// applyLayout builds the named layout. When the resolved wall is identical to
|
||||
// the one already running (same streams, geometry, and player settings), it
|
||||
// leaves the streams alone — so saving an unrelated config edit never blanks
|
||||
// the screen.
|
||||
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
|
||||
d.mu.Lock()
|
||||
cfg := d.cfg
|
||||
d.mu.Unlock()
|
||||
|
||||
specs, sig, err := d.resolveTiles(ctx, cfg, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
unchanged := d.layout == name && d.wallSig == sig && len(d.players) > 0
|
||||
d.mu.Unlock()
|
||||
if unchanged {
|
||||
d.log.Info("layout unchanged; leaving streams running", "layout", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
d.stopLayout()
|
||||
|
||||
loCtx, cancel := context.WithCancel(ctx)
|
||||
players := make([]*player.Player, 0, len(specs))
|
||||
places := make([]placement, 0, len(specs))
|
||||
|
||||
for _, s := range specs {
|
||||
p := player.New(s.slot, s.name, s.url, cfg.Player, d.runDir, d.log)
|
||||
players = append(players, p)
|
||||
places = append(places, placement{p: p, rect: s.rect})
|
||||
|
||||
d.wg.Add(1)
|
||||
go func() { defer d.wg.Done(); p.Supervise(loCtx) }()
|
||||
|
||||
// Position the window once it maps. Done per-tile so a slow camera
|
||||
// doesn't block the others.
|
||||
}
|
||||
if len(places) > 0 {
|
||||
d.wg.Add(1)
|
||||
go func(p *player.Player, rect compositor.Rect) {
|
||||
defer d.wg.Done()
|
||||
d.placeWhenReady(loCtx, p, rect)
|
||||
}(p, rect)
|
||||
go func() { defer d.wg.Done(); d.placeLoop(loCtx, places) }()
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.players = players
|
||||
d.layout = name
|
||||
d.wallSig = sig
|
||||
d.cancelLo = cancel
|
||||
d.cfg.ActiveLayout = name
|
||||
d.mu.Unlock()
|
||||
d.log.Info("layout applied", "layout", name, "tiles", len(players), "grid", layout.Grid, "res", fmt.Sprintf("%dx%d", w, h))
|
||||
d.log.Info("layout applied", "layout", name, "tiles", len(players))
|
||||
return nil
|
||||
}
|
||||
|
||||
// placeWhenReady waits for the mpv window to map, tiles it, then keeps
|
||||
// re-asserting its geometry on a timer. The re-assertion matters because mpv
|
||||
// resizes its own window to the camera's native resolution when the stream
|
||||
// loads (and mpv 0.35 has no flag to disable that); re-issuing the sway
|
||||
// resize snaps the window back into its cell, portably across mpv versions.
|
||||
func (d *Daemon) placeWhenReady(ctx context.Context, p *player.Player, rect compositor.Rect) {
|
||||
var readyPID int
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// placement pairs a player with the rectangle its window belongs in.
|
||||
type placement struct {
|
||||
p *player.Player
|
||||
rect compositor.Rect
|
||||
}
|
||||
|
||||
// placeLoop keeps every tile's window at its assigned rectangle using one
|
||||
// get_tree snapshot per tick, re-placing only windows that drifted. mpv
|
||||
// resizes its own window to the video's native size when a stream loads (mpv
|
||||
// 0.35 has no flag to disable that), so drift is expected on every stream
|
||||
// (re)start; comparing against the snapshot means a settled wall costs one
|
||||
// swaymsg per tick instead of one per tile. The tick runs fast while windows
|
||||
// are mapping or drifting and relaxes once everything has been in place for a
|
||||
// few consecutive ticks.
|
||||
func (d *Daemon) placeLoop(ctx context.Context, places []placement) {
|
||||
const fast, slow = 500 * time.Millisecond, 2 * time.Second
|
||||
settled := 0
|
||||
mapped := map[int]bool{} // pids whose window we have seen mapped
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
interval := fast
|
||||
if settled >= 3 {
|
||||
interval = slow
|
||||
}
|
||||
if !sleepCtx(ctx, interval) {
|
||||
return
|
||||
}
|
||||
pid := p.PID()
|
||||
if pid != 0 {
|
||||
// New process: wait for its window to map before positioning.
|
||||
if pid != readyPID {
|
||||
if err := d.comp.WaitForWindow(ctx, pid, 15*time.Second); err != nil {
|
||||
goto wait
|
||||
rects, err := d.comp.WindowRects(ctx)
|
||||
if err != nil {
|
||||
d.log.Debug("get_tree failed", "err", err)
|
||||
continue
|
||||
}
|
||||
readyPID = pid
|
||||
d.log.Debug("window mapped", "slot", p.Slot, "pid", pid, "rect", rect)
|
||||
clean := true
|
||||
for _, pl := range places {
|
||||
pid := pl.p.PID()
|
||||
if pid == 0 {
|
||||
continue // not running (backoff); nothing to place yet
|
||||
}
|
||||
// Re-assert geometry every tick to override mpv's auto-resize.
|
||||
if err := d.comp.Place(ctx, pid, rect); err != nil {
|
||||
d.log.Debug("place failed", "slot", p.Slot, "err", err)
|
||||
actual, ok := rects[pid]
|
||||
if !ok {
|
||||
// Process is up but its window hasn't mapped: poll fast so it
|
||||
// lands in its cell the moment it appears.
|
||||
if !mapped[pid] {
|
||||
clean = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !mapped[pid] {
|
||||
mapped[pid] = true
|
||||
d.log.Debug("window mapped", "slot", pl.p.Slot, "pid", pid, "rect", pl.rect)
|
||||
}
|
||||
if actual != pl.rect {
|
||||
clean = false
|
||||
if err := d.comp.Place(ctx, pid, pl.rect); err != nil {
|
||||
d.log.Debug("place failed", "slot", pl.p.Slot, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if clean {
|
||||
settled++
|
||||
} else {
|
||||
settled = 0
|
||||
}
|
||||
// Drop map entries for long-gone pids so restart churn over weeks of
|
||||
// uptime doesn't grow the set unboundedly.
|
||||
if len(mapped) > 2*len(places) {
|
||||
current := make(map[int]bool, len(places))
|
||||
for _, pl := range places {
|
||||
current[pl.p.PID()] = true
|
||||
}
|
||||
for pid := range mapped {
|
||||
if !current[pid] {
|
||||
delete(mapped, pid)
|
||||
}
|
||||
}
|
||||
wait:
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stopLayout cancels supervisors and kills current mpv processes.
|
||||
// sleepCtx sleeps for d or until ctx is cancelled; false means cancelled.
|
||||
func sleepCtx(ctx context.Context, dur time.Duration) bool {
|
||||
t := time.NewTimer(dur)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// stopLayout tears down the current wall. Cancelling the layout context
|
||||
// SIGTERMs every mpv concurrently (each supervisor's cmd.Cancel, escalating
|
||||
// to SIGKILL via WaitDelay) and stops the placement loop; the WaitGroup
|
||||
// confirms every process has been reaped before we build the next layout.
|
||||
func (d *Daemon) stopLayout() {
|
||||
d.mu.Lock()
|
||||
cancel := d.cancelLo
|
||||
players := d.players
|
||||
d.cancelLo = nil
|
||||
d.players = nil
|
||||
d.wallSig = ""
|
||||
d.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
for _, p := range players {
|
||||
p.Stop()
|
||||
}
|
||||
d.wg.Wait()
|
||||
}
|
||||
|
||||
@@ -222,7 +312,9 @@ func (d *Daemon) stopLayout() {
|
||||
func (d *Daemon) healthLoop(ctx context.Context) {
|
||||
ticker := time.NewTicker(20 * time.Second)
|
||||
defer ticker.Stop()
|
||||
stalls := map[int]int{}
|
||||
// Keyed by player (not slot) so strikes never carry over to a different
|
||||
// stream after a layout switch; stale entries are pruned each tick.
|
||||
stalls := map[*player.Player]int{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -232,21 +324,28 @@ func (d *Daemon) healthLoop(ctx context.Context) {
|
||||
d.mu.Lock()
|
||||
players := append([]*player.Player(nil), d.players...)
|
||||
d.mu.Unlock()
|
||||
current := make(map[*player.Player]bool, len(players))
|
||||
for _, p := range players {
|
||||
current[p] = true
|
||||
if !p.Running() {
|
||||
continue
|
||||
}
|
||||
if p.Healthy() {
|
||||
stalls[p.Slot] = 0
|
||||
stalls[p] = 0
|
||||
continue
|
||||
}
|
||||
stalls[p.Slot]++
|
||||
stalls[p]++
|
||||
// Require three consecutive unresponsive probes (~60s) before
|
||||
// forcing a restart, so a brief IPC hiccup never bounces a stream.
|
||||
if stalls[p.Slot] >= 3 {
|
||||
d.log.Warn("stream hung, forcing restart", "slot", p.Slot, "camera", p.Name, "misses", stalls[p.Slot])
|
||||
if stalls[p] >= 3 {
|
||||
d.log.Warn("stream hung, forcing restart", "slot", p.Slot, "camera", p.Name, "misses", stalls[p])
|
||||
p.Stop() // Supervise relaunches
|
||||
stalls[p.Slot] = 0
|
||||
stalls[p] = 0
|
||||
}
|
||||
}
|
||||
for p := range stalls {
|
||||
if !current[p] {
|
||||
delete(stalls, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,20 +356,22 @@ func (d *Daemon) healthLoop(ctx context.Context) {
|
||||
// the wall without manual re-import. Off unless view_refresh_seconds > 0 and
|
||||
// controller credentials are available to the daemon process.
|
||||
func (d *Daemon) viewSyncLoop(ctx context.Context) {
|
||||
warnedNoCreds := false
|
||||
for {
|
||||
// Re-read the interval every pass so enabling or tuning
|
||||
// view_refresh_seconds takes effect on reload, without a restart.
|
||||
d.mu.Lock()
|
||||
interval := d.cfg.ViewRefreshSeconds
|
||||
d.mu.Unlock()
|
||||
wait := time.Duration(interval) * time.Second
|
||||
if interval <= 0 {
|
||||
wait = 30 * time.Second // idle poll of the config; sync stays off
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
defer ticker.Stop()
|
||||
warnedNoCreds := false
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if interval <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := d.syncActiveView(ctx); err != nil {
|
||||
if err == errNoCreds {
|
||||
@@ -287,6 +388,22 @@ func (d *Daemon) viewSyncLoop(ctx context.Context) {
|
||||
|
||||
var errNoCreds = fmt.Errorf("no controller credentials")
|
||||
|
||||
// loginProtect builds a fresh authenticated Protect client and caches it for
|
||||
// subsequent sync ticks.
|
||||
func (d *Daemon) loginProtect(ctx context.Context, cfg *config.Config) (*protect.Client, error) {
|
||||
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.pcl = cl
|
||||
d.mu.Unlock()
|
||||
return cl, nil
|
||||
}
|
||||
|
||||
// syncActiveView refetches the linked live view and re-applies the layout if it
|
||||
// changed. No-op when the active layout isn't linked to a Protect view.
|
||||
func (d *Daemon) syncActiveView(ctx context.Context) error {
|
||||
@@ -302,16 +419,24 @@ func (d *Daemon) syncActiveView(ctx context.Context) error {
|
||||
if cfg.Controller.Host == "" || cfg.Controller.ResolvePassword() == "" {
|
||||
return errNoCreds
|
||||
}
|
||||
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
|
||||
if err != nil {
|
||||
// Reuse the cached session across sync ticks instead of logging in every
|
||||
// interval; if the session has expired (or this is the first sync), log in
|
||||
// fresh and retry once.
|
||||
d.mu.Lock()
|
||||
cl := d.pcl
|
||||
d.mu.Unlock()
|
||||
var views []protect.LiveView
|
||||
var err error
|
||||
if cl != nil {
|
||||
views, _, err = cl.LiveViews(ctx)
|
||||
}
|
||||
if cl == nil || err != nil {
|
||||
if cl, err = d.loginProtect(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil {
|
||||
if views, _, err = cl.LiveViews(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
views, _, err := cl.LiveViews(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var view *protect.LiveView
|
||||
for i := range views {
|
||||
@@ -365,6 +490,11 @@ func (d *Daemon) serveControl(ctx context.Context) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Back off briefly so a persistent accept error (e.g. the socket
|
||||
// vanishing) never becomes a hot loop.
|
||||
if !sleepCtx(ctx, time.Second) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
go d.handleControl(ctx, conn)
|
||||
@@ -415,10 +545,15 @@ func (d *Daemon) persistActiveLayout(name string) error {
|
||||
}
|
||||
|
||||
func (d *Daemon) status() ipc.Response {
|
||||
// Copy under the lock, probe outside it: Healthy() dials mpv's IPC socket
|
||||
// (up to ~2s per stream), and holding the daemon mutex through that would
|
||||
// block layout switches and reloads for the duration.
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
resp := ipc.Response{OK: true, ActiveLayout: d.layout}
|
||||
for _, p := range d.players {
|
||||
layout := d.layout
|
||||
players := append([]*player.Player(nil), d.players...)
|
||||
d.mu.Unlock()
|
||||
resp := ipc.Response{OK: true, ActiveLayout: layout}
|
||||
for _, p := range players {
|
||||
resp.Slots = append(resp.Slots, ipc.SlotStatus{
|
||||
Slot: p.Slot,
|
||||
Camera: p.Name,
|
||||
|
||||
@@ -61,7 +61,9 @@ func Send(req Request) (*Response, error) {
|
||||
return nil, fmt.Errorf("cannot reach daemon (is it running?): %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
// Generous deadline: a reload that rebuilds the wall tears down and
|
||||
// respawns every mpv before replying, and status probes each stream's IPC.
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
|
||||
enc := json.NewEncoder(conn)
|
||||
if err := enc.Encode(req); err != nil {
|
||||
|
||||
@@ -89,6 +89,11 @@ func (p *Player) args() []string {
|
||||
"--framedrop=decoder+vo",
|
||||
"--demuxer-lavf-o=stimeout=5000000,fflags=+nobuffer,flags=+low_delay",
|
||||
}
|
||||
if !p.cfg.Audio {
|
||||
// A wall of simultaneous feeds is unwatchable with sound, and skipping
|
||||
// the audio decoder saves CPU per stream on the Pi.
|
||||
args = append(args, "--no-audio")
|
||||
}
|
||||
if p.cfg.MaxFPS > 0 {
|
||||
args = append(args, fmt.Sprintf("--vf=fps=%d", p.cfg.MaxFPS))
|
||||
}
|
||||
@@ -121,6 +126,11 @@ func (p *Player) start(ctx context.Context) error {
|
||||
p.log.Warn("stale ipc socket", "err", err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "mpv", p.args()...)
|
||||
// On ctx cancel, ask mpv to exit cleanly first; WaitDelay escalates to
|
||||
// SIGKILL if it hasn't gone away shortly after. This makes layout teardown
|
||||
// graceful and parallel — every supervisor reaps its own process.
|
||||
cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) }
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
// Inherit the caller's environment (WAYLAND_DISPLAY etc. must be set).
|
||||
cmd.Env = os.Environ()
|
||||
// Discard mpv's chatty stdout/stderr; errors surface via exit code.
|
||||
@@ -174,7 +184,11 @@ func (p *Player) Supervise(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop terminates the mpv process (SIGTERM, then SIGKILL after a grace period).
|
||||
// Stop asks the mpv process to exit (SIGTERM, escalating to SIGKILL if it
|
||||
// ignores it). It never calls Wait — Supervise owns the reap, notices the
|
||||
// exit, and relaunches unless its context has been cancelled. Watching p.cmd
|
||||
// (which Supervise clears after reaping) instead of double-Waiting avoids a
|
||||
// race on the process handle.
|
||||
func (p *Player) Stop() {
|
||||
p.mu.Lock()
|
||||
cmd := p.cmd
|
||||
@@ -182,14 +196,21 @@ func (p *Player) Stop() {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
done := make(chan struct{})
|
||||
go func() { _, _ = cmd.Process.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
proc := cmd.Process
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
go func() {
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
p.mu.Lock()
|
||||
alive := p.cmd == cmd
|
||||
p.mu.Unlock()
|
||||
if !alive {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
}()
|
||||
}
|
||||
|
||||
// Command sends a JSON IPC command to mpv and returns the decoded reply. Used
|
||||
|
||||
Reference in New Issue
Block a user