diff --git a/README.md b/README.md index 4ca00c1..5856457 100644 --- a/README.md +++ b/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 diff --git a/cmd/rtsp-streamer/commands.go b/cmd/rtsp-streamer/commands.go index 1c22911..3da1eb9 100644 --- a/cmd/rtsp-streamer/commands.go +++ b/cmd/rtsp-streamer/commands.go @@ -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{ diff --git a/cmd/rtsp-streamer/main.go b/cmd/rtsp-streamer/main.go index 011be0d..e4099a0 100644 --- a/cmd/rtsp-streamer/main.go +++ b/cmd/rtsp-streamer/main.go @@ -39,6 +39,7 @@ func main() { daemonCmd(), discoverCmd(), layoutCmd(), + reloadCmd(), statusCmd(), tuiCmd(), configCmd(), diff --git a/config.example.yaml b/config.example.yaml index a19498c..42bce1e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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: [] diff --git a/internal/compositor/compositor.go b/internal/compositor/compositor.go index 13af82e..add2907 100644 --- a/internal/compositor/compositor.go +++ b/internal/compositor/compositor.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 860a339..e71b627 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 133fea1..454aabc 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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 + } + clean := true + for _, pl := range places { + pid := pl.p.PID() + if pid == 0 { + continue // not running (backoff); nothing to place yet + } + 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 } - readyPID = pid - d.log.Debug("window mapped", "slot", p.Slot, "pid", pid, "rect", rect) + continue } - // 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) + 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) + } } } - wait: - select { - case <-ctx.Done(): - return - case <-ticker.C: + 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) + } + } } } } -// 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) { - d.mu.Lock() - interval := d.cfg.ViewRefreshSeconds - d.mu.Unlock() - if interval <= 0 { - return - } - ticker := time.NewTicker(time.Duration(interval) * time.Second) - defer ticker.Stop() warnedNoCreds := false for { - select { - case <-ctx.Done(): + // 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 - 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 { - return err + // 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 err := cl.Login(ctx, cfg.Controller.Username, cfg.Controller.ResolvePassword()); err != nil { - return err - } - views, _, err := cl.LiveViews(ctx) - if err != nil { - return err + if cl == nil || err != nil { + if cl, err = d.loginProtect(ctx, cfg); err != nil { + return err + } + if views, _, err = cl.LiveViews(ctx); 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, diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 83882d8..33c9658 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -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 { diff --git a/internal/player/player.go b/internal/player/player.go index 20526b9..a999400 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -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 diff --git a/internal/protect/protect.go b/internal/protect/protect.go index 668cc20..75bfe5a 100644 --- a/internal/protect/protect.go +++ b/internal/protect/protect.go @@ -73,8 +73,8 @@ func New(host string, rtspPort int, verifyTLS bool) (*Client, error) { // returns the CSRF token in a response header on successful login. func (c *Client) Login(ctx context.Context, username, password string) error { body, _ := json.Marshal(map[string]any{ - "username": username, - "password": password, + "username": username, + "password": password, "rememberMe": true, }) url := fmt.Sprintf("https://%s/api/auth/login", c.host) @@ -109,12 +109,12 @@ type bootstrap struct { State string `json:"state"` IsRTSPEnabled bool `json:"isRtspEnabled"` ChannelsWrapper []struct { - ID int `json:"id"` - Name string `json:"name"` - Width int `json:"width"` - Height int `json:"height"` - IsRTSPEnabled bool `json:"isRtspEnabled"` - RTSPAlias string `json:"rtspAlias"` + ID int `json:"id"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` + IsRTSPEnabled bool `json:"isRtspEnabled"` + RTSPAlias string `json:"rtspAlias"` } `json:"channels"` } `json:"cameras"` } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 5bbedeb..04e40ee 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -7,9 +7,9 @@ import tea "github.com/charmbracelet/bubbletea" // starts at headerRows. The grid is drawn with a border between cells, hence // the +1s. const ( - headerRows = 2 // title line + blank line - cellStride = cellW + 1 // cell inner width + right border column - blockH = cellH + 1 // cell content rows + top border row + headerRows = 2 // title line + blank line + cellStride = cellW + 1 // cell inner width + right border column + blockH = cellH + 1 // cell content rows + top border row ) // handleMouse routes mouse events per screen. Enables click-to-select on the