Files
RTSP-Streamer/cmd/rtsp-streamer/main.go
Levi Woodard be1b53ae04 Restart without reboot; retrievable logs incl. mpv exit reasons
Two related gaps on the headless kiosk: no way to restart the wall short
of a full reboot, and no way to see why a stream keeps flapping (the
daemon's stderr goes to sway on tty1, and mpv's stderr was discarded).

Restart in place:
- `rtsp-streamer restart` sends a control-socket command; the daemon
  tears down its mpv children and syscall.Exec's the on-disk binary.
  Same PID, same parent (sway), same env — keeps WAYLAND_DISPLAY/
  SWAYSOCK and comes back up on the new binary, no reboot. Handles the
  os.Executable() "(deleted)" sentinel from make install's rename.
- sway config now launches the daemon in a relaunch loop, so a crash (or
  the restart) auto-recovers instead of leaving a black screen.
- `make deploy` now does `install` + `restart` instead of restarting
  getty@tty1 (which left stale duplicate sessions and forced reboots).

Retrievable logs:
- New internal/logbuf ring; the daemon tees slog output into it and
  serves the tail over the socket via `rtsp-streamer logs [-n N]` —
  readable over SSH, no file wrangling, no reboot.
- Capture the tail of each mpv's stderr and log its last line when the
  process exits, so "mpv exited, will restart" now carries the reason
  (connection refused, unsupported codec, 401, ...). This is the
  diagnostic for a single tile going unhealthy repeatedly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:03:25 -05:00

82 lines
2.0 KiB
Go

// Command rtsp-streamer is a full-screen RTSP video wall for UniFi Protect
// cameras, driven by mpv under a kiosk Wayland compositor and configured from
// the command line or an interactive TUI.
package main
import (
"fmt"
"io"
"log/slog"
"os"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/logbuf"
"github.com/spf13/cobra"
)
var (
cfgPath string
logLevel string
)
// Build metadata, injected via -ldflags -X at build time (see Makefile).
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
root := &cobra.Command{
Use: "rtsp-streamer",
Short: "Full-screen RTSP camera wall for UniFi Protect",
Version: fmt.Sprintf("%s (commit %s, built %s)", version, commit, date),
SilenceUsage: true,
SilenceErrors: true,
}
root.PersistentFlags().StringVar(&cfgPath, "config", config.DefaultPath(), "path to config file")
root.PersistentFlags().StringVar(&logLevel, "log", "info", "log level: debug|info|warn|error")
root.AddCommand(
daemonCmd(),
discoverCmd(),
layoutCmd(),
logsCmd(),
reloadCmd(),
restartCmd(),
statusCmd(),
tuiCmd(),
configCmd(),
versionCmd(),
viewsCmd(),
)
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func logLevelValue() slog.Level {
switch logLevel {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
// newDaemonLogger builds the daemon's logger, teeing output to stderr and to
// an in-memory ring so `rtsp-streamer logs` can retrieve recent activity over
// the control socket (the daemon's stderr itself is not reachable on the
// headless kiosk — it goes to sway on tty1).
func newDaemonLogger() (*slog.Logger, *logbuf.Writer) {
ring := logbuf.New(500)
h := slog.NewTextHandler(io.MultiWriter(os.Stderr, ring), &slog.HandlerOptions{Level: logLevelValue()})
return slog.New(h), ring
}