Add local-first refactor foundation

This commit is contained in:
2026-05-25 00:45:46 -04:00
parent 161e871c53
commit 517c6b987b
45 changed files with 11545 additions and 697 deletions

View File

@@ -38,6 +38,11 @@ func NewOpenF1ClientWithKey(url string, timeout time.Duration, apiKey string) *O
}
}
// BaseURL returns the configured OpenF1 API root URL.
func (c *OpenF1Client) BaseURL() string {
return c.url
}
// Cache returns the underlying Cache so callers can access track outline
// storage and other persistent data directly.
func (c *OpenF1Client) Cache() *Cache {

View File

@@ -89,6 +89,53 @@ func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
// FetchStrict performs a GET using the HTTP cache for fresh entries only.
// Unlike get(), it never falls back to expired cache on failure — intended
// for ingestion workflows that need authoritative responses or explicit errors.
func (c *OpenF1Client) FetchStrict(url string) ([]byte, error) {
if cachedData, ok := c.cache.Get(url); ok {
return cachedData, nil
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnauthorized {
var apiErr struct {
Detail string `json:"detail"`
}
if json.Unmarshal(data, &apiErr) == nil && apiErr.Detail != "" {
detail := strings.ToLower(apiErr.Detail)
if strings.Contains(detail, "live") && strings.Contains(detail, "session") {
return nil, fmt.Errorf("%w", ErrLiveSessionLocked)
}
return nil, fmt.Errorf("openf1 API: %s", apiErr.Detail)
}
}
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
}
_ = c.cache.Set(url, data)
return data, nil
}
// tryStale attempts to return stale cached data when a live request has failed.
// If stale data exists it sets the client's stale flag and returns the data.
// Otherwise it returns the original error unchanged so callers can handle it.

471
internal/ingest/ingest.go Normal file
View File

@@ -0,0 +1,471 @@
package ingest
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
// Options configures ingestion behavior.
type Options struct {
DryRun bool
RequestDelay time.Duration
MaxRetries int
RetryBackoff time.Duration
Progress *Progress
}
// DefaultOptions returns conservative ingestion defaults.
func DefaultOptions() Options {
return Options{
RequestDelay: 300 * time.Millisecond,
MaxRetries: 3,
RetryBackoff: 500 * time.Millisecond,
Progress: NewProgress(nil),
}
}
// Summary captures the outcome of an ingestion run.
type Summary struct {
ScopeType string `json:"scope_type"`
ScopeKey string `json:"scope_key"`
Status string `json:"status"`
DryRun bool `json:"dry_run"`
Meetings int `json:"meetings"`
Sessions int `json:"sessions"`
Drivers int `json:"drivers"`
SessionResults int `json:"session_results"`
StartingGrid int `json:"starting_grid"`
RawPayloads int `json:"raw_payloads"`
RawInserted int `json:"raw_inserted"`
Errors []string `json:"errors,omitempty"`
}
// Service orchestrates OpenF1-to-store ingestion workflows.
type Service struct {
store *store.Store
source Source
opts Options
}
// NewService creates an ingestion service.
func NewService(st *store.Store, source Source, opts Options) *Service {
if opts.MaxRetries <= 0 {
opts.MaxRetries = 3
}
if opts.RetryBackoff <= 0 {
opts.RetryBackoff = 500 * time.Millisecond
}
if opts.RequestDelay <= 0 {
opts.RequestDelay = 300 * time.Millisecond
}
if opts.Progress == nil {
opts.Progress = NewProgress(nil)
}
return &Service{store: st, source: source, opts: opts}
}
// IngestYear fetches and stores all meetings for a season year.
func (s *Service) IngestYear(year int) (Summary, error) {
summary := Summary{
ScopeType: "year",
ScopeKey: fmt.Sprintf("%d", year),
DryRun: s.opts.DryRun,
}
if year < 2023 {
return summary, fmt.Errorf("invalid year %d: must be 2023 or later", year)
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching meetings for %d", year)
fetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForYear(year)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(fetch, nil, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, m := range meetings {
if s.opts.DryRun {
summary.Meetings++
continue
}
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
// IngestMeeting fetches meeting metadata and all sessions for a meeting key.
func (s *Service) IngestMeeting(meetingKey int) (Summary, error) {
summary := Summary{
ScopeType: "meeting",
ScopeKey: fmt.Sprintf("%d", meetingKey),
DryRun: s.opts.DryRun,
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching meeting %d", meetingKey)
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForMeetingKey(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
mk := meetingKey
inserted, err := s.storeRaw(meetingFetch, &mk, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, m := range meetings {
if s.opts.DryRun {
summary.Meetings++
continue
}
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
s.opts.Progress.Step("fetching sessions for meeting %d", meetingKey)
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForMeeting(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
mk := meetingKey
inserted, err := s.storeRaw(sessionFetch, &mk, nil)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
for _, sess := range sessions {
if s.opts.DryRun {
summary.Sessions++
continue
}
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Sessions++
}
summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
// IngestSession ingests Race Hub v1 datasets for a single session.
func (s *Service) IngestSession(sessionKey int) (Summary, error) {
summary := Summary{
ScopeType: "session",
ScopeKey: fmt.Sprintf("%d", sessionKey),
DryRun: s.opts.DryRun,
}
runID, err := s.beginRun(summary.ScopeType, summary.ScopeKey)
if err != nil {
return summary, err
}
s.opts.Progress.Step("fetching session %d", sessionKey)
sessionFetch, sessions, err := fetchWithRetry(s, func() (FetchResult, []models.Session, error) {
return s.source.FetchSessionsForSessionKey(sessionKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
if len(sessions) == 0 {
err := fmt.Errorf("session %d not found", sessionKey)
return s.finishFailed(runID, summary, err)
}
sess := sessions[0]
meetingKey := sess.MeetingKey
sk := sessionKey
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(sessionFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
}
s.delay()
s.opts.Progress.Step("fetching meeting %d for session context", meetingKey)
meetingFetch, meetings, err := fetchWithRetry(s, func() (FetchResult, []models.Meeting, error) {
return s.source.FetchMeetingsForMeetingKey(meetingKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(meetingFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
for _, m := range meetings {
if err := s.store.UpsertMeeting(meetingToStore(m)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Meetings++
}
} else {
summary.Meetings = len(meetings)
}
s.delay()
if s.opts.DryRun {
summary.Sessions++
} else {
if err := s.store.UpsertSession(sessionToStore(sess)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Sessions++
}
s.opts.Progress.Step("fetching drivers for session %d", sessionKey)
driverFetch, drivers, err := fetchWithRetry(s, func() (FetchResult, []models.Driver, error) {
return s.source.FetchDriversForSession(sessionKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(driverFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
for _, d := range drivers {
if err := s.store.UpsertDriver(driverToStore(d)); err != nil {
return s.finishFailed(runID, summary, err)
}
if err := s.store.UpsertSessionDriver(sessionDriverToStore(d)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.Drivers++
}
} else {
summary.Drivers = len(drivers)
}
s.delay()
s.opts.Progress.Step("fetching session results for session %d", sessionKey)
resultFetch, results, err := fetchWithRetry(s, func() (FetchResult, []models.SessionResult, error) {
return s.source.FetchSessionResult(sessionKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(resultFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
for _, r := range results {
if err := s.store.UpsertSessionResult(sessionResultToStore(r)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.SessionResults++
}
} else {
summary.SessionResults = len(results)
}
s.delay()
s.opts.Progress.Step("fetching starting grid for session %d", sessionKey)
gridFetch, grid, err := fetchWithRetry(s, func() (FetchResult, []models.StartingGrid, error) {
return s.source.FetchStartingGrid(sessionKey)
})
if err != nil {
return s.finishFailed(runID, summary, err)
}
summary.RawPayloads++
if !s.opts.DryRun {
inserted, err := s.storeRaw(gridFetch, &meetingKey, &sk)
if err != nil {
return s.finishFailed(runID, summary, err)
}
if inserted {
summary.RawInserted++
}
for _, g := range grid {
if err := s.store.UpsertStartingGridEntry(startingGridToStore(g)); err != nil {
return s.finishFailed(runID, summary, err)
}
summary.StartingGrid++
}
} else {
summary.StartingGrid = len(grid)
}
summary.Status = statusForDryRun(s.opts.DryRun)
s.finishRun(runID, summary)
s.opts.Progress.Summary(summary)
return summary, nil
}
func (s *Service) beginRun(scopeType, scopeKey string) (int64, error) {
if s.opts.DryRun {
return 0, nil
}
return s.store.CreateIngestionRun(scopeType, scopeKey, false)
}
func (s *Service) finishRun(runID int64, summary Summary) {
if s.opts.DryRun || runID == 0 {
return
}
b, _ := json.Marshal(summary)
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
}
func (s *Service) finishFailed(runID int64, summary Summary, err error) (Summary, error) {
summary.Status = "failed"
summary.Errors = append(summary.Errors, err.Error())
if runID != 0 && !s.opts.DryRun {
b, _ := json.Marshal(summary)
_ = s.store.FinishIngestionRun(runID, summary.Status, string(b))
}
s.opts.Progress.Summary(summary)
return summary, err
}
func (s *Service) storeRaw(fetch FetchResult, meetingKey, sessionKey *int) (bool, error) {
_, inserted, err := s.store.InsertRawPayload(store.RawPayload{
Source: sourceOpenF1,
Endpoint: fetch.Endpoint,
RequestKey: fetch.RequestKey,
MeetingKey: meetingKey,
SessionKey: sessionKey,
Payload: string(fetch.Body),
FetchedAt: fetch.FetchedAt,
ProvenanceJSON: provenanceJSON(fetch),
})
return inserted, err
}
func (s *Service) delay() {
if s.opts.RequestDelay > 0 {
time.Sleep(s.opts.RequestDelay)
}
}
func statusForDryRun(dryRun bool) string {
if dryRun {
return "dry_run"
}
return "completed"
}
type fetchFunc[T any] func() (FetchResult, T, error)
func fetchWithRetry[T any](s *Service, fn fetchFunc[T]) (FetchResult, T, error) {
var zero T
var lastErr error
for attempt := 0; attempt < s.opts.MaxRetries; attempt++ {
if attempt > 0 {
time.Sleep(s.opts.RetryBackoff * time.Duration(attempt))
}
fetch, data, err := fn()
if err == nil {
return fetch, data, nil
}
lastErr = err
if api.IsLiveSessionError(err) {
return FetchResult{}, zero, err
}
if !isRetryable(err) {
return FetchResult{}, zero, err
}
}
return FetchResult{}, zero, lastErr
}
func isRetryable(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "status 429") ||
strings.Contains(msg, "status 5") ||
strings.Contains(msg, "timeout") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "temporary") {
return true
}
var netErr interface{ Timeout() bool }
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return false
}

View File

@@ -0,0 +1,381 @@
package ingest
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
type fakeSource struct {
meetingsByYear map[int][]models.Meeting
meetingsByKey map[int][]models.Meeting
sessionsByMeeting map[int][]models.Session
sessionsByKey map[int][]models.Session
drivers map[int][]models.Driver
results map[int][]models.SessionResult
grid map[int][]models.StartingGrid
failOn string
liveLockout bool
}
func newFakeSource() *fakeSource {
return &fakeSource{
meetingsByYear: make(map[int][]models.Meeting),
meetingsByKey: make(map[int][]models.Meeting),
sessionsByMeeting: make(map[int][]models.Session),
sessionsByKey: make(map[int][]models.Session),
drivers: make(map[int][]models.Driver),
results: make(map[int][]models.SessionResult),
grid: make(map[int][]models.StartingGrid),
}
}
func (f *fakeSource) maybeFail(endpoint string) error {
if f.liveLockout {
return fmt.Errorf("%w", api.ErrLiveSessionLocked)
}
if f.failOn == endpoint {
return errors.New("simulated fetch failure")
}
return nil
}
func (f *fakeSource) wrap(endpoint, requestKey string, payload any) FetchResult {
body, _ := json.Marshal(payload)
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: "fake://" + endpoint + "?" + requestKey,
Body: body,
FetchedAt: time.Now(),
}
}
func (f *fakeSource) FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error) {
if err := f.maybeFail("meetings"); err != nil {
return FetchResult{}, nil, err
}
data := f.meetingsByYear[year]
return f.wrap("meetings", fmt.Sprintf("year=%d", year), data), data, nil
}
func (f *fakeSource) FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error) {
if err := f.maybeFail("meetings"); err != nil {
return FetchResult{}, nil, err
}
data := f.meetingsByKey[meetingKey]
return f.wrap("meetings", fmt.Sprintf("meeting_key=%d", meetingKey), data), data, nil
}
func (f *fakeSource) FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error) {
if err := f.maybeFail("sessions"); err != nil {
return FetchResult{}, nil, err
}
data := f.sessionsByMeeting[meetingKey]
return f.wrap("sessions", fmt.Sprintf("meeting_key=%d", meetingKey), data), data, nil
}
func (f *fakeSource) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error) {
if err := f.maybeFail("sessions"); err != nil {
return FetchResult{}, nil, err
}
data := f.sessionsByKey[sessionKey]
return f.wrap("sessions", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
if err := f.maybeFail("drivers"); err != nil {
return FetchResult{}, nil, err
}
data := f.drivers[sessionKey]
return f.wrap("drivers", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
if err := f.maybeFail("session_result"); err != nil {
return FetchResult{}, nil, err
}
data := f.results[sessionKey]
return f.wrap("session_result", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func (f *fakeSource) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
if err := f.maybeFail("starting_grid"); err != nil {
return FetchResult{}, nil, err
}
data := f.grid[sessionKey]
return f.wrap("starting_grid", fmt.Sprintf("session_key=%d", sessionKey), data), data, nil
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "ingest.db")
s, err := store.Open(path)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func testSessionFixtures() (int, int, *fakeSource) {
const meetingKey = 1229
const sessionKey = 9472
src := newFakeSource()
src.meetingsByKey[meetingKey] = []models.Meeting{{
MeetingKey: meetingKey,
MeetingName: "Monaco",
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
Location: "Monaco",
CountryCode: "MON",
CountryName: "Monaco",
Circuit: models.Circuit{
CircuitKey: 10,
CircuitShortName: "Monte Carlo",
},
Year: 2025,
}}
src.sessionsByKey[sessionKey] = []models.Session{{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
CircuitKey: 10,
}}
src.drivers[sessionKey] = []models.Driver{
{
DriverNumber: 1,
FullName: "Max Verstappen",
SessionKey: sessionKey,
MeetingKey: meetingKey,
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
},
{
DriverNumber: 44,
FullName: "Lewis Hamilton",
SessionKey: sessionKey,
MeetingKey: meetingKey,
TeamName: "Ferrari",
TeamColour: "E8002D",
},
}
src.results[sessionKey] = []models.SessionResult{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, Points: 25, NumberOfLaps: 78},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, Points: 18, NumberOfLaps: 78, GapToLeader: 1.5},
}
src.grid[sessionKey] = []models.StartingGrid{
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 1, Position: 1, LapDuration: 71.234},
{SessionKey: sessionKey, MeetingKey: meetingKey, DriverNumber: 44, Position: 2, LapDuration: 71.456},
}
return meetingKey, sessionKey, src
}
func TestIngestSessionWritesDomainAndRawRows(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
summary, err := svc.IngestSession(sessionKey)
if err != nil {
t.Fatalf("IngestSession() error = %v", err)
}
if summary.Status != "completed" {
t.Fatalf("summary.Status = %q, want completed", summary.Status)
}
if summary.Drivers != 2 || summary.SessionResults != 2 || summary.StartingGrid != 2 {
t.Fatalf("summary counts = %+v, want 2 drivers/results/grid", summary)
}
if summary.RawPayloads != 5 {
t.Fatalf("summary.RawPayloads = %d, want 5", summary.RawPayloads)
}
drivers, err := st.ListSessionDrivers(sessionKey)
if err != nil {
t.Fatalf("ListSessionDrivers() error = %v", err)
}
if len(drivers) != 2 {
t.Fatalf("session drivers = %d, want 2", len(drivers))
}
results, err := st.ListSessionResults(sessionKey)
if err != nil {
t.Fatalf("ListSessionResults() error = %v", err)
}
if len(results) != 2 {
t.Fatalf("session results = %d, want 2", len(results))
}
grid, err := st.ListStartingGrid(sessionKey)
if err != nil {
t.Fatalf("ListStartingGrid() error = %v", err)
}
if len(grid) != 2 {
t.Fatalf("starting grid = %d, want 2", len(grid))
}
raw, err := st.ListRawPayloadsBySession(sessionKey)
if err != nil {
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
}
if len(raw) != 5 {
t.Fatalf("raw payloads = %d, want 5", len(raw))
}
}
func TestIngestSessionIsIdempotent(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
if _, err := svc.IngestSession(sessionKey); err != nil {
t.Fatalf("first IngestSession() error = %v", err)
}
if _, err := svc.IngestSession(sessionKey); err != nil {
t.Fatalf("second IngestSession() error = %v", err)
}
count := func(query string) int {
var n int
if err := st.DB().QueryRow(query, sessionKey).Scan(&n); err != nil {
t.Fatalf("count query failed: %v", err)
}
return n
}
if got := count(`SELECT COUNT(*) FROM session_drivers WHERE session_key = ?`); got != 2 {
t.Fatalf("session_drivers count = %d, want 2", got)
}
if got := count(`SELECT COUNT(*) FROM session_results WHERE session_key = ?`); got != 2 {
t.Fatalf("session_results count = %d, want 2", got)
}
if got := count(`SELECT COUNT(*) FROM starting_grid WHERE session_key = ?`); got != 2 {
t.Fatalf("starting_grid count = %d, want 2", got)
}
}
func TestDryRunDoesNotWriteDomainRows(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
st := openTestStore(t)
opts := DefaultOptions()
opts.DryRun = true
opts.RequestDelay = 0
svc := NewService(st, src, opts)
summary, err := svc.IngestSession(sessionKey)
if err != nil {
t.Fatalf("IngestSession() error = %v", err)
}
if summary.Status != "dry_run" {
t.Fatalf("summary.Status = %q, want dry_run", summary.Status)
}
var driverCount int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM drivers`).Scan(&driverCount); err != nil {
t.Fatalf("count drivers: %v", err)
}
if driverCount != 0 {
t.Fatalf("drivers written during dry-run = %d, want 0", driverCount)
}
var rawCount int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM raw_payloads`).Scan(&rawCount); err != nil {
t.Fatalf("count raw payloads: %v", err)
}
if rawCount != 0 {
t.Fatalf("raw payloads written during dry-run = %d, want 0", rawCount)
}
}
func TestSourceErrorStopsRun(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
src.failOn = "session_result"
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
summary, err := svc.IngestSession(sessionKey)
if err == nil {
t.Fatal("IngestSession() expected error, got nil")
}
if summary.Status != "failed" {
t.Fatalf("summary.Status = %q, want failed", summary.Status)
}
var resultCount int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM session_results WHERE session_key = ?`, sessionKey).Scan(&resultCount); err != nil {
t.Fatalf("count session_results: %v", err)
}
if resultCount != 0 {
t.Fatalf("session_results after failure = %d, want 0", resultCount)
}
}
func TestLiveSessionLockoutSurfacesControlledFailure(t *testing.T) {
_, sessionKey, src := testSessionFixtures()
src.liveLockout = true
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
_, err := svc.IngestSession(sessionKey)
if err == nil {
t.Fatal("IngestSession() expected live lockout error, got nil")
}
if !api.IsLiveSessionError(err) {
t.Fatalf("error = %v, want live session lockout", err)
}
}
func TestIngestYearAndMeeting(t *testing.T) {
src := newFakeSource()
src.meetingsByYear[2025] = []models.Meeting{
{MeetingKey: 100, MeetingName: "Bahrain", Year: 2025},
{MeetingKey: 101, MeetingName: "Saudi Arabia", Year: 2025},
}
src.meetingsByKey[100] = src.meetingsByYear[2025][:1]
src.sessionsByMeeting[100] = []models.Session{
{SessionKey: 9001, MeetingKey: 100, SessionName: "Race", SessionType: "Race"},
}
st := openTestStore(t)
opts := DefaultOptions()
opts.RequestDelay = 0
svc := NewService(st, src, opts)
yearSummary, err := svc.IngestYear(2025)
if err != nil {
t.Fatalf("IngestYear() error = %v", err)
}
if yearSummary.Meetings != 2 {
t.Fatalf("year meetings = %d, want 2", yearSummary.Meetings)
}
meetingSummary, err := svc.IngestMeeting(100)
if err != nil {
t.Fatalf("IngestMeeting() error = %v", err)
}
if meetingSummary.Sessions != 1 {
t.Fatalf("meeting sessions = %d, want 1", meetingSummary.Sessions)
}
}

270
internal/ingest/openf1.go Normal file
View File

@@ -0,0 +1,270 @@
package ingest
import (
"encoding/json"
"fmt"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/store"
)
const sourceOpenF1 = "openf1"
// FetchResult holds a fetched OpenF1 endpoint response with provenance metadata.
type FetchResult struct {
Endpoint string
RequestKey string
URL string
Body []byte
FetchedAt time.Time
}
// Source fetches OpenF1 REST data for ingestion workflows.
type Source interface {
FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error)
FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error)
FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error)
FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error)
FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error)
FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error)
FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error)
}
// OpenF1Source adapts OpenF1Client for ingestion using strict fetches.
type OpenF1Source struct {
client *api.OpenF1Client
}
// NewOpenF1Source returns a Source backed by the OpenF1 API client.
func NewOpenF1Source(client *api.OpenF1Client) *OpenF1Source {
return &OpenF1Source{client: client}
}
func (s *OpenF1Source) FetchMeetingsForYear(year int) (FetchResult, []models.Meeting, error) {
url := fmt.Sprintf("%s/v1/meetings?year=%d", s.client.BaseURL(), year)
return s.fetchMeetings(url, "meetings", fmt.Sprintf("year=%d", year))
}
func (s *OpenF1Source) FetchMeetingsForMeetingKey(meetingKey int) (FetchResult, []models.Meeting, error) {
url := fmt.Sprintf("%s/v1/meetings?meeting_key=%d", s.client.BaseURL(), meetingKey)
return s.fetchMeetings(url, "meetings", fmt.Sprintf("meeting_key=%d", meetingKey))
}
func (s *OpenF1Source) FetchSessionsForMeeting(meetingKey int) (FetchResult, []models.Session, error) {
url := fmt.Sprintf("%s/v1/sessions?meeting_key=%d", s.client.BaseURL(), meetingKey)
return s.fetchSessions(url, "sessions", fmt.Sprintf("meeting_key=%d", meetingKey))
}
func (s *OpenF1Source) FetchSessionsForSessionKey(sessionKey int) (FetchResult, []models.Session, error) {
url := fmt.Sprintf("%s/v1/sessions?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchSessions(url, "sessions", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchDriversForSession(sessionKey int) (FetchResult, []models.Driver, error) {
url := fmt.Sprintf("%s/v1/drivers?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchDrivers(url, "drivers", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchSessionResult(sessionKey int) (FetchResult, []models.SessionResult, error) {
url := fmt.Sprintf("%s/v1/session_result?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchSessionResults(url, "session_result", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) FetchStartingGrid(sessionKey int) (FetchResult, []models.StartingGrid, error) {
url := fmt.Sprintf("%s/v1/starting_grid?session_key=%d", s.client.BaseURL(), sessionKey)
return s.fetchStartingGrid(url, "starting_grid", fmt.Sprintf("session_key=%d", sessionKey))
}
func (s *OpenF1Source) fetchMeetings(url, endpoint, requestKey string) (FetchResult, []models.Meeting, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Meeting
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchSessions(url, endpoint, requestKey string) (FetchResult, []models.Session, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Session
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchDrivers(url, endpoint, requestKey string) (FetchResult, []models.Driver, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.Driver
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchSessionResults(url, endpoint, requestKey string) (FetchResult, []models.SessionResult, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.SessionResult
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func (s *OpenF1Source) fetchStartingGrid(url, endpoint, requestKey string) (FetchResult, []models.StartingGrid, error) {
body, err := s.client.FetchStrict(url)
if err != nil {
return FetchResult{}, nil, err
}
var result []models.StartingGrid
if err := json.Unmarshal(body, &result); err != nil {
return FetchResult{}, nil, err
}
return FetchResult{
Endpoint: endpoint,
RequestKey: requestKey,
URL: url,
Body: body,
FetchedAt: time.Now(),
}, result, nil
}
func meetingToStore(m models.Meeting) store.Meeting {
return store.Meeting{
MeetingKey: int(m.MeetingKey),
MeetingName: m.MeetingName,
MeetingOfficialName: m.MeetingOfficialName,
Location: m.Location,
CountryCode: m.CountryCode,
CountryName: m.CountryName,
CircuitKey: m.CircuitKey,
CircuitShortName: m.CircuitShortName,
GMTOffset: m.GMTOffset,
DateStart: m.DateStart,
DateEnd: m.DateEnd,
Year: m.Year,
}
}
func sessionToStore(s models.Session) store.Session {
return store.Session{
SessionKey: s.SessionKey,
MeetingKey: s.MeetingKey,
SessionName: s.SessionName,
SessionType: s.SessionType,
CircuitKey: s.CircuitKey,
DateStart: s.DateStart,
DateEnd: s.DateEnd,
GMTOffset: s.GMTOffset,
}
}
func driverToStore(d models.Driver) store.Driver {
return store.Driver{
DriverNumber: d.DriverNumber,
BroadcastName: d.BroadcastName,
FirstName: d.FirstName,
FullName: d.FullName,
LastName: d.LastName,
NameAcronym: d.NameAcronym,
HeadshotURL: d.HeadshotURL,
TeamName: d.TeamName,
TeamColour: d.TeamColour,
}
}
func sessionDriverToStore(d models.Driver) store.SessionDriver {
return store.SessionDriver{
SessionKey: d.SessionKey,
DriverNumber: d.DriverNumber,
MeetingKey: d.MeetingKey,
TeamName: d.TeamName,
TeamColour: d.TeamColour,
}
}
func sessionResultToStore(r models.SessionResult) store.SessionResult {
return store.SessionResult{
SessionKey: r.SessionKey,
DriverNumber: r.DriverNumber,
MeetingKey: r.MeetingKey,
Position: r.Position,
Points: r.Points,
NumberOfLaps: r.NumberOfLaps,
DurationJSON: jsonField(r.Duration),
GapToLeaderJSON: jsonField(r.GapToLeader),
DNF: r.DNF,
DNS: r.DNS,
DSQ: r.DSQ,
}
}
func startingGridToStore(g models.StartingGrid) store.StartingGridEntry {
return store.StartingGridEntry{
SessionKey: g.SessionKey,
DriverNumber: g.DriverNumber,
MeetingKey: g.MeetingKey,
Position: g.Position,
LapDuration: g.LapDuration,
}
}
func jsonField(v any) string {
if v == nil {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return ""
}
return string(b)
}
func provenanceJSON(fetch FetchResult) string {
meta := map[string]string{
"url": fetch.URL,
}
b, err := json.Marshal(meta)
if err != nil {
return ""
}
return string(b)
}

View File

@@ -0,0 +1,49 @@
package ingest
import (
"fmt"
"io"
"os"
)
// Progress reports ingestion progress to a writer.
type Progress struct {
w io.Writer
}
// NewProgress returns a progress helper writing to w, or stderr when w is nil.
func NewProgress(w io.Writer) *Progress {
if w == nil {
w = os.Stderr
}
return &Progress{w: w}
}
func (p *Progress) Step(format string, args ...any) {
fmt.Fprintf(p.w, "ingest: "+format+"\n", args...)
}
func (p *Progress) Summary(summary Summary) {
fmt.Fprintf(p.w, "\ningest summary (%s %s): status=%s\n", summary.ScopeType, summary.ScopeKey, summary.Status)
if summary.Meetings > 0 {
fmt.Fprintf(p.w, " meetings: %d\n", summary.Meetings)
}
if summary.Sessions > 0 {
fmt.Fprintf(p.w, " sessions: %d\n", summary.Sessions)
}
if summary.Drivers > 0 {
fmt.Fprintf(p.w, " drivers: %d\n", summary.Drivers)
}
if summary.SessionResults > 0 {
fmt.Fprintf(p.w, " session results: %d\n", summary.SessionResults)
}
if summary.StartingGrid > 0 {
fmt.Fprintf(p.w, " starting grid: %d\n", summary.StartingGrid)
}
if summary.RawPayloads > 0 {
fmt.Fprintf(p.w, " raw payloads fetched: %d (inserted: %d)\n", summary.RawPayloads, summary.RawInserted)
}
for _, errMsg := range summary.Errors {
fmt.Fprintf(p.w, " error: %s\n", errMsg)
}
}

View File

@@ -0,0 +1,323 @@
package live_test
import (
"encoding/json"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
)
func TestProcessMessageFullState(t *testing.T) {
state := live.NewState()
msg := []byte(`{
"R": {
"TimingData": {"Lines": {"1": {"Position": "1", "RacingNumber": "1", "LastLapTime": {"Value": "1:32.456"}}}},
"DriverList": {"1": {"RacingNumber": "1", "Tla": "VER", "TeamName": "Red Bull"}},
"LapCount": {"CurrentLap": 12, "TotalLaps": 57},
"TrackStatus": {"Status": "1", "Message": "AllClear"},
"WeatherData": {"AirTemp": "24.5", "TrackTemp": "38.0", "Humidity": "55", "WindSpeed": "2.1", "WindDirection": "180", "Rainfall": "0"},
"SessionInfo": {"Meeting": {"Name": "Monaco Grand Prix"}, "Name": "Race", "Type": "Race"},
"ExtrapolatedClock": {"Remaining": "0:45:00", "Utc": "2025-05-25T14:00:00Z", "Extrapolating": true}
}
}`)
if !state.ProcessMessage(msg) {
t.Fatal("expected full-state message to produce updates")
}
snap := state.Snapshot()
if snap.Drivers["1"].Position != 1 {
t.Errorf("driver position = %d, want 1", snap.Drivers["1"].Position)
}
if snap.Drivers["1"].LastLapTime != "1:32.456" {
t.Errorf("last lap = %q, want 1:32.456", snap.Drivers["1"].LastLapTime)
}
if snap.DriverInfo["1"].Tla != "VER" {
t.Errorf("TLA = %q, want VER", snap.DriverInfo["1"].Tla)
}
if snap.CurrentLap != 12 || snap.TotalLaps != 57 {
t.Errorf("laps = %d/%d, want 12/57", snap.CurrentLap, snap.TotalLaps)
}
if snap.TrackStatus != "1" {
t.Errorf("track status = %q, want 1", snap.TrackStatus)
}
if snap.Weather.AirTemp != 24.5 || snap.Weather.TrackTemp != 38.0 {
t.Errorf("weather temps = %.1f/%.1f, want 24.5/38.0", snap.Weather.AirTemp, snap.Weather.TrackTemp)
}
if snap.Session.MeetingName != "Monaco Grand Prix" || snap.Session.SessionName != "Race" {
t.Errorf("session = %+v", snap.Session)
}
if snap.Clock != "0:45:00" || !snap.ClockExtrapolating {
t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating)
}
}
func TestProcessMessageIncremental(t *testing.T) {
state := live.NewState()
msg := []byte(`{
"M": [{
"A": ["TimingData", {"Lines": {"44": {"Position": "2", "GapToLeader": "+1.234", "IntervalToPositionAhead": {"Value": "+0.456"}}}}]
}]
}`)
if !state.ProcessMessage(msg) {
t.Fatal("expected incremental message to produce updates")
}
d := state.Snapshot().Drivers["44"]
if d.Position != 2 {
t.Errorf("position = %d, want 2", d.Position)
}
if d.GapToLeader != "+1.234" {
t.Errorf("gap = %q, want +1.234", d.GapToLeader)
}
if d.Interval != "+0.456" {
t.Errorf("interval = %q, want +0.456", d.Interval)
}
}
func TestProcessTopicTimingData(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Lines": {
"16": {
"Position": 3,
"GapToLeader": 2.5,
"NumberOfLaps": "15",
"Sectors": {
"0": {"Value": "28.123", "PersonalFastest": true},
"1": {"Value": "31.456"},
"2": {"Value": ""}
},
"Speeds": {"ST": {"Value": "312"}}
}
}
}`)
if !state.ProcessTopic("TimingData", data) {
t.Fatal("TimingData should update state")
}
d := state.Snapshot().Drivers["16"]
if d.Position != 3 {
t.Errorf("position = %d, want 3", d.Position)
}
if d.GapToLeader != "+2.500" {
t.Errorf("gap = %q, want +2.500", d.GapToLeader)
}
if d.NumberOfLaps != 15 {
t.Errorf("laps = %d, want 15", d.NumberOfLaps)
}
if d.Sectors[0].Value != "28.123" || !d.Sectors[0].PersonalFastest {
t.Errorf("sector 0 = %+v", d.Sectors[0])
}
if d.Sectors[2].Value != "" {
t.Errorf("sector 2 should be cleared, got %q", d.Sectors[2].Value)
}
if !d.OnFlyingLap {
t.Error("expected OnFlyingLap=true when S1/S2 set and S3 empty")
}
if d.SpeedTrap != "312" {
t.Errorf("speed trap = %q, want 312", d.SpeedTrap)
}
}
func TestProcessTopicDriverList(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"63": {"RacingNumber": "63", "Tla": "RUS", "TeamName": "Mercedes", "TeamColour": "27F4D2"}}`)
state.ProcessTopic("DriverList", data)
if len(state.Snapshot().DriverInfo) != 1 {
t.Fatalf("expected 1 driver info entry")
}
// Entries without TLA are ignored.
data2 := json.RawMessage(`{"99": {"RacingNumber": "99", "TeamName": "Unknown"}}`)
state.ProcessTopic("DriverList", data2)
if _, ok := state.Snapshot().DriverInfo["99"]; ok {
t.Error("driver without TLA should be ignored")
}
}
func TestProcessTopicLapCount(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"CurrentLap": "5", "TotalLaps": "78"}`)
state.ProcessTopic("LapCount", data)
snap := state.Snapshot()
if snap.CurrentLap != 5 || snap.TotalLaps != 78 {
t.Errorf("laps = %d/%d, want 5/78", snap.CurrentLap, snap.TotalLaps)
}
}
func TestProcessTopicExtrapolatedClock(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{"Remaining": "1:00:00", "Utc": "2025-05-25T15:04:05.123Z", "Extrapolating": true}`)
state.ProcessTopic("ExtrapolatedClock", data)
snap := state.Snapshot()
if snap.Clock != "1:00:00" || !snap.ClockExtrapolating {
t.Errorf("clock = %q extrapolating=%v", snap.Clock, snap.ClockExtrapolating)
}
want := time.Date(2025, 5, 25, 15, 4, 5, 123000000, time.UTC)
if !snap.ClockRefTime.Equal(want) {
t.Errorf("ClockRefTime = %v, want %v", snap.ClockRefTime, want)
}
}
func TestProcessTopicTrackStatus(t *testing.T) {
state := live.NewState()
state.ProcessTopic("TrackStatus", json.RawMessage(`{"Status": "4", "Message": "SC DEPLOYED"}`))
if state.Snapshot().TrackStatus != "4" {
t.Errorf("track status = %q, want 4", state.Snapshot().TrackStatus)
}
}
func TestProcessTopicRaceControlMessages(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Messages": {
"1": {"Utc": "2025-05-25T15:04:30Z", "Category": "Flag", "Flag": "YELLOW", "Message": "Yellow in sector 2", "Lap": 8}
}
}`)
state.ProcessTopic("RaceControlMessages", data)
rc := state.Snapshot().RCMessages
if len(rc) != 1 {
t.Fatalf("expected 1 RC message, got %d", len(rc))
}
if rc[0].Time != "15:04" || rc[0].Flag != "YELLOW" || rc[0].Message != "Yellow in sector 2" || rc[0].Lap != 8 {
t.Errorf("RC message = %+v", rc[0])
}
}
func TestProcessTopicWeatherData(t *testing.T) {
state := live.NewState()
state.ProcessTopic("WeatherData", json.RawMessage(`{
"AirTemp": 22, "TrackTemp": 35, "Humidity": 60, "WindSpeed": 3.5, "WindDirection": 90, "Rainfall": 1
}`))
w := state.Snapshot().Weather
if w.AirTemp != 22 || w.TrackTemp != 35 || w.Humidity != 60 || w.WindSpeed != 3.5 || w.WindDir != 90 || !w.Rainfall {
t.Errorf("weather = %+v", w)
}
}
func TestProcessTopicSessionInfo(t *testing.T) {
state := live.NewState()
state.ProcessTopic("SessionInfo", json.RawMessage(`{
"Meeting": {"Name": "British Grand Prix"},
"Name": "Qualifying",
"Type": "Qualifying"
}`))
s := state.Snapshot().Session
if s.MeetingName != "British Grand Prix" || s.SessionName != "Qualifying" || s.SessionType != "Qualifying" {
t.Errorf("session = %+v", s)
}
}
func TestProcessTopicCurrentTyres(t *testing.T) {
state := live.NewState()
state.Tyres["1"] = live.LiveTyreData{Age: 7}
state.ProcessTopic("CurrentTyres", json.RawMessage(`{
"1": {"Compound": "SOFT", "New": "true"},
"_kf": {"Compound": "ignore"}
}`))
tyre := state.Snapshot().Tyres["1"]
if tyre.Compound != "SOFT" || !tyre.New {
t.Errorf("tyre = %+v", tyre)
}
if tyre.Age != 7 {
t.Errorf("age should be preserved from prior state, got %d", tyre.Age)
}
}
func TestProcessTopicTimingAppData(t *testing.T) {
state := live.NewState()
data := json.RawMessage(`{
"Lines": {
"4": {
"Stints": {
"0": {"Compound": "MEDIUM", "New": "true", "TotalLaps": 0},
"1": {"Compound": "HARD", "New": "false", "TotalLaps": 18}
}
}
}
}`)
state.ProcessTopic("TimingAppData", data)
snap := state.Snapshot()
stints := snap.Stints["4"]
if len(stints) != 2 {
t.Fatalf("expected 2 stints, got %d", len(stints))
}
tyre := snap.Tyres["4"]
if tyre.Compound != "HARD" || tyre.Age != 18 || tyre.New {
t.Errorf("tyre synced from stint = %+v", tyre)
}
}
func TestProcessTopicTimingStats(t *testing.T) {
state := live.NewState()
state.Drivers["55"] = live.LiveDriverData{RacingNumber: "55"}
state.ProcessTopic("TimingStats", json.RawMessage(`{
"Lines": {"55": {"PersonalBestLapTime": {"Value": "1:28.999"}}}
}`))
if state.Snapshot().Drivers["55"].BestLapTime != "1:28.999" {
t.Errorf("best lap = %q", state.Snapshot().Drivers["55"].BestLapTime)
}
}
func TestProcessTopicUnknownIgnored(t *testing.T) {
state := live.NewState()
if state.ProcessTopic("Heartbeat", json.RawMessage(`{"Seq": 1}`)) {
t.Error("unknown topic should not mark state updated")
}
if state.ProcessTopic("TotallyUnknown", json.RawMessage(`{"foo": "bar"}`)) {
t.Error("unknown topic should not mark state updated")
}
}
func TestSnapshotCopiesMapsAndSlices(t *testing.T) {
state := live.NewState()
state.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 1, GapToLeader: "+0.000"}
state.DriverInfo["1"] = live.F1DriverListEntry{Tla: "VER"}
state.Tyres["1"] = live.LiveTyreData{Compound: "SOFT", Age: 5}
state.Stints["1"] = []live.LiveStintData{{Compound: "SOFT", Laps: 5}}
state.RCMessages = []live.LiveRCMessage{{Message: "Green flag"}}
snap := state.Snapshot()
snap.Drivers["1"] = live.LiveDriverData{RacingNumber: "1", Position: 99}
snap.DriverInfo["1"] = live.F1DriverListEntry{Tla: "MUTATED"}
snap.Tyres["1"] = live.LiveTyreData{Compound: "WET"}
snap.Stints["1"][0].Compound = "WET"
snap.RCMessages[0].Message = "mutated"
inner := state.Snapshot()
if inner.Drivers["1"].Position != 1 {
t.Error("mutating snapshot drivers leaked into state")
}
if inner.DriverInfo["1"].Tla != "VER" {
t.Error("mutating snapshot driverInfo leaked into state")
}
if inner.Tyres["1"].Compound != "SOFT" {
t.Error("mutating snapshot tyres leaked into state")
}
if inner.Stints["1"][0].Compound != "SOFT" {
t.Error("mutating snapshot stints leaked into state")
}
if inner.RCMessages[0].Message != "Green flag" {
t.Error("mutating snapshot RC messages leaked into state")
}
}
func TestProcessMessageInvalidJSON(t *testing.T) {
state := live.NewState()
if state.ProcessMessage([]byte(`not json`)) {
t.Error("invalid JSON should not update state")
}
}
func TestProcessMessageEmptyPayload(t *testing.T) {
state := live.NewState()
if state.ProcessMessage([]byte(`{}`)) {
t.Error("empty envelope should not update state")
}
}

82
internal/live/signalr.go Normal file
View File

@@ -0,0 +1,82 @@
package live
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
)
// ConnectToF1LiveTiming negotiates with the official F1 SignalR hub, subscribes
// to timing topics, and sends defensive snapshots on dataChan until the
// connection closes.
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
req, err := http.NewRequest("GET", negotiateURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
cookies := resp.Cookies()
defer resp.Body.Close()
var neg struct {
ConnectionToken string `json:"ConnectionToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken),
url.QueryEscape(hubName),
)
header := http.Header{}
for _, cookie := range cookies {
header.Add("Cookie", cookie.String())
}
header.Add("User-Agent", "BestHTTP")
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
}
go func() {
defer c.Close()
state := NewState()
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WS Read Error:", err)
return
}
if state.ProcessMessage(message) {
select {
case dataChan <- state.Snapshot():
default:
}
}
}
}()
return nil
}

495
internal/live/state.go Normal file
View File

@@ -0,0 +1,495 @@
package live
import (
"encoding/json"
"fmt"
"time"
)
// State accumulates live timing updates from SignalR topic payloads.
type State struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
Stints map[string][]LiveStintData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string
CurrentLap int
TotalLaps int
Clock string
ClockRefTime time.Time
ClockExtrapolating bool
}
// NewState returns an empty live timing accumulator.
func NewState() *State {
return &State{
Drivers: make(map[string]LiveDriverData),
DriverInfo: make(map[string]F1DriverListEntry),
Tyres: make(map[string]LiveTyreData),
Stints: make(map[string][]LiveStintData),
}
}
// Snapshot returns a defensive copy of the current state.
func (s *State) Snapshot() LiveStreamData {
cpyDrivers := make(map[string]LiveDriverData, len(s.Drivers))
for k, v := range s.Drivers {
cpyDrivers[k] = v
}
cpyInfo := make(map[string]F1DriverListEntry, len(s.DriverInfo))
for k, v := range s.DriverInfo {
cpyInfo[k] = v
}
cpyTyres := make(map[string]LiveTyreData, len(s.Tyres))
for k, v := range s.Tyres {
cpyTyres[k] = v
}
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
copy(cpyRC, s.RCMessages)
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
for k, v := range s.Stints {
st := make([]LiveStintData, len(v))
copy(st, v)
cpyStints[k] = st
}
return LiveStreamData{
Drivers: cpyDrivers,
DriverInfo: cpyInfo,
Tyres: cpyTyres,
RCMessages: cpyRC,
Weather: s.Weather,
Session: s.Session,
TrackStatus: s.TrackStatus,
CurrentLap: s.CurrentLap,
TotalLaps: s.TotalLaps,
Clock: s.Clock,
ClockRefTime: s.ClockRefTime,
ClockExtrapolating: s.ClockExtrapolating,
Stints: cpyStints,
}
}
// ProcessMessage parses a raw SignalR WebSocket frame and applies any updates.
func (s *State) ProcessMessage(message []byte) bool {
var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil {
return false
}
updated := false
if len(parsed.R) > 2 {
var rMap map[string]json.RawMessage
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
for topic, data := range rMap {
if s.ProcessTopic(topic, data) {
updated = true
}
}
}
}
for _, m := range parsed.M {
if len(m.A) > 1 {
var topic string
json.Unmarshal(m.A[0], &topic)
if s.ProcessTopic(topic, m.A[1]) {
updated = true
}
}
}
return updated
}
// ProcessTopic applies a single topic payload to the accumulator.
func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
updated := false
switch topic {
case "TimingData":
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(s.Drivers, num, line)
updated = true
}
}
}
case "DriverList":
var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
s.DriverInfo[num] = entry
updated = true
}
}
}
case "LapCount":
var lc struct {
CurrentLap json.Number `json:"CurrentLap"`
TotalLaps json.Number `json:"TotalLaps"`
}
if json.Unmarshal(data, &lc) == nil {
if v, err := lc.CurrentLap.Int64(); err == nil {
s.CurrentLap = int(v)
}
if v, err := lc.TotalLaps.Int64(); err == nil {
s.TotalLaps = int(v)
}
updated = true
}
case "ExtrapolatedClock":
var ec struct {
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
}
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
s.Clock = ec.Remaining
s.ClockExtrapolating = ec.Extrapolating
if ec.Utc != "" {
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
s.ClockRefTime = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
s.ClockRefTime = t
} else {
s.ClockRefTime = time.Now()
}
} else {
s.ClockRefTime = time.Now()
}
updated = true
}
case "TrackStatus":
var ts struct {
Status string `json:"Status"`
Message string `json:"Message"`
}
if json.Unmarshal(data, &ts) == nil && ts.Status != "" {
s.TrackStatus = ts.Status
updated = true
}
case "RaceControlMessages":
var rcm struct {
Messages map[string]json.RawMessage `json:"Messages"`
}
if json.Unmarshal(data, &rcm) == nil {
for _, msgRaw := range rcm.Messages {
var msg struct {
Utc string `json:"Utc"`
Category string `json:"Category"`
Flag string `json:"Flag"`
Message string `json:"Message"`
Lap int `json:"Lap"`
}
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" {
t := ""
if len(msg.Utc) >= 19 {
t = msg.Utc[11:16]
}
s.RCMessages = append(s.RCMessages, LiveRCMessage{
Time: t,
Category: msg.Category,
Flag: msg.Flag,
Message: msg.Message,
Lap: msg.Lap,
})
updated = true
}
}
}
case "WeatherData":
var wd struct {
AirTemp json.Number `json:"AirTemp"`
TrackTemp json.Number `json:"TrackTemp"`
Humidity json.Number `json:"Humidity"`
WindSpeed json.Number `json:"WindSpeed"`
WindDirection json.Number `json:"WindDirection"`
Rainfall json.Number `json:"Rainfall"`
}
if json.Unmarshal(data, &wd) == nil {
if v, err := wd.AirTemp.Float64(); err == nil {
s.Weather.AirTemp = v
}
if v, err := wd.TrackTemp.Float64(); err == nil {
s.Weather.TrackTemp = v
}
if v, err := wd.Humidity.Float64(); err == nil {
s.Weather.Humidity = v
}
if v, err := wd.WindSpeed.Float64(); err == nil {
s.Weather.WindSpeed = v
}
if v, err := wd.WindDirection.Int64(); err == nil {
s.Weather.WindDir = int(v)
}
if v, err := wd.Rainfall.Float64(); err == nil {
s.Weather.Rainfall = v > 0
}
updated = true
}
case "SessionInfo":
var si struct {
Meeting struct {
Name string `json:"Name"`
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
s.Session.MeetingName = si.Meeting.Name
}
if si.Name != "" {
s.Session.SessionName = si.Name
}
if si.Type != "" {
s.Session.SessionType = si.Type
}
updated = true
}
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
for num, raw := range ct {
if num == "_kf" {
continue
}
var td struct {
Compound string `json:"Compound"`
New string `json:"New"`
}
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
t := s.Tyres[num]
t.Compound = td.Compound
t.New = td.New == "true" || td.New == "True"
s.Tyres[num] = t
updated = true
}
}
}
case "TimingAppData":
var tad struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &tad) == nil {
for num, lineRaw := range tad.Lines {
var line struct {
Stints map[string]json.RawMessage `json:"Stints"`
}
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData
for _, sRaw := range line.Stints {
var st struct {
Compound string `json:"Compound"`
New string `json:"New"`
TotalLaps int `json:"TotalLaps"`
}
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
}
}
if len(driverStints) > 0 {
s.Stints[num] = driverStints
lastStint := driverStints[len(driverStints)-1]
t := s.Tyres[num]
t.Age = lastStint.Laps
if lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
}
s.Tyres[num] = t
updated = true
}
}
}
}
case "TimingStats":
var ts struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &ts) == nil {
for num, lineRaw := range ts.Lines {
var line struct {
PersonalBestLapTime struct {
Value string `json:"Value"`
} `json:"PersonalBestLapTime"`
}
if json.Unmarshal(lineRaw, &line) == nil {
if d, ok := s.Drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
d.BestLapTime = line.PersonalBestLapTime.Value
s.Drivers[num] = d
updated = true
}
}
}
}
}
return updated
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num]
if !exists {
d = LiveDriverData{RacingNumber: num}
if line.RacingNumber != "" {
d.RacingNumber = line.RacingNumber
}
}
if line.Position != nil {
var newPos int
switch v := line.Position.(type) {
case string:
fmt.Sscanf(v, "%d", &newPos)
case float64:
newPos = int(v)
}
if newPos > 0 && newPos != d.Position {
d.PrevPosition = d.Position
d.Position = newPos
}
}
if line.GapToLeader != nil {
if s := extractStringVal(line.GapToLeader); s != "" {
d.GapToLeader = s
}
}
if line.IntervalToPositionAhead.Value != nil {
if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
d.Interval = s
}
}
if line.LastLapTime.Value != "" {
d.LastLapTime = line.LastLapTime.Value
d.LastLapPB = line.LastLapTime.PersonalFastest
d.LastLapOB = line.LastLapTime.OverallFastest
}
if line.BestLapTime.Value != "" {
d.BestLapTime = line.BestLapTime.Value
d.BestLapPB = line.BestLapTime.PersonalFastest
d.BestLapOB = line.BestLapTime.OverallFastest
if line.BestLapTime.Lap > 0 {
d.BestLapNum = line.BestLapTime.Lap
}
}
if line.InPit != nil {
d.InPit = toBool(line.InPit)
}
if line.PitOut != nil {
d.PitOut = toBool(line.PitOut)
}
if line.Retired != nil {
d.Retired = toBool(line.Retired)
}
if line.KnockedOut != nil {
d.KnockedOut = toBool(line.KnockedOut)
}
if line.Cutoff != nil {
d.Cutoff = toBool(line.Cutoff)
}
if line.NumberOfLaps != nil {
if v, ok := toInt(line.NumberOfLaps); ok {
d.NumberOfLaps = v
}
}
if st, ok := line.Speeds["ST"]; ok {
var sp struct {
Value string `json:"Value"`
}
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
d.SpeedTrap = sp.Value
}
}
for idx, sRaw := range line.Sectors {
i := 0
fmt.Sscanf(idx, "%d", &i)
if i >= 0 && i < 3 {
var sec struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
}
if json.Unmarshal(sRaw, &sec) == nil {
if sec.Value == "" {
d.Sectors[i] = LiveSectorData{}
} else {
d.Sectors[i] = LiveSectorData{
Value: sec.Value,
PersonalFastest: sec.PersonalFastest,
OverallFastest: sec.OverallFastest,
}
}
}
}
}
d.OnFlyingLap = !d.InPit && !d.Retired &&
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
d.Sectors[2].Value == ""
drivers[num] = d
}
func extractStringVal(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
if val == 0 {
return ""
}
return fmt.Sprintf("+%.3f", val)
case map[string]interface{}:
if s, ok := val["Value"].(string); ok {
return s
}
}
return ""
}
func toBool(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true" || val == "True"
}
return false
}
func toInt(v interface{}) (int, bool) {
switch val := v.(type) {
case float64:
return int(val), true
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i), true
}
case string:
var i int
if _, err := fmt.Sscanf(val, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}

144
internal/live/types.go Normal file
View File

@@ -0,0 +1,144 @@
package live
import (
"encoding/json"
"time"
)
// F1SignalRMessage is the top-level envelope from the official F1 SignalR feed.
type F1SignalRMessage struct {
M []struct {
A []json.RawMessage `json:"A"`
} `json:"M"`
R json.RawMessage `json:"R"`
}
// F1TimingLine is a single driver's timing row from TimingData.
type F1TimingLine struct {
GapToLeader interface{} `json:"GapToLeader"`
IntervalToPositionAhead struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"`
LastLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
} `json:"LastLapTime"`
BestLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
Lap int `json:"Lap"`
} `json:"BestLapTime"`
InPit interface{} `json:"InPit"`
PitOut interface{} `json:"PitOut"`
Retired interface{} `json:"Retired"`
KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"`
}
// F1DriverListEntry is driver metadata from the DriverList topic.
type F1DriverListEntry struct {
RacingNumber string `json:"RacingNumber"`
BroadcastName string `json:"BroadcastName"`
Tla string `json:"Tla"`
TeamName string `json:"TeamName"`
TeamColour string `json:"TeamColour"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}
// LiveTyreData holds current tyre compound and age for a driver.
type LiveTyreData struct {
Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET
New bool
Age int // laps on current set
}
// LiveRCMessage is a parsed race control message.
type LiveRCMessage struct {
Time string // "15:04" formatted
Category string // Flag, SafetyCar, Drs, Other
Flag string // GREEN, YELLOW, RED, etc.
Message string
Lap int
}
// LiveWeatherData holds session weather readings.
type LiveWeatherData struct {
AirTemp float64
TrackTemp float64
Humidity float64
WindSpeed float64
WindDir int
Rainfall bool
}
// LiveSessionMeta holds session and meeting metadata.
type LiveSessionMeta struct {
MeetingName string
CircuitName string
SessionType string
SessionName string
}
// LiveSectorData holds a single sector time and flags.
type LiveSectorData struct {
Value string
PersonalFastest bool
OverallFastest bool
}
// LiveDriverData is the normalized timing state for one driver.
type LiveDriverData struct {
RacingNumber string
Position int
PrevPosition int
GapToLeader string
Interval string
LastLapTime string
LastLapPB bool // personal best
LastLapOB bool // overall best
BestLapTime string
BestLapPB bool // just set a new personal best
BestLapOB bool // overall fastest in session
BestLapNum int // lap number when best was set
InPit bool
PitOut bool
Retired bool
KnockedOut bool // eliminated in qualifying
Cutoff bool // currently in elimination zone (danger zone)
OnFlyingLap bool // currently running a timed lap (derived from sector state)
NumberOfLaps int
SpeedTrap string // fastest recorded speed at speed trap
Sectors [3]LiveSectorData
}
// LiveStintData is one stint in a driver's tyre history.
type LiveStintData struct {
Compound string
New bool
Laps int
}
// LiveStreamData is an immutable snapshot of all live timing state.
type LiveStreamData struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
Clock string // "HH:MM:SS" remaining at ClockRefTime
ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData
}

92
internal/store/db.go Normal file
View File

@@ -0,0 +1,92 @@
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// Store owns the local domain SQLite database.
type Store struct {
db *sql.DB
}
// Open opens or creates a domain database at path and applies pending migrations.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
dsn := path + "?_journal_mode=WAL&_busy_timeout=5000&_foreign_keys=ON"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.applyMigrations(); err != nil {
_ = db.Close()
return nil, err
}
return s, nil
}
// OpenDefault opens the default user domain database path.
func OpenDefault() (*Store, error) {
return Open(DefaultDBPath())
}
// DefaultDBPath returns the default domain database file path.
func DefaultDBPath() string {
home, err := os.UserHomeDir()
if err == nil {
return filepath.Join(home, ".local", "share", "box-box", "boxbox.db")
}
return filepath.Join(".local", "share", "box-box", "boxbox.db")
}
// DB exposes the underlying connection for advanced callers and tests.
func (s *Store) DB() *sql.DB {
return s.db
}
// Close closes the database connection.
func (s *Store) Close() error {
if s.db == nil {
return nil
}
return s.db.Close()
}
// WithTx runs fn inside a transaction, rolling back on error.
func (s *Store) WithTx(fn func(tx *sql.Tx) error) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// SchemaVersion returns the highest applied migration version.
func (s *Store) SchemaVersion() (int, error) {
var version sql.NullInt64
err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
if err != nil {
return 0, err
}
if !version.Valid {
return 0, nil
}
return int(version.Int64), nil
}

295
internal/store/meetings.go Normal file
View File

@@ -0,0 +1,295 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertMeeting inserts or updates a meeting by meeting_key.
func (s *Store) UpsertMeeting(m Meeting) error {
if m.UpdatedAt.IsZero() {
m.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO meetings (
meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(meeting_key) DO UPDATE SET
meeting_name = excluded.meeting_name,
meeting_official_name = excluded.meeting_official_name,
location = excluded.location,
country_code = excluded.country_code,
country_name = excluded.country_name,
circuit_key = excluded.circuit_key,
circuit_short_name = excluded.circuit_short_name,
gmt_offset = excluded.gmt_offset,
date_start = excluded.date_start,
date_end = excluded.date_end,
year = excluded.year,
updated_at = excluded.updated_at
`,
m.MeetingKey,
m.MeetingName,
nullString(m.MeetingOfficialName),
nullString(m.Location),
nullString(m.CountryCode),
nullString(m.CountryName),
nullableZeroInt(m.CircuitKey),
nullString(m.CircuitShortName),
nullString(m.GMTOffset),
nullString(m.DateStart),
nullString(m.DateEnd),
m.Year,
m.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert meeting: %w", err)
}
return nil
}
// GetMeeting returns a meeting by key.
func (s *Store) GetMeeting(meetingKey int) (Meeting, error) {
var m Meeting
var updatedAt int64
var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
err := s.db.QueryRow(`
SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
FROM meetings
WHERE meeting_key = ?
`, meetingKey).Scan(
&m.MeetingKey,
&m.MeetingName,
&officialName,
&location,
&countryCode,
&countryName,
&circuitKey,
&circuitShortName,
&gmtOffset,
&dateStart,
&dateEnd,
&m.Year,
&updatedAt,
)
if err != nil {
return Meeting{}, err
}
m.MeetingOfficialName = officialName.String
m.Location = location.String
m.CountryCode = countryCode.String
m.CountryName = countryName.String
if circuitKey.Valid {
m.CircuitKey = int(circuitKey.Int64)
}
m.CircuitShortName = circuitShortName.String
m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String
m.DateEnd = dateEnd.String
m.UpdatedAt = time.Unix(updatedAt, 0)
return m, nil
}
// ListMeetingsByYear returns meetings for a season ordered by start date.
func (s *Store) ListMeetingsByYear(year int) ([]Meeting, error) {
rows, err := s.db.Query(`
SELECT meeting_key, meeting_name, meeting_official_name, location,
country_code, country_name, circuit_key, circuit_short_name,
gmt_offset, date_start, date_end, year, updated_at
FROM meetings
WHERE year = ?
ORDER BY date_start ASC, meeting_key ASC
`, year)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMeetings(rows)
}
// UpsertSession inserts or updates a session by session_key.
func (s *Store) UpsertSession(sess Session) error {
if sess.UpdatedAt.IsZero() {
sess.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO sessions (
session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key) DO UPDATE SET
meeting_key = excluded.meeting_key,
session_name = excluded.session_name,
session_type = excluded.session_type,
circuit_key = excluded.circuit_key,
date_start = excluded.date_start,
date_end = excluded.date_end,
gmt_offset = excluded.gmt_offset,
updated_at = excluded.updated_at
`,
sess.SessionKey,
sess.MeetingKey,
sess.SessionName,
sess.SessionType,
nullableZeroInt(sess.CircuitKey),
nullString(sess.DateStart),
nullString(sess.DateEnd),
nullString(sess.GMTOffset),
sess.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert session: %w", err)
}
return nil
}
// GetSession returns a session by key.
func (s *Store) GetSession(sessionKey int) (Session, error) {
var sess Session
var updatedAt int64
var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString
err := s.db.QueryRow(`
SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
FROM sessions
WHERE session_key = ?
`, sessionKey).Scan(
&sess.SessionKey,
&sess.MeetingKey,
&sess.SessionName,
&sess.SessionType,
&circuitKey,
&dateStart,
&dateEnd,
&gmtOffset,
&updatedAt,
)
if err != nil {
return Session{}, err
}
if circuitKey.Valid {
sess.CircuitKey = int(circuitKey.Int64)
}
sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String
sess.UpdatedAt = time.Unix(updatedAt, 0)
return sess, nil
}
// ListSessionsByMeeting returns sessions for a meeting ordered by start time.
func (s *Store) ListSessionsByMeeting(meetingKey int) ([]Session, error) {
rows, err := s.db.Query(`
SELECT session_key, meeting_key, session_name, session_type,
circuit_key, date_start, date_end, gmt_offset, updated_at
FROM sessions
WHERE meeting_key = ?
ORDER BY date_start ASC, session_key ASC
`, meetingKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessions(rows)
}
func scanMeetings(rows *sql.Rows) ([]Meeting, error) {
var out []Meeting
for rows.Next() {
var m Meeting
var updatedAt int64
var officialName, location, countryCode, countryName sql.NullString
var circuitKey sql.NullInt64
var circuitShortName, gmtOffset, dateStart, dateEnd sql.NullString
if err := rows.Scan(
&m.MeetingKey,
&m.MeetingName,
&officialName,
&location,
&countryCode,
&countryName,
&circuitKey,
&circuitShortName,
&gmtOffset,
&dateStart,
&dateEnd,
&m.Year,
&updatedAt,
); err != nil {
return nil, err
}
m.MeetingOfficialName = officialName.String
m.Location = location.String
m.CountryCode = countryCode.String
m.CountryName = countryName.String
if circuitKey.Valid {
m.CircuitKey = int(circuitKey.Int64)
}
m.CircuitShortName = circuitShortName.String
m.GMTOffset = gmtOffset.String
m.DateStart = dateStart.String
m.DateEnd = dateEnd.String
m.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, m)
}
return out, rows.Err()
}
func scanSessions(rows *sql.Rows) ([]Session, error) {
var out []Session
for rows.Next() {
var sess Session
var updatedAt int64
var circuitKey sql.NullInt64
var dateStart, dateEnd, gmtOffset sql.NullString
if err := rows.Scan(
&sess.SessionKey,
&sess.MeetingKey,
&sess.SessionName,
&sess.SessionType,
&circuitKey,
&dateStart,
&dateEnd,
&gmtOffset,
&updatedAt,
); err != nil {
return nil, err
}
if circuitKey.Valid {
sess.CircuitKey = int(circuitKey.Int64)
}
sess.DateStart = dateStart.String
sess.DateEnd = dateEnd.String
sess.GMTOffset = gmtOffset.String
sess.UpdatedAt = time.Unix(updatedAt, 0)
out = append(out, sess)
}
return out, rows.Err()
}
func nullableZeroInt(v int) any {
if v == 0 {
return nil
}
return v
}

View File

@@ -0,0 +1,92 @@
package store
import (
"embed"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func (s *Store) applyMigrations() error {
if _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`); err != nil {
return fmt.Errorf("bootstrap schema_migrations: %w", err)
}
entries, err := migrationFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
var files []string
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
files = append(files, entry.Name())
}
sort.Strings(files)
for _, name := range files {
version, err := migrationVersion(name)
if err != nil {
return err
}
applied, err := s.isMigrationApplied(version)
if err != nil {
return err
}
if applied {
continue
}
sqlBytes, err := migrationFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
if _, err := s.db.Exec(string(sqlBytes)); err != nil {
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := s.db.Exec(
`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`,
version, time.Now().Unix(),
); err != nil {
return fmt.Errorf("record migration %s: %w", name, err)
}
}
return nil
}
func migrationVersion(name string) (int, error) {
prefix := strings.SplitN(name, "_", 2)[0]
version, err := strconv.Atoi(prefix)
if err != nil {
return 0, fmt.Errorf("invalid migration filename %q: %w", name, err)
}
return version, nil
}
func (s *Store) isMigrationApplied(version int) (bool, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`,
version,
).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@@ -0,0 +1,118 @@
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS raw_payloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
endpoint TEXT NOT NULL,
request_key TEXT NOT NULL,
meeting_key INTEGER,
session_key INTEGER,
payload TEXT NOT NULL,
payload_hash TEXT NOT NULL,
fetched_at INTEGER NOT NULL,
provenance_json TEXT,
UNIQUE (source, request_key, payload_hash)
);
CREATE INDEX IF NOT EXISTS idx_raw_payloads_meeting ON raw_payloads (meeting_key);
CREATE INDEX IF NOT EXISTS idx_raw_payloads_session ON raw_payloads (session_key);
CREATE TABLE IF NOT EXISTS ingestion_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope_type TEXT NOT NULL,
scope_key TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
refresh INTEGER NOT NULL DEFAULT 0,
summary_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_ingestion_runs_scope ON ingestion_runs (scope_type, scope_key);
CREATE TABLE IF NOT EXISTS meetings (
meeting_key INTEGER PRIMARY KEY,
meeting_name TEXT NOT NULL,
meeting_official_name TEXT,
location TEXT,
country_code TEXT,
country_name TEXT,
circuit_key INTEGER,
circuit_short_name TEXT,
gmt_offset TEXT,
date_start TEXT,
date_end TEXT,
year INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meetings_year ON meetings (year);
CREATE TABLE IF NOT EXISTS sessions (
session_key INTEGER PRIMARY KEY,
meeting_key INTEGER NOT NULL REFERENCES meetings (meeting_key),
session_name TEXT NOT NULL,
session_type TEXT NOT NULL,
circuit_key INTEGER,
date_start TEXT,
date_end TEXT,
gmt_offset TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_meeting ON sessions (meeting_key);
CREATE TABLE IF NOT EXISTS drivers (
driver_number INTEGER PRIMARY KEY,
broadcast_name TEXT,
first_name TEXT,
full_name TEXT NOT NULL,
last_name TEXT,
name_acronym TEXT,
headshot_url TEXT,
team_name TEXT,
team_colour TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS session_drivers (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
team_name TEXT,
team_colour TEXT,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_session_drivers_meeting ON session_drivers (meeting_key);
CREATE TABLE IF NOT EXISTS session_results (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
position INTEGER NOT NULL,
points REAL NOT NULL DEFAULT 0,
number_of_laps INTEGER,
duration_json TEXT,
gap_to_leader_json TEXT,
dnf INTEGER NOT NULL DEFAULT 0,
dns INTEGER NOT NULL DEFAULT 0,
dsq INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_session_results_meeting ON session_results (meeting_key);
CREATE TABLE IF NOT EXISTS starting_grid (
session_key INTEGER NOT NULL,
driver_number INTEGER NOT NULL,
meeting_key INTEGER NOT NULL,
position INTEGER NOT NULL,
lap_duration REAL,
PRIMARY KEY (session_key, driver_number)
);
CREATE INDEX IF NOT EXISTS idx_starting_grid_meeting ON starting_grid (meeting_key);

106
internal/store/models.go Normal file
View File

@@ -0,0 +1,106 @@
package store
import "time"
// RawPayload stores a fetched source payload with provenance metadata.
type RawPayload struct {
ID int64
Source string
Endpoint string
RequestKey string
MeetingKey *int
SessionKey *int
Payload string
PayloadHash string
FetchedAt time.Time
ProvenanceJSON string
}
// IngestionRun tracks a scoped ingestion attempt.
type IngestionRun struct {
ID int64
ScopeType string
ScopeKey string
StartedAt time.Time
FinishedAt *time.Time
Status string
Refresh bool
SummaryJSON string
}
// Meeting is a race weekend record.
type Meeting struct {
MeetingKey int
MeetingName string
MeetingOfficialName string
Location string
CountryCode string
CountryName string
CircuitKey int
CircuitShortName string
GMTOffset string
DateStart string
DateEnd string
Year int
UpdatedAt time.Time
}
// Session is a session within a meeting.
type Session struct {
SessionKey int
MeetingKey int
SessionName string
SessionType string
CircuitKey int
DateStart string
DateEnd string
GMTOffset string
UpdatedAt time.Time
}
// Driver is a driver identity record.
type Driver struct {
DriverNumber int
BroadcastName string
FirstName string
FullName string
LastName string
NameAcronym string
HeadshotURL string
TeamName string
TeamColour string
UpdatedAt time.Time
}
// SessionDriver links a driver to a session with session-specific team info.
type SessionDriver struct {
SessionKey int
DriverNumber int
MeetingKey int
TeamName string
TeamColour string
}
// SessionResult is a final classification row for a session.
type SessionResult struct {
SessionKey int
DriverNumber int
MeetingKey int
Position int
Points float64
NumberOfLaps int
DurationJSON string
GapToLeaderJSON string
DNF bool
DNS bool
DSQ bool
}
// StartingGridEntry is a starting grid position for a session.
type StartingGridEntry struct {
SessionKey int
DriverNumber int
MeetingKey int
Position int
LapDuration float64
}

177
internal/store/raw.go Normal file
View File

@@ -0,0 +1,177 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"time"
)
// InsertRawPayload stores a raw payload if the source/request/hash tuple is new.
// Returns the row ID and true when inserted, or the existing ID and false on duplicate.
func (s *Store) InsertRawPayload(p RawPayload) (int64, bool, error) {
if p.PayloadHash == "" {
p.PayloadHash = hashPayload(p.Payload)
}
if p.FetchedAt.IsZero() {
p.FetchedAt = time.Now()
}
result, err := s.db.Exec(`
INSERT INTO raw_payloads (
source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, request_key, payload_hash) DO NOTHING
`,
p.Source,
p.Endpoint,
p.RequestKey,
nullableInt(p.MeetingKey),
nullableInt(p.SessionKey),
p.Payload,
p.PayloadHash,
p.FetchedAt.Unix(),
nullString(p.ProvenanceJSON),
)
if err != nil {
return 0, false, fmt.Errorf("insert raw payload: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return 0, false, err
}
if rows == 0 {
id, err := s.findRawPayloadID(p.Source, p.RequestKey, p.PayloadHash)
return id, false, err
}
id, err := result.LastInsertId()
return id, true, err
}
// GetRawPayload returns a raw payload by ID.
func (s *Store) GetRawPayload(id int64) (RawPayload, error) {
var p RawPayload
var fetchedAt int64
var meetingKey, sessionKey sql.NullInt64
var provenance sql.NullString
err := s.db.QueryRow(`
SELECT id, source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
FROM raw_payloads
WHERE id = ?
`, id).Scan(
&p.ID,
&p.Source,
&p.Endpoint,
&p.RequestKey,
&meetingKey,
&sessionKey,
&p.Payload,
&p.PayloadHash,
&fetchedAt,
&provenance,
)
if err != nil {
return RawPayload{}, err
}
p.FetchedAt = time.Unix(fetchedAt, 0)
p.MeetingKey = nullIntPtr(meetingKey)
p.SessionKey = nullIntPtr(sessionKey)
if provenance.Valid {
p.ProvenanceJSON = provenance.String
}
return p, nil
}
// ListRawPayloadsBySession returns raw payloads for a session ordered by fetch time.
func (s *Store) ListRawPayloadsBySession(sessionKey int) ([]RawPayload, error) {
rows, err := s.db.Query(`
SELECT id, source, endpoint, request_key, meeting_key, session_key,
payload, payload_hash, fetched_at, provenance_json
FROM raw_payloads
WHERE session_key = ?
ORDER BY fetched_at ASC, id ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRawPayloads(rows)
}
func (s *Store) findRawPayloadID(source, requestKey, payloadHash string) (int64, error) {
var id int64
err := s.db.QueryRow(`
SELECT id FROM raw_payloads
WHERE source = ? AND request_key = ? AND payload_hash = ?
`, source, requestKey, payloadHash).Scan(&id)
return id, err
}
func scanRawPayloads(rows *sql.Rows) ([]RawPayload, error) {
var out []RawPayload
for rows.Next() {
var p RawPayload
var fetchedAt int64
var meetingKey, sessionKey sql.NullInt64
var provenance sql.NullString
if err := rows.Scan(
&p.ID,
&p.Source,
&p.Endpoint,
&p.RequestKey,
&meetingKey,
&sessionKey,
&p.Payload,
&p.PayloadHash,
&fetchedAt,
&provenance,
); err != nil {
return nil, err
}
p.FetchedAt = time.Unix(fetchedAt, 0)
p.MeetingKey = nullIntPtr(meetingKey)
p.SessionKey = nullIntPtr(sessionKey)
if provenance.Valid {
p.ProvenanceJSON = provenance.String
}
out = append(out, p)
}
return out, rows.Err()
}
func hashPayload(payload string) string {
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}
func nullableInt(v *int) any {
if v == nil {
return nil
}
return *v
}
func nullIntPtr(v sql.NullInt64) *int {
if !v.Valid {
return nil
}
n := int(v.Int64)
return &n
}
func nullString(v string) any {
if v == "" {
return nil
}
return v
}

302
internal/store/results.go Normal file
View File

@@ -0,0 +1,302 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// UpsertDriver inserts or updates a driver by driver_number.
func (s *Store) UpsertDriver(d Driver) error {
if d.UpdatedAt.IsZero() {
d.UpdatedAt = time.Now()
}
_, err := s.db.Exec(`
INSERT INTO drivers (
driver_number, broadcast_name, first_name, full_name, last_name,
name_acronym, headshot_url, team_name, team_colour, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(driver_number) DO UPDATE SET
broadcast_name = excluded.broadcast_name,
first_name = excluded.first_name,
full_name = excluded.full_name,
last_name = excluded.last_name,
name_acronym = excluded.name_acronym,
headshot_url = excluded.headshot_url,
team_name = excluded.team_name,
team_colour = excluded.team_colour,
updated_at = excluded.updated_at
`,
d.DriverNumber,
nullString(d.BroadcastName),
nullString(d.FirstName),
d.FullName,
nullString(d.LastName),
nullString(d.NameAcronym),
nullString(d.HeadshotURL),
nullString(d.TeamName),
nullString(d.TeamColour),
d.UpdatedAt.Unix(),
)
if err != nil {
return fmt.Errorf("upsert driver: %w", err)
}
return nil
}
// GetDriver returns a driver by number.
func (s *Store) GetDriver(driverNumber int) (Driver, error) {
var d Driver
var updatedAt int64
var broadcastName, firstName, lastName, nameAcronym sql.NullString
var headshotURL, teamName, teamColour sql.NullString
err := s.db.QueryRow(`
SELECT driver_number, broadcast_name, first_name, full_name, last_name,
name_acronym, headshot_url, team_name, team_colour, updated_at
FROM drivers
WHERE driver_number = ?
`, driverNumber).Scan(
&d.DriverNumber,
&broadcastName,
&firstName,
&d.FullName,
&lastName,
&nameAcronym,
&headshotURL,
&teamName,
&teamColour,
&updatedAt,
)
if err != nil {
return Driver{}, err
}
d.BroadcastName = broadcastName.String
d.FirstName = firstName.String
d.LastName = lastName.String
d.NameAcronym = nameAcronym.String
d.HeadshotURL = headshotURL.String
d.TeamName = teamName.String
d.TeamColour = teamColour.String
d.UpdatedAt = time.Unix(updatedAt, 0)
return d, nil
}
// UpsertSessionDriver links a driver to a session.
func (s *Store) UpsertSessionDriver(sd SessionDriver) error {
_, err := s.db.Exec(`
INSERT INTO session_drivers (
session_key, driver_number, meeting_key, team_name, team_colour
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
team_name = excluded.team_name,
team_colour = excluded.team_colour
`,
sd.SessionKey,
sd.DriverNumber,
sd.MeetingKey,
nullString(sd.TeamName),
nullString(sd.TeamColour),
)
if err != nil {
return fmt.Errorf("upsert session driver: %w", err)
}
return nil
}
// ListSessionDrivers returns drivers entered for a session ordered by number.
func (s *Store) ListSessionDrivers(sessionKey int) ([]SessionDriver, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, team_name, team_colour
FROM session_drivers
WHERE session_key = ?
ORDER BY driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SessionDriver
for rows.Next() {
var sd SessionDriver
var teamName, teamColour sql.NullString
if err := rows.Scan(
&sd.SessionKey,
&sd.DriverNumber,
&sd.MeetingKey,
&teamName,
&teamColour,
); err != nil {
return nil, err
}
sd.TeamName = teamName.String
sd.TeamColour = teamColour.String
out = append(out, sd)
}
return out, rows.Err()
}
// UpsertSessionResult inserts or updates a session classification row.
func (s *Store) UpsertSessionResult(r SessionResult) error {
_, err := s.db.Exec(`
INSERT INTO session_results (
session_key, driver_number, meeting_key, position, points,
number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
position = excluded.position,
points = excluded.points,
number_of_laps = excluded.number_of_laps,
duration_json = excluded.duration_json,
gap_to_leader_json = excluded.gap_to_leader_json,
dnf = excluded.dnf,
dns = excluded.dns,
dsq = excluded.dsq
`,
r.SessionKey,
r.DriverNumber,
r.MeetingKey,
r.Position,
r.Points,
nullableZeroInt(r.NumberOfLaps),
nullString(r.DurationJSON),
nullString(r.GapToLeaderJSON),
boolInt(r.DNF),
boolInt(r.DNS),
boolInt(r.DSQ),
)
if err != nil {
return fmt.Errorf("upsert session result: %w", err)
}
return nil
}
// ListSessionResults returns classification rows ordered by finishing position.
func (s *Store) ListSessionResults(sessionKey int) ([]SessionResult, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, position, points,
number_of_laps, duration_json, gap_to_leader_json, dnf, dns, dsq
FROM session_results
WHERE session_key = ?
ORDER BY position ASC, driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSessionResults(rows)
}
// UpsertStartingGridEntry inserts or updates a starting grid row.
func (s *Store) UpsertStartingGridEntry(g StartingGridEntry) error {
_, err := s.db.Exec(`
INSERT INTO starting_grid (
session_key, driver_number, meeting_key, position, lap_duration
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_key, driver_number) DO UPDATE SET
meeting_key = excluded.meeting_key,
position = excluded.position,
lap_duration = excluded.lap_duration
`,
g.SessionKey,
g.DriverNumber,
g.MeetingKey,
g.Position,
nullableZeroFloat(g.LapDuration),
)
if err != nil {
return fmt.Errorf("upsert starting grid: %w", err)
}
return nil
}
// ListStartingGrid returns grid rows ordered by position.
func (s *Store) ListStartingGrid(sessionKey int) ([]StartingGridEntry, error) {
rows, err := s.db.Query(`
SELECT session_key, driver_number, meeting_key, position, lap_duration
FROM starting_grid
WHERE session_key = ?
ORDER BY position ASC, driver_number ASC
`, sessionKey)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StartingGridEntry
for rows.Next() {
var g StartingGridEntry
var lapDuration sql.NullFloat64
if err := rows.Scan(
&g.SessionKey,
&g.DriverNumber,
&g.MeetingKey,
&g.Position,
&lapDuration,
); err != nil {
return nil, err
}
if lapDuration.Valid {
g.LapDuration = lapDuration.Float64
}
out = append(out, g)
}
return out, rows.Err()
}
func scanSessionResults(rows *sql.Rows) ([]SessionResult, error) {
var out []SessionResult
for rows.Next() {
var r SessionResult
var numberOfLaps sql.NullInt64
var durationJSON, gapJSON sql.NullString
var dnf, dns, dsq int
if err := rows.Scan(
&r.SessionKey,
&r.DriverNumber,
&r.MeetingKey,
&r.Position,
&r.Points,
&numberOfLaps,
&durationJSON,
&gapJSON,
&dnf,
&dns,
&dsq,
); err != nil {
return nil, err
}
if numberOfLaps.Valid {
r.NumberOfLaps = int(numberOfLaps.Int64)
}
r.DurationJSON = durationJSON.String
r.GapToLeaderJSON = gapJSON.String
r.DNF = dnf != 0
r.DNS = dns != 0
r.DSQ = dsq != 0
out = append(out, r)
}
return out, rows.Err()
}
func boolInt(v bool) int {
if v {
return 1
}
return 0
}
func nullableZeroFloat(v float64) any {
if v == 0 {
return nil
}
return v
}

31
internal/store/runs.go Normal file
View File

@@ -0,0 +1,31 @@
package store
import (
"fmt"
"time"
)
// CreateIngestionRun records the start of an ingestion attempt.
func (s *Store) CreateIngestionRun(scopeType, scopeKey string, refresh bool) (int64, error) {
result, err := s.db.Exec(`
INSERT INTO ingestion_runs (scope_type, scope_key, started_at, status, refresh)
VALUES (?, ?, ?, 'running', ?)
`, scopeType, scopeKey, time.Now().Unix(), boolInt(refresh))
if err != nil {
return 0, fmt.Errorf("create ingestion run: %w", err)
}
return result.LastInsertId()
}
// FinishIngestionRun marks an ingestion run complete with status and summary JSON.
func (s *Store) FinishIngestionRun(id int64, status, summaryJSON string) error {
_, err := s.db.Exec(`
UPDATE ingestion_runs
SET finished_at = ?, status = ?, summary_json = ?
WHERE id = ?
`, time.Now().Unix(), status, nullString(summaryJSON), id)
if err != nil {
return fmt.Errorf("finish ingestion run: %w", err)
}
return nil
}

View File

@@ -0,0 +1,450 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
"time"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestOpenAppliesMigrations(t *testing.T) {
s := openTestStore(t)
version, err := s.SchemaVersion()
if err != nil {
t.Fatalf("SchemaVersion() error = %v", err)
}
if version != 1 {
t.Fatalf("SchemaVersion() = %d, want 1", version)
}
tables := []string{
"schema_migrations",
"raw_payloads",
"ingestion_runs",
"meetings",
"sessions",
"drivers",
"session_drivers",
"session_results",
"starting_grid",
}
for _, table := range tables {
var name string
err := s.db.QueryRow(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,
table,
).Scan(&name)
if err != nil {
t.Fatalf("table %q missing: %v", table, err)
}
}
}
func TestMigrationsAreIdempotent(t *testing.T) {
s := openTestStore(t)
if err := s.applyMigrations(); err != nil {
t.Fatalf("second applyMigrations() error = %v", err)
}
if err := s.applyMigrations(); err != nil {
t.Fatalf("third applyMigrations() error = %v", err)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = 1`).Scan(&count); err != nil {
t.Fatalf("count schema_migrations: %v", err)
}
if count != 1 {
t.Fatalf("schema_migrations count = %d, want 1", count)
}
}
func TestRawPayloadInsertAndRead(t *testing.T) {
s := openTestStore(t)
meetingKey := 1229
sessionKey := 9472
fetchedAt := time.Unix(1710000000, 0).UTC()
payload := RawPayload{
Source: "openf1",
Endpoint: "session_result",
RequestKey: "session_key=9472",
MeetingKey: &meetingKey,
SessionKey: &sessionKey,
Payload: `[{"position":1,"driver_number":1}]`,
PayloadHash: "abc123",
FetchedAt: fetchedAt,
ProvenanceJSON: `{"status":"ok","http_status":200}`,
}
id, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("InsertRawPayload() error = %v", err)
}
if !inserted {
t.Fatal("InsertRawPayload() inserted = false, want true")
}
if id <= 0 {
t.Fatalf("InsertRawPayload() id = %d, want > 0", id)
}
got, err := s.GetRawPayload(id)
if err != nil {
t.Fatalf("GetRawPayload() error = %v", err)
}
if got.Source != payload.Source ||
got.Endpoint != payload.Endpoint ||
got.RequestKey != payload.RequestKey ||
got.Payload != payload.Payload ||
got.PayloadHash != payload.PayloadHash ||
got.ProvenanceJSON != payload.ProvenanceJSON {
t.Fatalf("GetRawPayload() = %+v, want provenance preserved", got)
}
if got.MeetingKey == nil || *got.MeetingKey != meetingKey {
t.Fatalf("MeetingKey = %v, want %d", got.MeetingKey, meetingKey)
}
if got.SessionKey == nil || *got.SessionKey != sessionKey {
t.Fatalf("SessionKey = %v, want %d", got.SessionKey, sessionKey)
}
if !got.FetchedAt.Equal(fetchedAt) {
t.Fatalf("FetchedAt = %v, want %v", got.FetchedAt, fetchedAt)
}
rows, err := s.ListRawPayloadsBySession(sessionKey)
if err != nil {
t.Fatalf("ListRawPayloadsBySession() error = %v", err)
}
if len(rows) != 1 || rows[0].ID != id {
t.Fatalf("ListRawPayloadsBySession() = %+v, want one row id=%d", rows, id)
}
}
func TestRawPayloadDuplicateIsIdempotent(t *testing.T) {
s := openTestStore(t)
payload := RawPayload{
Source: "openf1",
Endpoint: "meetings",
RequestKey: "year=2025",
Payload: `[{"meeting_key":1229}]`,
PayloadHash: "dup-hash",
}
firstID, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("first InsertRawPayload() error = %v", err)
}
if !inserted {
t.Fatal("first insert should succeed")
}
secondID, inserted, err := s.InsertRawPayload(payload)
if err != nil {
t.Fatalf("second InsertRawPayload() error = %v", err)
}
if inserted {
t.Fatal("duplicate insert should not create a new row")
}
if secondID != firstID {
t.Fatalf("duplicate id = %d, want %d", secondID, firstID)
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM raw_payloads`).Scan(&count); err != nil {
t.Fatalf("count raw_payloads: %v", err)
}
if count != 1 {
t.Fatalf("raw_payloads count = %d, want 1", count)
}
}
func TestMeetingSessionDriverUpsertsAreIdempotent(t *testing.T) {
s := openTestStore(t)
meeting := Meeting{
MeetingKey: 1229,
MeetingName: "Monaco",
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
Location: "Monaco",
CountryCode: "MON",
CountryName: "Monaco",
CircuitKey: 10,
CircuitShortName: "Monaco",
Year: 2025,
DateStart: "2025-05-23T00:00:00+00:00",
DateEnd: "2025-05-25T00:00:00+00:00",
}
updatedMeeting := meeting
updatedMeeting.MeetingName = "Monaco GP"
for i := 0; i < 2; i++ {
m := meeting
if i == 1 {
m = updatedMeeting
}
if err := s.UpsertMeeting(m); err != nil {
t.Fatalf("UpsertMeeting(%d) error = %v", i, err)
}
}
gotMeeting, err := s.GetMeeting(meeting.MeetingKey)
if err != nil {
t.Fatalf("GetMeeting() error = %v", err)
}
if gotMeeting.MeetingName != updatedMeeting.MeetingName {
t.Fatalf("MeetingName = %q, want %q", gotMeeting.MeetingName, updatedMeeting.MeetingName)
}
var meetingCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings`).Scan(&meetingCount); err != nil {
t.Fatalf("count meetings: %v", err)
}
if meetingCount != 1 {
t.Fatalf("meetings count = %d, want 1", meetingCount)
}
session := Session{
SessionKey: 9472,
MeetingKey: meeting.MeetingKey,
SessionName: "Race",
SessionType: "Race",
CircuitKey: 10,
DateStart: "2025-05-25T13:00:00+00:00",
}
updatedSession := session
updatedSession.DateEnd = "2025-05-25T15:00:00+00:00"
for i := 0; i < 2; i++ {
sess := session
if i == 1 {
sess = updatedSession
}
if err := s.UpsertSession(sess); err != nil {
t.Fatalf("UpsertSession(%d) error = %v", i, err)
}
}
gotSession, err := s.GetSession(session.SessionKey)
if err != nil {
t.Fatalf("GetSession() error = %v", err)
}
if gotSession.DateEnd != updatedSession.DateEnd {
t.Fatalf("DateEnd = %q, want %q", gotSession.DateEnd, updatedSession.DateEnd)
}
driver := Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}
updatedDriver := driver
updatedDriver.TeamName = "Oracle Red Bull Racing"
for i := 0; i < 2; i++ {
d := driver
if i == 1 {
d = updatedDriver
}
if err := s.UpsertDriver(d); err != nil {
t.Fatalf("UpsertDriver(%d) error = %v", i, err)
}
}
gotDriver, err := s.GetDriver(driver.DriverNumber)
if err != nil {
t.Fatalf("GetDriver() error = %v", err)
}
if gotDriver.TeamName != updatedDriver.TeamName {
t.Fatalf("TeamName = %q, want %q", gotDriver.TeamName, updatedDriver.TeamName)
}
sessionDriver := SessionDriver{
SessionKey: session.SessionKey,
DriverNumber: driver.DriverNumber,
MeetingKey: meeting.MeetingKey,
TeamName: "Red Bull Racing",
TeamColour: "3671C6",
}
if err := s.UpsertSessionDriver(sessionDriver); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
if err := s.UpsertSessionDriver(sessionDriver); err != nil {
t.Fatalf("second UpsertSessionDriver() error = %v", err)
}
meetings, err := s.ListMeetingsByYear(2025)
if err != nil {
t.Fatalf("ListMeetingsByYear() error = %v", err)
}
if len(meetings) != 1 {
t.Fatalf("ListMeetingsByYear() len = %d, want 1", len(meetings))
}
sessions, err := s.ListSessionsByMeeting(meeting.MeetingKey)
if err != nil {
t.Fatalf("ListSessionsByMeeting() error = %v", err)
}
if len(sessions) != 1 {
t.Fatalf("ListSessionsByMeeting() len = %d, want 1", len(sessions))
}
sessionDrivers, err := s.ListSessionDrivers(session.SessionKey)
if err != nil {
t.Fatalf("ListSessionDrivers() error = %v", err)
}
if len(sessionDrivers) != 1 {
t.Fatalf("ListSessionDrivers() len = %d, want 1", len(sessionDrivers))
}
}
func TestSessionResultAndStartingGridUpsertRead(t *testing.T) {
s := openTestStore(t)
meetingKey := 1229
sessionKey := 9472
if err := s.UpsertMeeting(Meeting{
MeetingKey: meetingKey,
MeetingName: "Monaco",
Year: 2025,
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
if err := s.UpsertSession(Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
result := SessionResult{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
Points: 25,
NumberOfLaps: 78,
DurationJSON: "5234.567",
GapToLeaderJSON: "0",
}
updatedResult := result
updatedResult.Points = 26
for i := 0; i < 2; i++ {
r := result
if i == 1 {
r = updatedResult
}
if err := s.UpsertSessionResult(r); err != nil {
t.Fatalf("UpsertSessionResult(%d) error = %v", i, err)
}
}
results, err := s.ListSessionResults(sessionKey)
if err != nil {
t.Fatalf("ListSessionResults() error = %v", err)
}
if len(results) != 1 {
t.Fatalf("ListSessionResults() len = %d, want 1", len(results))
}
if results[0].Points != updatedResult.Points {
t.Fatalf("Points = %v, want %v", results[0].Points, updatedResult.Points)
}
grid := StartingGridEntry{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
Position: 1,
LapDuration: 71.234,
}
updatedGrid := grid
updatedGrid.LapDuration = 71.111
for i := 0; i < 2; i++ {
g := grid
if i == 1 {
g = updatedGrid
}
if err := s.UpsertStartingGridEntry(g); err != nil {
t.Fatalf("UpsertStartingGridEntry(%d) error = %v", i, err)
}
}
grids, err := s.ListStartingGrid(sessionKey)
if err != nil {
t.Fatalf("ListStartingGrid() error = %v", err)
}
if len(grids) != 1 {
t.Fatalf("ListStartingGrid() len = %d, want 1", len(grids))
}
if grids[0].LapDuration != updatedGrid.LapDuration {
t.Fatalf("LapDuration = %v, want %v", grids[0].LapDuration, updatedGrid.LapDuration)
}
var resultCount int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM session_results`).Scan(&resultCount); err != nil {
t.Fatalf("count session_results: %v", err)
}
if resultCount != 1 {
t.Fatalf("session_results count = %d, want 1", resultCount)
}
}
func TestWithTxRollback(t *testing.T) {
s := openTestStore(t)
err := s.WithTx(func(tx *sql.Tx) error {
if _, err := tx.Exec(`
INSERT INTO meetings (meeting_key, meeting_name, year, updated_at)
VALUES (999, 'Rollback Test', 2025, ?)
`, time.Now().Unix()); err != nil {
return err
}
return assertAnError("rollback")
})
if err == nil {
t.Fatal("WithTx() error = nil, want rollback error")
}
var count int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM meetings WHERE meeting_key = 999`).Scan(&count); err != nil {
t.Fatalf("count meetings: %v", err)
}
if count != 0 {
t.Fatalf("meetings count after rollback = %d, want 0", count)
}
}
func assertAnError(msg string) error {
return &testError{msg: msg}
}
type testError struct {
msg string
}
func (e *testError) Error() string {
return e.msg
}

View File

@@ -1,707 +1,29 @@
package ui
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/AmanTahiliani/box-box/internal/live"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
)
// ---------------------------------------------------------------------------
// SignalR protocol types
// ---------------------------------------------------------------------------
type F1SignalRMessage struct {
M []struct {
A []json.RawMessage `json:"A"`
} `json:"M"`
R json.RawMessage `json:"R"`
}
// ---------------------------------------------------------------------------
// Data types from the WebSocket feed
// ---------------------------------------------------------------------------
type F1TimingLine struct {
GapToLeader interface{} `json:"GapToLeader"`
IntervalToPositionAhead struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position interface{} `json:"Position"`
RacingNumber string `json:"RacingNumber"`
LastLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
} `json:"LastLapTime"`
BestLapTime struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
Lap int `json:"Lap"`
} `json:"BestLapTime"`
InPit interface{} `json:"InPit"`
PitOut interface{} `json:"PitOut"`
Retired interface{} `json:"Retired"`
KnockedOut interface{} `json:"KnockedOut"`
Cutoff interface{} `json:"Cutoff"`
NumberOfLaps interface{} `json:"NumberOfLaps"`
Sectors map[string]json.RawMessage `json:"Sectors"`
Speeds map[string]json.RawMessage `json:"Speeds"`
}
type F1DriverListEntry struct {
RacingNumber string `json:"RacingNumber"`
BroadcastName string `json:"BroadcastName"`
Tla string `json:"Tla"`
TeamName string `json:"TeamName"`
TeamColour string `json:"TeamColour"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}
type LiveTyreData struct {
Compound string // SOFT, MEDIUM, HARD, INTERMEDIATE, WET
New bool
Age int // laps on current set
}
type LiveRCMessage struct {
Time string // "15:04" formatted
Category string // Flag, SafetyCar, Drs, Other
Flag string // GREEN, YELLOW, RED, etc.
Message string
Lap int
}
type LiveWeatherData struct {
AirTemp float64
TrackTemp float64
Humidity float64
WindSpeed float64
WindDir int
Rainfall bool
}
type LiveSessionMeta struct {
MeetingName string
CircuitName string
SessionType string
SessionName string
}
type LiveSectorData struct {
Value string
PersonalFastest bool
OverallFastest bool
}
type LiveDriverData struct {
RacingNumber string
Position int
PrevPosition int
GapToLeader string
Interval string
LastLapTime string
LastLapPB bool // personal best
LastLapOB bool // overall best
BestLapTime string
BestLapPB bool // just set a new personal best
BestLapOB bool // overall fastest in session
BestLapNum int // lap number when best was set
InPit bool
PitOut bool
Retired bool
KnockedOut bool // eliminated in qualifying
Cutoff bool // currently in elimination zone (danger zone)
OnFlyingLap bool // currently running a timed lap (derived from sector state)
NumberOfLaps int
SpeedTrap string // fastest recorded speed at speed trap
Sectors [3]LiveSectorData
}
type LiveStintData struct {
Compound string
New bool
Laps int
}
type LiveStreamData struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
Tyres map[string]LiveTyreData
RCMessages []LiveRCMessage
Weather LiveWeatherData
Session LiveSessionMeta
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
CurrentLap int
TotalLaps int
Clock string // "HH:MM:SS" remaining at ClockRefTime
ClockRefTime time.Time // UTC when Clock was accurate
ClockExtrapolating bool // true = actively counting down
Stints map[string][]LiveStintData
}
// ---------------------------------------------------------------------------
// WebSocket connection & parsing
// ---------------------------------------------------------------------------
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
req, err := http.NewRequest("GET", negotiateURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
cookies := resp.Cookies()
defer resp.Body.Close()
var neg struct {
ConnectionToken string `json:"ConnectionToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken),
url.QueryEscape(hubName),
)
header := http.Header{}
for _, cookie := range cookies {
header.Add("Cookie", cookie.String())
}
header.Add("User-Agent", "BestHTTP")
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
// Subscribe to all desired topics
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
}
go func() {
defer c.Close()
drivers := make(map[string]LiveDriverData)
driverInfo := make(map[string]F1DriverListEntry)
tyres := make(map[string]LiveTyreData)
stints := make(map[string][]LiveStintData)
var rcMessages []LiveRCMessage
var weather LiveWeatherData
var session LiveSessionMeta
var trackStatus string
var currentLap, totalLaps int
var clock string
var clockRefTime time.Time
var clockExtrapolating bool
sendUpdate := func() {
cpyDrivers := make(map[string]LiveDriverData)
for k, v := range drivers {
cpyDrivers[k] = v
}
cpyInfo := make(map[string]F1DriverListEntry)
for k, v := range driverInfo {
cpyInfo[k] = v
}
cpyTyres := make(map[string]LiveTyreData)
for k, v := range tyres {
cpyTyres[k] = v
}
cpyRC := make([]LiveRCMessage, len(rcMessages))
copy(cpyRC, rcMessages)
cpyStints := make(map[string][]LiveStintData)
for k, v := range stints {
s := make([]LiveStintData, len(v))
copy(s, v)
cpyStints[k] = s
}
select {
case dataChan <- LiveStreamData{
Drivers: cpyDrivers,
DriverInfo: cpyInfo,
Tyres: cpyTyres,
RCMessages: cpyRC,
Weather: weather,
Session: session,
TrackStatus: trackStatus,
CurrentLap: currentLap,
TotalLaps: totalLaps,
Clock: clock,
ClockRefTime: clockRefTime,
ClockExtrapolating: clockExtrapolating,
Stints: cpyStints,
}:
default:
}
}
// processTopic handles a single topic's JSON payload (shared by R and M paths)
processTopic := func(topic string, data json.RawMessage) bool {
updated := false
switch topic {
case "TimingData":
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &td) == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line)
updated = true
}
}
}
case "DriverList":
var dlMap map[string]json.RawMessage
if json.Unmarshal(data, &dlMap) == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
driverInfo[num] = entry
updated = true
}
}
}
case "LapCount":
var lc struct {
CurrentLap json.Number `json:"CurrentLap"`
TotalLaps json.Number `json:"TotalLaps"`
}
if json.Unmarshal(data, &lc) == nil {
if v, err := lc.CurrentLap.Int64(); err == nil {
currentLap = int(v)
}
if v, err := lc.TotalLaps.Int64(); err == nil {
totalLaps = int(v)
}
updated = true
}
case "ExtrapolatedClock":
var ec struct {
Remaining string `json:"Remaining"`
Utc string `json:"Utc"`
Extrapolating bool `json:"Extrapolating"`
}
if json.Unmarshal(data, &ec) == nil && ec.Remaining != "" {
clock = ec.Remaining
clockExtrapolating = ec.Extrapolating
if ec.Utc != "" {
// Try RFC3339 first, then with milliseconds
if t, err := time.Parse(time.RFC3339, ec.Utc); err == nil {
clockRefTime = t
} else if t, err := time.Parse("2006-01-02T15:04:05.999Z", ec.Utc); err == nil {
clockRefTime = t
} else {
clockRefTime = time.Now()
}
} else {
clockRefTime = time.Now()
}
updated = true
}
case "TrackStatus":
var ts struct {
Status string `json:"Status"`
Message string `json:"Message"`
}
if json.Unmarshal(data, &ts) == nil && ts.Status != "" {
trackStatus = ts.Status
updated = true
}
case "RaceControlMessages":
var rcm struct {
Messages map[string]json.RawMessage `json:"Messages"`
}
if json.Unmarshal(data, &rcm) == nil {
for _, msgRaw := range rcm.Messages {
var msg struct {
Utc string `json:"Utc"`
Category string `json:"Category"`
Flag string `json:"Flag"`
Message string `json:"Message"`
Lap int `json:"Lap"`
}
if json.Unmarshal(msgRaw, &msg) == nil && msg.Message != "" {
t := ""
if len(msg.Utc) >= 19 {
t = msg.Utc[11:16]
}
rcMessages = append(rcMessages, LiveRCMessage{
Time: t,
Category: msg.Category,
Flag: msg.Flag,
Message: msg.Message,
Lap: msg.Lap,
})
updated = true
}
}
}
case "WeatherData":
var wd struct {
AirTemp json.Number `json:"AirTemp"`
TrackTemp json.Number `json:"TrackTemp"`
Humidity json.Number `json:"Humidity"`
WindSpeed json.Number `json:"WindSpeed"`
WindDirection json.Number `json:"WindDirection"`
Rainfall json.Number `json:"Rainfall"`
}
if json.Unmarshal(data, &wd) == nil {
if v, err := wd.AirTemp.Float64(); err == nil {
weather.AirTemp = v
}
if v, err := wd.TrackTemp.Float64(); err == nil {
weather.TrackTemp = v
}
if v, err := wd.Humidity.Float64(); err == nil {
weather.Humidity = v
}
if v, err := wd.WindSpeed.Float64(); err == nil {
weather.WindSpeed = v
}
if v, err := wd.WindDirection.Int64(); err == nil {
weather.WindDir = int(v)
}
if v, err := wd.Rainfall.Float64(); err == nil {
weather.Rainfall = v > 0
}
updated = true
}
case "SessionInfo":
var si struct {
Meeting struct {
Name string `json:"Name"`
} `json:"Meeting"`
Name string `json:"Name"`
Type string `json:"Type"`
}
if json.Unmarshal(data, &si) == nil {
if si.Meeting.Name != "" {
session.MeetingName = si.Meeting.Name
}
if si.Name != "" {
session.SessionName = si.Name
}
if si.Type != "" {
session.SessionType = si.Type
}
updated = true
}
case "CurrentTyres":
var ct map[string]json.RawMessage
if json.Unmarshal(data, &ct) == nil {
for num, raw := range ct {
if num == "_kf" {
continue
}
var td struct {
Compound string `json:"Compound"`
New string `json:"New"`
}
if json.Unmarshal(raw, &td) == nil && td.Compound != "" {
// Preserve the existing Age — CurrentTyres only carries
// compound and newness, not lap count.
t := tyres[num]
t.Compound = td.Compound
t.New = td.New == "true" || td.New == "True"
tyres[num] = t
updated = true
}
}
}
case "TimingAppData":
var tad struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &tad) == nil {
for num, lineRaw := range tad.Lines {
var line struct {
Stints map[string]json.RawMessage `json:"Stints"`
}
if json.Unmarshal(lineRaw, &line) == nil && line.Stints != nil {
var driverStints []LiveStintData
for _, sRaw := range line.Stints {
var st struct {
Compound string `json:"Compound"`
New string `json:"New"`
TotalLaps int `json:"TotalLaps"`
}
if json.Unmarshal(sRaw, &st) == nil && st.Compound != "" {
driverStints = append(driverStints, LiveStintData{
Compound: st.Compound,
New: st.New == "true" || st.New == "True",
Laps: st.TotalLaps,
})
}
}
if len(driverStints) > 0 {
stints[num] = driverStints
// Sync compound and age from the latest stint.
// Stints are authoritative: they include historical data and
// carry both compound and laps on the current set.
lastStint := driverStints[len(driverStints)-1]
t := tyres[num]
t.Age = lastStint.Laps
if lastStint.Compound != "" {
t.Compound = lastStint.Compound
t.New = lastStint.New
}
tyres[num] = t
updated = true
}
}
}
}
case "TimingStats":
var ts struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(data, &ts) == nil {
for num, lineRaw := range ts.Lines {
var line struct {
PersonalBestLapTime struct {
Value string `json:"Value"`
} `json:"PersonalBestLapTime"`
}
if json.Unmarshal(lineRaw, &line) == nil {
if d, ok := drivers[num]; ok && line.PersonalBestLapTime.Value != "" {
d.BestLapTime = line.PersonalBestLapTime.Value
drivers[num] = d
updated = true
}
}
}
}
}
return updated
}
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WS Read Error:", err)
return
}
var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil {
continue
}
updated := false
// Full state payload (R)
if len(parsed.R) > 2 {
var rMap map[string]json.RawMessage
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
for topic, data := range rMap {
if processTopic(topic, data) {
updated = true
}
}
}
}
// Incremental feed (M)
for _, m := range parsed.M {
if len(m.A) > 1 {
var topic string
json.Unmarshal(m.A[0], &topic)
if processTopic(topic, m.A[1]) {
updated = true
}
}
}
if updated {
sendUpdate()
}
}
}()
return nil
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num]
if !exists {
d = LiveDriverData{RacingNumber: num}
if line.RacingNumber != "" {
d.RacingNumber = line.RacingNumber
}
}
if line.Position != nil {
var newPos int
switch v := line.Position.(type) {
case string:
fmt.Sscanf(v, "%d", &newPos)
case float64:
newPos = int(v)
}
if newPos > 0 && newPos != d.Position {
d.PrevPosition = d.Position
d.Position = newPos
}
}
if line.GapToLeader != nil {
if s := extractStringVal(line.GapToLeader); s != "" {
d.GapToLeader = s
}
}
if line.IntervalToPositionAhead.Value != nil {
if s := extractStringVal(line.IntervalToPositionAhead.Value); s != "" {
d.Interval = s
}
}
if line.LastLapTime.Value != "" {
d.LastLapTime = line.LastLapTime.Value
d.LastLapPB = line.LastLapTime.PersonalFastest
d.LastLapOB = line.LastLapTime.OverallFastest
}
if line.BestLapTime.Value != "" {
d.BestLapTime = line.BestLapTime.Value
d.BestLapPB = line.BestLapTime.PersonalFastest
d.BestLapOB = line.BestLapTime.OverallFastest
if line.BestLapTime.Lap > 0 {
d.BestLapNum = line.BestLapTime.Lap
}
}
if line.InPit != nil {
d.InPit = toBool(line.InPit)
}
if line.PitOut != nil {
d.PitOut = toBool(line.PitOut)
}
if line.Retired != nil {
d.Retired = toBool(line.Retired)
}
if line.KnockedOut != nil {
d.KnockedOut = toBool(line.KnockedOut)
}
if line.Cutoff != nil {
d.Cutoff = toBool(line.Cutoff)
}
if line.NumberOfLaps != nil {
if v, ok := toInt(line.NumberOfLaps); ok {
d.NumberOfLaps = v
}
}
// Parse speed trap (ST = highest speed on track)
if st, ok := line.Speeds["ST"]; ok {
var sp struct {
Value string `json:"Value"`
}
if json.Unmarshal(st, &sp) == nil && sp.Value != "" {
d.SpeedTrap = sp.Value
}
}
// Parse sector times — handle empty Value as a sector clear (new lap starting)
for idx, sRaw := range line.Sectors {
i := 0
fmt.Sscanf(idx, "%d", &i)
if i >= 0 && i < 3 {
var sec struct {
Value string `json:"Value"`
PersonalFastest bool `json:"PersonalFastest"`
OverallFastest bool `json:"OverallFastest"`
}
if json.Unmarshal(sRaw, &sec) == nil {
if sec.Value == "" {
d.Sectors[i] = LiveSectorData{} // clear = new lap starting
} else {
d.Sectors[i] = LiveSectorData{
Value: sec.Value,
PersonalFastest: sec.PersonalFastest,
OverallFastest: sec.OverallFastest,
}
}
}
}
}
// Derive: driver is on a flying lap if S1 or S2 populated but S3 not yet
d.OnFlyingLap = !d.InPit && !d.Retired &&
(d.Sectors[0].Value != "" || d.Sectors[1].Value != "") &&
d.Sectors[2].Value == ""
drivers[num] = d
}
// extractStringVal extracts a string from a timing value that may arrive as a
// plain string, a float64, or a {"Value": "..."} object from the SignalR feed.
func extractStringVal(v interface{}) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
if val == 0 {
return ""
}
return fmt.Sprintf("+%.3f", val)
case map[string]interface{}:
if s, ok := val["Value"].(string); ok {
return s
}
}
return ""
}
func toBool(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case string:
return val == "true" || val == "True"
}
return false
}
func toInt(v interface{}) (int, bool) {
switch val := v.(type) {
case float64:
return int(val), true
case json.Number:
if i, err := val.Int64(); err == nil {
return int(i), true
}
case string:
var i int
if _, err := fmt.Sscanf(val, "%d", &i); err == nil {
return i, true
}
}
return 0, false
}
// Live timing types re-exported from internal/live for TUI sub-views.
type (
F1DriverListEntry = live.F1DriverListEntry
LiveTyreData = live.LiveTyreData
LiveRCMessage = live.LiveRCMessage
LiveWeatherData = live.LiveWeatherData
LiveSessionMeta = live.LiveSessionMeta
LiveSectorData = live.LiveSectorData
LiveDriverData = live.LiveDriverData
LiveStintData = live.LiveStintData
LiveStreamData = live.LiveStreamData
)
// ---------------------------------------------------------------------------
// Model wrapper
@@ -931,7 +253,7 @@ func NewOfficialLiveModel() OfficialLiveModel {
}
func (m OfficialLiveModel) Init() tea.Cmd {
err := ConnectToF1LiveTiming(m.dataChan)
err := live.ConnectToF1LiveTiming(m.dataChan)
if err != nil {
return func() tea.Msg { return err }
}

View File

@@ -8,7 +8,7 @@ import (
"sync"
"time"
"github.com/AmanTahiliani/box-box/internal/ui"
"github.com/AmanTahiliani/box-box/internal/live"
)
// sseClient is a connected SSE subscriber.
@@ -30,7 +30,7 @@ type SSEHub struct {
broadcast chan sseEvent
mu sync.RWMutex
lastSnapshot *ui.LiveStreamData
lastSnapshot *live.LiveStreamData
isLive bool
}
@@ -87,7 +87,7 @@ func formatSSEFrame(event string, data []byte) []byte {
}
// Snapshot returns the latest live data snapshot and whether a session is active.
func (h *SSEHub) Snapshot() (*ui.LiveStreamData, bool) {
func (h *SSEHub) Snapshot() (*live.LiveStreamData, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.lastSnapshot, h.isLive
@@ -134,9 +134,9 @@ func (s *Server) signalRLoop() {
// connectAndDrain establishes a SignalR connection and drains the data channel
// until the feed goes silent for 60 seconds.
func (s *Server) connectAndDrain() error {
dataChan := make(chan ui.LiveStreamData, 16)
dataChan := make(chan live.LiveStreamData, 16)
if err := ui.ConnectToF1LiveTiming(dataChan); err != nil {
if err := live.ConnectToF1LiveTiming(dataChan); err != nil {
return err
}
log.Printf("web: live feed connected")