// RocketLeagueBot-Renderer // // Live UDP ingest + WebSocket fan-out + SQLite recording/playback for // RocketSimVis-compatible state telemetry. // // Architecture: // // bot ──UDP──▶ server.ListenUDP ──▶ hub.Broadcast ──WS──▶ live viewers // │ // └──▶ recording.Recorder (batched, zstd) ──▶ SQLite // │ // └──▶ recording.Player ──WS──▶ playback viewers package main import ( "context" "flag" "log/slog" "os" "os/signal" "strings" "syscall" "time" _ "github.com/ncruces/go-sqlite3/driver" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/server" ) var ( httpAddr = flag.String("http", ":80", "HTTP listen address (must be :80 for autocert)") udpAddr = flag.String("udp", ":9273", "UDP listen address (RocketSimVis port)") tlsAddr = flag.String("tls", ":443", "HTTPS listen address with autocert") domain = flag.String("domain", "", "Domain for autocert (e.g., example.com)") password = flag.String("password", "", "Password to protect the viewer (required)") certDir = flag.String("certdir", "./certs", "Directory for storing certificates") dbPath = flag.String("db", "./recordings.db", "Path to SQLite database for recordings") retention = flag.Duration("retention", 7*24*time.Hour, "How long to keep recordings (e.g., 72h)") compress = flag.Bool("compress", true, "Compress recorded frames with zstd") verbose = flag.Bool("verbose", false, "Verbose HTTP request logging (including WS upgrades)") logLevel = flag.String("log-level", "info", "Log level: debug|info|warn|error") ) func setupLogger() { var level slog.Level switch strings.ToLower(*logLevel) { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))) } func main() { flag.Parse() setupLogger() if *password == "" { slog.Error("password flag is required (use -password=yourpassword)") os.Exit(1) } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() h := hub.New() db, err := recording.OpenDB(*dbPath) if err != nil { slog.Error("db open failed", "err", err) os.Exit(1) } defer db.Close() rec, err := recording.NewRecorder(db, h, *compress) if err != nil { slog.Error("recorder init failed", "err", err) os.Exit(1) } rec.Start() defer rec.Stop() // Retention cleanup: once at boot, then hourly. recording.CleanOld(db, h, *retention) go func() { t := time.NewTicker(time.Hour) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: recording.CleanOld(db, h, *retention) } } }() go server.ListenUDP(ctx, *udpAddr, h, rec) app := server.New(server.Options{ Password: *password, DBPath: *dbPath, Verbose: *verbose, Compress: *compress, Retention: *retention, DB: db, Hub: h, Recorder: rec, }) serverErrCh := server.Listen(app, *httpAddr, *tlsAddr, *domain, *certDir) select { case <-ctx.Done(): slog.Info("shutdown signal received") case err := <-serverErrCh: slog.Error("server error", "err", err) } shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) defer shutdownCancel() if err := app.ShutdownWithContext(shutdownCtx); err != nil { slog.Warn("app shutdown error", "err", err) } slog.Info("bye") }