Files
RTSP-Streamer/internal/daemon/daemon.go
Levi Woodard 0fb731a71e Fix mpv tile overlap portably; add make install/deploy
mpv 0.35 rejects --auto-window-resize, which crash-looped every player.
Drop that flag and re-assert each tile geometry on a 1s timer in the
daemon, overriding mpv auto-resize on any mpv version.

Add make install (auto-sudo, native build + copy to /usr/local/bin)
and make deploy (install + restart the kiosk getty unit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:36:36 -05:00

325 lines
8.3 KiB
Go

// Package daemon is the orchestrator: it reads the config, asks the compositor
// for the output geometry, launches one supervised mpv per occupied grid slot,
// tiles them, and exposes a control socket so the layout can be switched live.
package daemon
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
"os"
"sync"
"time"
"github.com/lwoodard/rtsp-streamer/internal/compositor"
"github.com/lwoodard/rtsp-streamer/internal/config"
"github.com/lwoodard/rtsp-streamer/internal/ipc"
"github.com/lwoodard/rtsp-streamer/internal/player"
)
// Daemon owns the running video wall.
type Daemon struct {
cfgPath string
log *slog.Logger
comp *compositor.Client
runDir string
mu sync.Mutex
cfg *config.Config
players []*player.Player
layout string
cancelLo context.CancelFunc // cancels the current layout's supervisors
wg sync.WaitGroup
}
// New constructs a daemon bound to a config path.
func New(cfgPath string, log *slog.Logger) (*Daemon, error) {
comp, err := compositor.New()
if err != nil {
return nil, err
}
runDir := ipc.RunDir()
if err := os.MkdirAll(runDir, 0o700); err != nil {
return nil, err
}
return &Daemon{cfgPath: cfgPath, log: log, comp: comp, runDir: runDir}, nil
}
// Run starts the wall and blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
if err := d.comp.PrepareForMPV(ctx); err != nil {
d.log.Warn("could not preinstall mpv window rules", "err", err)
}
if err := d.reload(ctx); err != nil {
return err
}
go d.serveControl(ctx)
go d.healthLoop(ctx)
<-ctx.Done()
d.log.Info("shutting down")
d.stopLayout()
return nil
}
// resolution returns the render size, from config or the live output.
func (d *Daemon) resolution(ctx context.Context, cfg *config.Config) (int, int) {
if cfg.Display.Width > 0 && cfg.Display.Height > 0 {
return cfg.Display.Width, cfg.Display.Height
}
if out, err := d.comp.PrimaryOutput(ctx); err == nil && out.CurrentMode.Width > 0 {
return out.CurrentMode.Width, out.CurrentMode.Height
}
d.log.Warn("falling back to 1920x1080; set display.width/height to override")
return 1920, 1080
}
// reload re-reads config from disk and applies the active layout.
func (d *Daemon) reload(ctx context.Context) error {
cfg, err := config.Load(d.cfgPath)
if err != nil {
return err
}
d.mu.Lock()
d.cfg = cfg
d.mu.Unlock()
name := cfg.ActiveLayout
if name == "" {
d.log.Warn("no active_layout set; nothing to display")
d.stopLayout()
return nil
}
return d.applyLayout(ctx, name)
}
// applyLayout tears down the current wall and builds the named layout.
func (d *Daemon) applyLayout(ctx context.Context, name string) error {
d.mu.Lock()
cfg := d.cfg
d.mu.Unlock()
layout := cfg.LayoutByName(name)
if layout == nil {
return fmt.Errorf("layout %q not found", name)
}
cols, rows, err := layout.Dimensions()
if err != nil {
return err
}
w, h := d.resolution(ctx, cfg)
tiles := layout.EffectiveTiles()
d.stopLayout()
loCtx, cancel := context.WithCancel(ctx)
var players []*player.Player
for slot, tile := range tiles {
if tile.Camera == "" {
continue
}
cam := cfg.CameraByName(tile.Camera)
if cam == nil || cam.Disabled || cam.RTSP == "" {
d.log.Warn("skipping tile: camera unavailable", "slot", slot, "camera", tile.Camera)
continue
}
cs, rs := tile.Span()
rect := compositor.TileRect(w, h, cols, rows, tile.Col, tile.Row, cs, rs)
p := player.New(slot, cam.Name, cam.RTSP, cfg.Player, d.runDir, d.log)
players = append(players, p)
d.wg.Add(1)
go func() { defer d.wg.Done(); p.Supervise(loCtx) }()
// Position the window once it maps. Done per-tile so a slow camera
// doesn't block the others.
d.wg.Add(1)
go func(p *player.Player, rect compositor.Rect) {
defer d.wg.Done()
d.placeWhenReady(loCtx, p, rect)
}(p, rect)
}
d.mu.Lock()
d.players = players
d.layout = name
d.cancelLo = cancel
d.cfg.ActiveLayout = name
d.mu.Unlock()
d.log.Info("layout applied", "layout", name, "tiles", len(players), "grid", layout.Grid, "res", fmt.Sprintf("%dx%d", w, h))
return nil
}
// placeWhenReady waits for the mpv window to map, tiles it, then keeps
// re-asserting its geometry on a timer. The re-assertion matters because mpv
// resizes its own window to the camera's native resolution when the stream
// loads (and mpv 0.35 has no flag to disable that); re-issuing the sway
// resize snaps the window back into its cell, portably across mpv versions.
func (d *Daemon) placeWhenReady(ctx context.Context, p *player.Player, rect compositor.Rect) {
var readyPID int
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
if ctx.Err() != nil {
return
}
pid := p.PID()
if pid != 0 {
// New process: wait for its window to map before positioning.
if pid != readyPID {
if err := d.comp.WaitForWindow(ctx, pid, 15*time.Second); err != nil {
goto wait
}
readyPID = pid
d.log.Debug("window mapped", "slot", p.Slot, "pid", pid, "rect", rect)
}
// Re-assert geometry every tick to override mpv's auto-resize.
if err := d.comp.Place(ctx, pid, rect); err != nil {
d.log.Debug("place failed", "slot", p.Slot, "err", err)
}
}
wait:
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// stopLayout cancels supervisors and kills current mpv processes.
func (d *Daemon) stopLayout() {
d.mu.Lock()
cancel := d.cancelLo
players := d.players
d.cancelLo = nil
d.players = nil
d.mu.Unlock()
if cancel != nil {
cancel()
}
for _, p := range players {
p.Stop()
}
d.wg.Wait()
}
// healthLoop periodically nudges stalled streams. Supervise already restarts
// exited mpv; this catches the "process alive but frozen" case.
func (d *Daemon) healthLoop(ctx context.Context) {
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
stalls := map[int]int{}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
d.mu.Lock()
players := append([]*player.Player(nil), d.players...)
d.mu.Unlock()
for _, p := range players {
if !p.Running() {
continue
}
if p.Healthy() {
stalls[p.Slot] = 0
continue
}
stalls[p.Slot]++
if stalls[p.Slot] >= 2 {
d.log.Warn("stream stalled, forcing restart", "slot", p.Slot, "camera", p.Name)
p.Stop() // Supervise relaunches
stalls[p.Slot] = 0
}
}
}
}
// serveControl accepts control-socket connections for status/reload/set-layout.
func (d *Daemon) serveControl(ctx context.Context) {
path := ipc.SocketPath()
_ = os.Remove(path)
ln, err := net.Listen("unix", path)
if err != nil {
d.log.Error("control socket listen failed", "err", err)
return
}
go func() { <-ctx.Done(); ln.Close(); os.Remove(path) }()
d.log.Info("control socket ready", "path", path)
for {
conn, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return
}
continue
}
go d.handleControl(ctx, conn)
}
}
func (d *Daemon) handleControl(ctx context.Context, conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
var req ipc.Request
if err := json.NewDecoder(conn).Decode(&req); err != nil {
return
}
resp := d.dispatch(ctx, req)
_ = json.NewEncoder(conn).Encode(resp)
}
func (d *Daemon) dispatch(ctx context.Context, req ipc.Request) ipc.Response {
switch req.Cmd {
case "status":
return d.status()
case "reload":
if err := d.reload(ctx); err != nil {
return ipc.Response{OK: false, Error: err.Error()}
}
return d.status()
case "set-layout":
if err := d.applyLayout(ctx, req.Name); err != nil {
return ipc.Response{OK: false, Error: err.Error()}
}
// Persist the choice so a restart keeps it.
if err := d.persistActiveLayout(req.Name); err != nil {
d.log.Warn("could not persist active layout", "err", err)
}
return d.status()
default:
return ipc.Response{OK: false, Error: fmt.Sprintf("unknown command %q", req.Cmd)}
}
}
func (d *Daemon) persistActiveLayout(name string) error {
cfg, err := config.Load(d.cfgPath)
if err != nil {
return err
}
cfg.ActiveLayout = name
return config.Save(d.cfgPath, cfg)
}
func (d *Daemon) status() ipc.Response {
d.mu.Lock()
defer d.mu.Unlock()
resp := ipc.Response{OK: true, ActiveLayout: d.layout}
for _, p := range d.players {
resp.Slots = append(resp.Slots, ipc.SlotStatus{
Slot: p.Slot,
Camera: p.Name,
PID: p.PID(),
Running: p.Running(),
Healthy: p.Healthy(),
})
}
return resp
}