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 var asJSON bool c := &cobra.Command{ Use: "discover", Short: "Discover cameras from UniFi Protect and update the config", RunE: func(cmd *cobra.Command, _ []string) error { // --json reports what discovery found and writes nothing, so the // opentui configurator can preview the merge before saving. if asJSON { return discoverJSON(enableRTSP) } 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") c.Flags().BoolVar(&asJSON, "json", false, "print results as JSON without saving the config") 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 ", 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: the opentui one by default, // or the previous Bubble Tea implementation with --legacy. The legacy path is // kept because on a headless Pi the TUI is the only config UI there is, and it // needs no Bun runtime — useful if the compiled TUI is missing or misbehaves. func tuiCmd() *cobra.Command { var legacy bool c := &cobra.Command{ Use: "tui", Aliases: []string{"config-ui"}, Short: "Interactive configurator (great over SSH)", Long: `Interactive configurator: browse cameras, place them into layout slots, pick the active layout, discover cameras from UniFi Protect, and save — which signals a running daemon to reload. This runs the opentui configurator, a separate binary (rtsp-streamer-tui) built with Bun because opentui is a TypeScript library. It is looked for at $RTSP_STREAMER_TUI, then next to this executable, then on $PATH. Build it with 'make tui' and install it with 'make install'. --legacy runs the previous Bubble Tea configurator instead. It is compiled into this binary, so it needs no Bun runtime and always works — useful if the configurator is missing or misbehaving.`, RunE: func(cmd *cobra.Command, _ []string) error { if legacy { return tui.Run(cfgPath) } return runTUI(cmd.Context()) }, } c.Flags().BoolVar(&legacy, "legacy", false, "use the previous Bubble Tea configurator") return c } // 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, configExportCmd(), configApplyCmd()) 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 }