170 lines
4.6 KiB
Go
170 lines
4.6 KiB
Go
// 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, ¬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
|
|
}
|