Files
RTSP-Streamer/cmd/rtsp-streamer/commands.go
Levi Woodard 98ddf1645c 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>
2026-07-02 07:38:01 -05:00

495 lines
14 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 := newLogger()
d, err := daemon.New(cfgPath, log)
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
}
// 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
}