// Package recording handles persistence (SQLite), capture (Recorder), and // playback (Player) of UDP telemetry frames. package recording import ( "database/sql" "fmt" "log/slog" "os" "time" "git.destefano.cloud/texasmade/RocketLeagueBot-Renderer/internal/hub" ) // Info is the JSON shape returned by /api/recordings. type Info struct { ID int64 `json:"id"` Name string `json:"name"` CreatedAt string `json:"created_at"` Frames int `json:"frames"` DurationMs int64 `json:"duration_ms"` } // OpenDB opens (or creates) the SQLite database and ensures the schema is // up-to-date, performing any necessary additive migrations. func OpenDB(path string) (*sql.DB, error) { db, err := sql.Open("sqlite3", path+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)") if err != nil { return nil, fmt.Errorf("open: %w", err) } db.SetMaxOpenConns(4) if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, frames INTEGER DEFAULT 0, duration_ms INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS frames ( id INTEGER PRIMARY KEY AUTOINCREMENT, recording_id INTEGER NOT NULL, offset_ms INTEGER NOT NULL, data BLOB NOT NULL, FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_frames_recording ON frames(recording_id, offset_ms); CREATE INDEX IF NOT EXISTS idx_recordings_created_at ON recordings(created_at); `); err != nil { return nil, fmt.Errorf("create tables: %w", err) } if !columnExists(db, "frames", "codec") { if _, err := db.Exec(`ALTER TABLE frames ADD COLUMN codec INTEGER NOT NULL DEFAULT 0`); err != nil { return nil, fmt.Errorf("add codec column: %w", err) } slog.Info("migration: added frames.codec column") } return db, nil } func columnExists(db *sql.DB, table, col string) bool { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) if err != nil { return false } defer rows.Close() for rows.Next() { var cid int var name, ctype string var notnull, pk int var dflt sql.NullString if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { continue } if name == col { return true } } return false } // List returns recordings ordered newest-first. // since: optional RFC3339 timestamp lower bound (empty disables). // limit: max rows (0 = no limit). func List(db *sql.DB, since string, limit int) ([]Info, error) { q := `SELECT id, name, created_at, frames, duration_ms FROM recordings` args := []any{} if since != "" { if t, err := time.Parse(time.RFC3339, since); err == nil { q += ` WHERE created_at >= ?` args = append(args, t.UTC().Format("2006-01-02 15:04:05")) } } q += ` ORDER BY created_at DESC` if limit > 0 { q += ` LIMIT ?` args = append(args, limit) } rows, err := db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := []Info{} for rows.Next() { var r Info if err := rows.Scan(&r.ID, &r.Name, &r.CreatedAt, &r.Frames, &r.DurationMs); err != nil { continue } out = append(out, r) } return out, nil } // Delete removes a recording by ID. CASCADE drops associated frames. // Returns the rows affected. func Delete(db *sql.DB, id int64) (int64, error) { res, err := db.Exec(`DELETE FROM recordings WHERE id = ?`, id) if err != nil { return 0, err } n, _ := res.RowsAffected() return n, nil } // CleanOld removes recordings older than retention and emits deletion events // to the hub. func CleanOld(db *sql.DB, h *hub.Hub, retention time.Duration) { cutoff := time.Now().Add(-retention).UTC().Format("2006-01-02 15:04:05") rows, err := db.Query(`SELECT id FROM recordings WHERE created_at < ?`, cutoff) if err != nil { slog.Error("cleanup query error", "err", err) return } var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err == nil { ids = append(ids, id) } } rows.Close() if len(ids) == 0 { return } if _, err := db.Exec(`DELETE FROM recordings WHERE created_at < ?`, cutoff); err != nil { slog.Error("cleanup error", "err", err) return } slog.Info("cleaned old recordings", "n", len(ids)) for _, id := range ids { h.PublishEvent(map[string]any{ "type": "event", "kind": "recording_deleted", "recording": map[string]any{"id": id}, }) } } // DBSize returns the size of the database file in bytes, or 0 if it cannot // be stat'd. func DBSize(path string) int64 { if fi, err := os.Stat(path); err == nil { return fi.Size() } return 0 }