Files
RTSP-Streamer/cmd/rtsp-streamer/commands.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

540 lines
15 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/signal"
"runtime"
"sort"
"strings"
"syscall"
"text/tabwriter"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/daemon"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/protect"
"github.com/lwoodard/rtsp-streamer/internal/tui"
"github.com/lwoodard/rtsp-streamer/internal/viewmap"
"github.com/spf13/cobra"
)
// versionCmd prints detailed build and runtime info.
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version, git commit, and build info",
Run: func(cmd *cobra.Command, _ []string) {
fmt.Printf("rtsp-streamer %s\n", version)
fmt.Printf(" commit: %s\n", commit)
fmt.Printf(" built: %s\n", date)
fmt.Printf(" go: %s\n", runtime.Version())
fmt.Printf(" platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
}
// daemonCmd runs the video wall. This is what systemd (via sway) launches.
func daemonCmd() *cobra.Command {
return &cobra.Command{
Use: "daemon",
Short: "Run the video wall (launched by the kiosk session)",
RunE: func(cmd *cobra.Command, _ []string) error {
log, ring := newDaemonLogger()
d, err := daemon.New(cfgPath, log, ring)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// SIGHUP triggers a config reload via the control socket path is
// unnecessary here; the daemon reloads on start and on demand.
return d.Run(ctx)
},
}
}
// discoverCmd queries UniFi Protect and merges cameras into the config.
func discoverCmd() *cobra.Command {
var enableRTSP string
c := &cobra.Command{
Use: "discover",
Short: "Discover cameras from UniFi Protect and update the config",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
if cfg.Controller.Host == "" {
return fmt.Errorf("controller.host is not set; edit %s or run `rtsp-streamer config init`", cfgPath)
}
pw := cfg.Controller.ResolvePassword()
if pw == "" {
return fmt.Errorf("no controller password (set controller.password or the env var in controller.password_env)")
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return err
}
ctx := context.Background()
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
return err
}
cams, err := cl.Cameras(ctx)
if err != nil {
return err
}
// Qualities to ensure RTSP-enabled, e.g. --enable-rtsp=high,low
var wantEnable []string
for _, q := range strings.Split(enableRTSP, ",") {
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
wantEnable = append(wantEnable, q)
}
}
added, updated, skipped := 0, 0, 0
for i := range cams {
cam := &cams[i]
for _, q := range wantEnable {
target := cam.ChannelByPreference(q)
if target == nil {
continue
}
if !target.RTSPEnabled || target.RTSPAlias == "" {
alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID)
if err != nil {
fmt.Fprintf(os.Stderr, " ! %s: enable %q failed: %v\n", cam.Name, q, err)
continue
}
target.RTSPEnabled, target.RTSPAlias = true, alias
fmt.Printf(" + enabled %s on %s\n", q, cam.Name)
}
}
// Record every enabled channel as a selectable quality.
streams := map[string]string{}
for _, ch := range cam.Channels {
if ch.RTSPEnabled && ch.RTSPAlias != "" {
streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias)
}
}
if len(streams) == 0 {
fmt.Fprintf(os.Stderr, " ! %s: no RTSP-enabled channel (try --enable-rtsp=high,low)\n", cam.Name)
skipped++
continue
}
entry := findByID(cfg, cam.ID)
if entry == nil {
entry = cfg.CameraByName(cam.Name)
}
if entry == nil {
cfg.Cameras = append(cfg.Cameras, config.Camera{})
entry = &cfg.Cameras[len(cfg.Cameras)-1]
added++
} else {
updated++
}
entry.ID, entry.Name, entry.Streams, entry.RTSP = cam.ID, cam.Name, streams, ""
fmt.Printf(" ✓ %-24s [%s]\n", cam.Name, strings.Join(availQual(streams), ","))
}
if err := config.Save(cfgPath, cfg); err != nil {
return err
}
fmt.Printf("\nDiscovered %d cameras (%d added, %d updated, %d skipped). Saved to %s\n",
len(cams), added, updated, skipped, cfgPath)
return nil
},
}
c.Flags().StringVar(&enableRTSP, "enable-rtsp", "", "comma-separated channels to enable in Protect and record, e.g. high,low")
return c
}
// loginClient loads controller creds and returns a logged-in Protect client.
func loginClient(cfg *config.Config) (*protect.Client, context.Context, error) {
if cfg.Controller.Host == "" {
return nil, nil, fmt.Errorf("controller.host is not set")
}
pw := cfg.Controller.ResolvePassword()
if pw == "" {
return nil, nil, fmt.Errorf("no controller password (set controller.password or password_env)")
}
cl, err := protect.New(cfg.Controller.Host, cfg.Controller.RTSPPort, cfg.Controller.VerifyTLS)
if err != nil {
return nil, nil, err
}
ctx := context.Background()
if err := cl.Login(ctx, cfg.Controller.Username, pw); err != nil {
return nil, nil, err
}
return cl, ctx, nil
}
// viewsCmd lists or imports UniFi Protect live views as layouts.
func viewsCmd() *cobra.Command {
c := &cobra.Command{Use: "views", Short: "List or import UniFi Protect live views"}
ls := &cobra.Command{
Use: "ls",
Short: "List the controller's saved live views",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
cl, ctx, err := loginClient(cfg)
if err != nil {
return err
}
views, _, err := cl.LiveViews(ctx)
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "VIEW\tSLOTS\tLAYOUT")
for _, v := range views {
fmt.Fprintf(w, "%s\t%d\t%d\n", v.Name, len(v.Slots), v.Layout)
}
return w.Flush()
},
}
dump := &cobra.Command{
Use: "dump",
Short: "Print the raw live-view JSON (for mapping asymmetric layouts)",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
cl, ctx, err := loginClient(cfg)
if err != nil {
return err
}
_, raw, err := cl.LiveViews(ctx)
if err != nil {
return err
}
fmt.Println(string(raw))
return nil
},
}
var importAll bool
imp := &cobra.Command{
Use: "import [view-name]",
Short: "Import a live view (or --all) as a layout, cameras and grid",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
cl, ctx, err := loginClient(cfg)
if err != nil {
return err
}
views, _, err := cl.LiveViews(ctx)
if err != nil {
return err
}
idToName := map[string]string{}
for _, cam := range cfg.Cameras {
if cam.ID != "" {
idToName[cam.ID] = cam.Name
}
}
imported := 0
for _, v := range views {
if !importAll && (len(args) == 0 || !strings.EqualFold(v.Name, args[0])) {
continue
}
layout, warns := viewmap.LayoutFromView(v, idToName)
for _, wmsg := range warns {
fmt.Fprintf(os.Stderr, " ! %s: %s\n", v.Name, wmsg)
}
if len(layout.Tiles) == 0 {
fmt.Fprintf(os.Stderr, " ! %s: no mappable cameras (run discover first), skipped\n", v.Name)
continue
}
upsertLayout(cfg, layout)
fmt.Printf(" ✓ imported %q as layout %q (%s, %d cameras)\n", v.Name, layout.Name, layout.Grid, len(layout.Tiles))
imported++
}
if imported == 0 {
return fmt.Errorf("no views imported (name not found? use `views ls`, or --all)")
}
if err := config.Save(cfgPath, cfg); err != nil {
return err
}
fmt.Printf("\nImported %d view(s). Saved to %s\n", imported, cfgPath)
return nil
},
}
imp.Flags().BoolVar(&importAll, "all", false, "import every live view")
c.AddCommand(ls, dump, imp)
return c
}
// upsertLayout replaces a layout of the same name or appends it.
func upsertLayout(cfg *config.Config, l config.Layout) {
for i := range cfg.Layouts {
if cfg.Layouts[i].Name == l.Name {
cfg.Layouts[i] = l
return
}
}
cfg.Layouts = append(cfg.Layouts, l)
}
// availQual lists the qualities present in a stream map, high to low.
func availQual(streams map[string]string) []string {
var out []string
for _, q := range config.Qualities {
if streams[q] != "" {
out = append(out, q)
}
}
return out
}
func findByID(cfg *config.Config, id string) *config.Camera {
if id == "" {
return nil
}
for i := range cfg.Cameras {
if cfg.Cameras[i].ID == id {
return &cfg.Cameras[i]
}
}
return nil
}
// layoutCmd groups layout subcommands.
func layoutCmd() *cobra.Command {
c := &cobra.Command{Use: "layout", Short: "List or switch layouts"}
ls := &cobra.Command{
Use: "ls",
Short: "List defined layouts",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
names := make([]string, 0, len(cfg.Layouts))
for _, l := range cfg.Layouts {
names = append(names, l.Name)
}
sort.Strings(names)
for _, name := range names {
l := cfg.LayoutByName(name)
marker := " "
if l.Name == cfg.ActiveLayout {
marker = "* "
}
fmt.Printf("%s%-16s %-6s %d cameras\n", marker, l.Name, l.Grid, len(l.EffectiveTiles()))
}
return nil
},
}
set := &cobra.Command{
Use: "set <name>",
Short: "Switch the active layout (live if the daemon is running)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
// Prefer switching the live daemon; it persists the choice too.
if resp, err := ipc.Send(ipc.Request{Cmd: "set-layout", Name: name}); err == nil {
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
fmt.Printf("Active layout is now %q (applied live)\n", name)
return nil
}
// Daemon not running: just update the config for next start.
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
if cfg.LayoutByName(name) == nil {
return fmt.Errorf("layout %q not found", name)
}
cfg.ActiveLayout = name
if err := config.Save(cfgPath, cfg); err != nil {
return err
}
fmt.Printf("Active layout set to %q (daemon not running; will apply on next start)\n", name)
return nil
},
}
c.AddCommand(ls, set)
return c
}
// logsCmd retrieves recent daemon log lines over the control socket — the
// practical way to see why a stream is flapping on the headless kiosk.
func logsCmd() *cobra.Command {
var n int
c := &cobra.Command{
Use: "logs",
Short: "Show recent daemon log lines (incl. mpv exit reasons)",
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := ipc.Send(ipc.Request{Cmd: "logs", Count: n})
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
for _, line := range resp.Logs {
fmt.Println(line)
}
return nil
},
}
c.Flags().IntVarP(&n, "lines", "n", 50, "show at most the most recent N lines (0 = all buffered)")
return c
}
// restartCmd asks the running daemon to re-exec itself in place, picking up a
// freshly installed binary without a reboot or a new sway session.
func restartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the running daemon in place (reloads the binary; no reboot)",
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := ipc.Send(ipc.Request{Cmd: "restart"})
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
fmt.Println("daemon is restarting in place (picking up the installed binary)…")
return nil
},
}
}
// 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{
Use: "status",
Short: "Show the running daemon's slot health",
RunE: func(cmd *cobra.Command, _ []string) error {
resp, err := ipc.Send(ipc.Request{Cmd: "status"})
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("%s", resp.Error)
}
fmt.Printf("Active layout: %s\n\n", resp.ActiveLayout)
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "SLOT\tCAMERA\tPID\tRUNNING\tHEALTHY")
for _, s := range resp.Slots {
fmt.Fprintf(w, "%d\t%s\t%d\t%v\t%v\n", s.Slot, s.Camera, s.PID, s.Running, s.Healthy)
}
return w.Flush()
},
}
}
// tuiCmd launches the interactive configurator.
func tuiCmd() *cobra.Command {
return &cobra.Command{
Use: "tui",
Aliases: []string{"config-ui"},
Short: "Interactive configurator (great over SSH)",
RunE: func(cmd *cobra.Command, _ []string) error {
return tui.Run(cfgPath)
},
}
}
// configCmd holds config-file utilities.
func configCmd() *cobra.Command {
c := &cobra.Command{Use: "config", Short: "Config file helpers"}
initCmd := &cobra.Command{
Use: "init",
Short: "Write a starter config if none exists",
RunE: func(cmd *cobra.Command, _ []string) error {
if _, err := os.Stat(cfgPath); err == nil {
return fmt.Errorf("config already exists at %s", cfgPath)
}
if err := config.Save(cfgPath, starterConfig()); err != nil {
return err
}
fmt.Printf("Wrote starter config to %s\n", cfgPath)
return nil
},
}
pathCmd := &cobra.Command{
Use: "path",
Short: "Print the resolved config path",
Run: func(cmd *cobra.Command, _ []string) { fmt.Println(cfgPath) },
}
editCmd := &cobra.Command{
Use: "validate",
Short: "Validate the config file",
RunE: func(cmd *cobra.Command, _ []string) error {
if _, err := config.Load(cfgPath); err != nil {
return err
}
fmt.Println("config OK")
return nil
},
}
c.AddCommand(initCmd, pathCmd, editCmd)
return c
}
func starterConfig() *config.Config {
c := &config.Config{
Controller: config.Controller{
Host: "192.168.1.1",
Username: "viewer",
PasswordEnv: "RTSP_STREAMER_PASSWORD",
VerifyTLS: false,
},
Layouts: []config.Layout{
// Empty layouts to fill in with `rtsp-streamer tui`.
{Name: "quad", Grid: "2x2"},
{Name: "main-plus", Grid: "4x3"},
{Name: "single", Grid: "1x1"},
},
ActiveLayout: "quad",
}
c.Defaults()
return c
}