Release v1.0.0 #major
release / goreleaser (push) Failing after 51s

This commit is contained in:
2026-06-02 23:12:36 -05:00
parent 276981a7df
commit d5e65fbb03
17 changed files with 3447 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
// 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/fdestefano/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, &notnull, &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
}