134 lines
3.8 KiB
Go
134 lines
3.8 KiB
Go
// 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")
|
|
}
|