- 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>
495 lines
14 KiB
Go
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
|
|
}
|