From ed9814ab4e1ed80e90c9ef0ca98ccb3ca081cafc Mon Sep 17 00:00:00 2001 From: Levi Woodard Date: Thu, 2 Jul 2026 12:54:02 -0500 Subject: [PATCH] Reconnect streams in-process; fix for_window IPC rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the periodic drops (found via the new logs): mpv was exiting cleanly (err=nil, exit 0) with its status line at 100% — i.e. end-of-stream. UniFi Protect closes some cameras' RTSP connections every 20-40s; with --keep-open=no --idle=no mpv treated that as the file ending, exited, and the supervisor relaunched it ~3s later (the visible drop). - Add --loop-file=inf so mpv reopens the stream the instant it EOFs, in the same process and window: sub-second recovery, no teardown, no supervisor bounce. The supervisor now only fires for real crashes. - Clean up the logged exit "reason": strip ANSI escapes and skip mpv's transient A/V status prints, so a genuine error surfaces instead of "\x1b[KV: 00:00:35 / 00:00:35 (100%)". Tests cover the parsing. Also fold in the for_window IPC fix: sway's IPC parser splits comma- joined command lists at the top level and does not fold the continuation into for_window (unlike the config-file parser), so the map-time placement rules errored with "Only views can have borders" and never installed. Register each command separately; drop the redundant border none (the kiosk config already sets default_border none). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +++++ internal/compositor/compositor.go | 44 +++++++++++++++++++----------- internal/player/player.go | 45 ++++++++++++++++++++++++++----- internal/player/player_test.go | 31 +++++++++++++++++++++ 4 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 internal/player/player_test.go diff --git a/README.md b/README.md index c42624e..ad0893c 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,12 @@ pegged, work through: thing to check when one tile restarts repeatedly. A camera whose `video-codec` is `hevc` can't hardware-decode on a Pi 4 and often stutters/exits under load — switch it to H.264 in Protect or give the tile the `low` substream. + UniFi Protect also *closes* some cameras' RTSP connections periodically (every + 20-40s on certain models), which mpv sees as a clean end-of-stream. mpv is run + with `--loop-file=inf` so it reconnects in-process (sub-second, no window + teardown) rather than exiting and being relaunched — so this is normally + invisible. If a camera still blips on reconnect, it's dropping unusually often; + check its codec/bitrate in Protect. - **`status` can't reach the daemon** (`control.sock: no such file`) — the wall is running as a *different user* than your SSH session (check `ps -o user= -p "$(pgrep -x sway)"` vs `id`). Re-run `install.sh ` diff --git a/internal/compositor/compositor.go b/internal/compositor/compositor.go index 462b85c..44cf4e5 100644 --- a/internal/compositor/compositor.go +++ b/internal/compositor/compositor.go @@ -179,23 +179,37 @@ func (c *Client) Place(ctx context.Context, pid int, r Rect) error { return c.run(ctx, cmd) } -// PrepareForMPV installs global rules so every mpv window is borderless and -// floating the moment it maps, avoiding a flash of tiled/bordered video before -// Place runs. Safe to call repeatedly. +// PrepareForMPV installs a global rule so every mpv window maps floating, +// ready for the daemon to position. Borders are already off via the kiosk +// config's `default_border none`, so this rule is a single command: sway's +// IPC parser splits a comma-joined command list at the top level (it does NOT +// fold the continuation into for_window the way the config-file parser does), +// so a multi-command for_window must be registered one command per call. +// Safe to call repeatedly. func (c *Client) PrepareForMPV(ctx context.Context) error { - return c.run(ctx, `for_window [app_id="mpv"] floating enable, border none`) + return c.run(ctx, `for_window [app_id="mpv"] floating enable`) } -// PlaceOnMap installs a for_window rule that positions any window with the -// given title at rect the moment it maps. This is what keeps a restarting -// stream from flashing at the wrong place/size: sway applies the geometry -// synchronously at map time, before the daemon's corrective loop ever sees -// the window. Re-installing a rule for the same title replaces it. The title -// is anchored (^...$) so slot-1 never matches slot-10. +// PlaceOnMap installs for_window rules that position and size any window with +// the given title the moment it maps — so a restarting stream lands in its +// cell without a wrong-place flash, before the corrective loop ever runs. The +// title is anchored (^...$) so slot-1 never matches slot-10. Because sway's +// IPC command parser splits on commas at the top level, each rule carries a +// single command and is registered separately (a comma-joined for_window would +// silently drop everything after the first command and run the rest +// immediately against the focused container). Re-registering a rule for the +// same criteria replaces it. func (c *Client) PlaceOnMap(ctx context.Context, title string, r Rect) error { - cmd := fmt.Sprintf( - `for_window [title="^%s$"] floating enable, border none, move absolute position %d %d, resize set %d %d`, - title, r.X, r.Y, r.W, r.H, - ) - return c.run(ctx, cmd) + crit := fmt.Sprintf(`[title="^%s$"]`, title) + cmds := []string{ + fmt.Sprintf("for_window %s floating enable", crit), + fmt.Sprintf("for_window %s move absolute position %d %d", crit, r.X, r.Y), + fmt.Sprintf("for_window %s resize set %d %d", crit, r.W, r.H), + } + for _, cmd := range cmds { + if err := c.run(ctx, cmd); err != nil { + return err + } + } + return nil } diff --git a/internal/player/player.go b/internal/player/player.go index 41b4b6d..2e70d28 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "syscall" @@ -74,6 +75,13 @@ func (p *Player) args() []string { "--cursor-autohide=always", "--no-border", "--fullscreen=no", // we tile via the compositor, not fullscreen + // Reconnect in-process when the RTSP stream ends. UniFi Protect closes + // a camera's RTSP connection periodically (every 20-40s on some + // models); without this mpv hits EOF, exits cleanly, and the + // supervisor relaunches it ~3s later — a visible drop. loop-file=inf + // reopens the same URL the instant it ends, keeping the window alive, + // so recovery is sub-second with no teardown. + "--loop-file=inf", // Fit any camera into its tile regardless of the camera's aspect: // * keepaspect=yes → letterbox the video, never crop/stretch // * keepaspect-window=no → do NOT reshape the window to the video's @@ -282,20 +290,45 @@ func (p *Player) Healthy() bool { return reply["error"] == "success" } -// lastLine returns the most recent non-empty stderr line mpv emitted, trimmed, -// or "" if there was none. mpv prints its fatal error last, so the tail is the -// useful part. +// ansiRE matches terminal escape sequences mpv sprinkles into its output. +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) + +// lastLine returns the most useful recent stderr line mpv emitted: the last +// line that is not one of mpv's periodic playback-status prints (e.g. +// "V: 00:00:35 / 00:00:35 (100%)"), with ANSI escapes stripped. A real error +// (connection refused, unsupported codec, 401) is what we want to surface; the +// status line is only the fallback when nothing else was printed. func lastLine(w *logbuf.Writer) string { if w == nil { return "" } lines := w.Lines(0) + fallback := "" for i := len(lines) - 1; i >= 0; i-- { - if s := strings.TrimSpace(lines[i]); s != "" { - return s + s := strings.TrimSpace(ansiRE.ReplaceAllString(lines[i], "")) + if s == "" { + continue + } + if fallback == "" { + fallback = s + } + if isStatusLine(s) { + continue + } + return s + } + return fallback +} + +// isStatusLine reports whether s is one of mpv's transient A/V progress prints +// rather than a substantive message. +func isStatusLine(s string) bool { + for _, p := range []string{"V:", "A:", "AV:", "(Paused)", "(Buffering)"} { + if strings.HasPrefix(s, p) { + return true } } - return "" + return false } func sleep(ctx context.Context, d time.Duration) bool { diff --git a/internal/player/player_test.go b/internal/player/player_test.go new file mode 100644 index 0000000..c7ed679 --- /dev/null +++ b/internal/player/player_test.go @@ -0,0 +1,31 @@ +package player + +import ( + "fmt" + "testing" + + "github.com/lwoodard/rtsp-streamer/internal/logbuf" +) + +func TestLastLinePrefersRealMessageOverStatus(t *testing.T) { + w := logbuf.New(20) + fmt.Fprintln(w, "Failed to open rtsps://host/x: Connection refused") + fmt.Fprint(w, "\x1b[KV: 00:00:35 / 00:00:35 (100%)\n") // ANSI-wrapped status + if got := lastLine(w); got != "Failed to open rtsps://host/x: Connection refused" { + t.Errorf("lastLine = %q, want the error line", got) + } +} + +func TestLastLineFallsBackToStatusWhenOnlyStatus(t *testing.T) { + w := logbuf.New(20) + fmt.Fprint(w, "\x1b[KV: 00:00:20 / 00:00:20 (100%)\n") + if got := lastLine(w); got != "V: 00:00:20 / 00:00:20 (100%)" { + t.Errorf("lastLine = %q, want the stripped status line", got) + } +} + +func TestLastLineNil(t *testing.T) { + if got := lastLine(nil); got != "" { + t.Errorf("lastLine(nil) = %q, want empty", got) + } +}