The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
568 lines
16 KiB
Go
568 lines
16 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
|
|
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 <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: 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
|
|
}
|