package server import ( "database/sql" "strconv" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/websocket/v2" "log/slog" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/assets" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/hub" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/recording" "git.destefano.cloud/fdestefano/RocketLeagueBot-Renderer/internal/stats" ) // Options configures the Fiber app constructed by New. type Options struct { Password string DBPath string Verbose bool Compress bool Retention time.Duration DB *sql.DB Hub *hub.Hub Recorder *recording.Recorder } // New constructs the Fiber application with all middleware and routes wired. func New(opt Options) *fiber.App { app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Use(logger.New(logger.Config{ Format: "${time} ${method} ${path} ${status}\n", Next: func(c *fiber.Ctx) bool { if opt.Verbose { return false } // Suppress noisy WS access logs by default. path := c.Path() return len(path) >= 3 && path[:3] == "/ws" }, })) app.Use(AuthMiddleware(opt.Password)) app.Get("/", func(c *fiber.Ctx) error { data, err := assets.FS.ReadFile("viewer.html") if err != nil { return fiber.ErrInternalServerError } c.Set("Content-Type", "text/html; charset=utf-8") return c.Send(data) }) // Live WebSocket. app.Use("/ws", upgradeOnly()) app.Get("/ws", websocket.New(func(c *websocket.Conn) { cl := opt.Hub.Add(c) slog.Info("client connected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count()) defer func() { opt.Hub.Remove(cl) slog.Info("client disconnected", "addr", c.RemoteAddr().String(), "total", opt.Hub.Count()) }() for { if _, _, err := c.ReadMessage(); err != nil { return } } })) // Recording API. app.Get("/api/recordings", func(c *fiber.Ctx) error { since := c.Query("since", "") limit, _ := strconv.Atoi(c.Query("limit", "0")) recs, err := recording.List(opt.DB, since, limit) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(recs) }) app.Delete("/api/recordings/:id", func(c *fiber.Ctx) error { id, err := strconv.ParseInt(c.Params("id"), 10, 64) if err != nil { return c.Status(400).JSON(fiber.Map{"error": "invalid id"}) } n, err := recording.Delete(opt.DB, id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if n == 0 { return c.Status(404).JSON(fiber.Map{"error": "not found"}) } opt.Hub.PublishEvent(map[string]any{ "type": "event", "kind": "recording_deleted", "recording": map[string]any{"id": id}, }) return c.JSON(fiber.Map{"ok": true}) }) app.Get("/api/stats", statsHandler(opt)) // Playback websocket. app.Use("/ws/playback", upgradeOnly()) app.Get("/ws/playback", websocket.New(func(c *websocket.Conn) { idStr := c.Query("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { _ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"invalid id"}`)) return } slog.Info("playback start", "id", id, "addr", c.RemoteAddr().String()) p, err := recording.NewPlayer(opt.DB, c, id) if err != nil { _ = c.WriteMessage(websocket.TextMessage, []byte(`{"error":"player init failed"}`)) return } p.Run() slog.Info("playback end", "id", id, "addr", c.RemoteAddr().String()) })) return app } func upgradeOnly() fiber.Handler { return func(c *fiber.Ctx) error { if websocket.IsWebSocketUpgrade(c) { return c.Next() } return fiber.ErrUpgradeRequired } } func statsHandler(opt Options) fiber.Handler { return func(c *fiber.Ctx) error { dbSize := recording.DBSize(opt.DBPath) lastUDP := stats.Global.LastUDPPacketUnix.Load() var lastAgeMs int64 = -1 if lastUDP > 0 { lastAgeMs = (time.Now().UnixNano() - lastUDP) / int64(time.Millisecond) } before := stats.Global.BytesBeforeCompr.Load() after := stats.Global.BytesAfterCompr.Load() var compressionRatio float64 if after > 0 { compressionRatio = float64(before) / float64(after) } curID, curName, curFrames, curElapsed := opt.Recorder.CurrentSession() var current any if curID != 0 { current = map[string]any{ "id": curID, "name": curName, "frames": curFrames, "elapsed_ms": curElapsed, } } return c.JSON(fiber.Map{ "uptime_s": int64(time.Since(stats.Global.StartTime).Seconds()), "udp_packets_total": stats.Global.UDPPackets.Load(), "udp_bytes_total": stats.Global.UDPBytes.Load(), "udp_last_packet_age_ms": lastAgeMs, "ws_clients": opt.Hub.Count(), "frames_written_total": stats.Global.FramesWritten.Load(), "frames_dropped_total": stats.Global.FramesDropped.Load(), "bytes_before_compr": before, "bytes_after_compr": after, "compression_ratio": compressionRatio, "db_size_bytes": dbSize, "current_recording": current, "retention_hours": opt.Retention.Hours(), "compression_enabled": opt.Compress, }) } }