@@ -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, ¬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
|
||||
}
|
||||
Reference in New Issue
Block a user