mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add local-first refactor foundation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
471
internal/ingest/ingest.go
Normal 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
|
||||
}
|
||||
381
internal/ingest/ingest_test.go
Normal file
381
internal/ingest/ingest_test.go
Normal 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
270
internal/ingest/openf1.go
Normal 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)
|
||||
}
|
||||
49
internal/ingest/progress.go
Normal file
49
internal/ingest/progress.go
Normal 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)
|
||||
}
|
||||
}
|
||||
323
internal/live/parser_test.go
Normal file
323
internal/live/parser_test.go
Normal 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
82
internal/live/signalr.go
Normal 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
495
internal/live/state.go
Normal 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
144
internal/live/types.go
Normal 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
92
internal/store/db.go
Normal 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
295
internal/store/meetings.go
Normal 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
|
||||
}
|
||||
92
internal/store/migrations.go
Normal file
92
internal/store/migrations.go
Normal 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
|
||||
}
|
||||
118
internal/store/migrations/001_initial.sql
Normal file
118
internal/store/migrations/001_initial.sql
Normal 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
106
internal/store/models.go
Normal 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
177
internal/store/raw.go
Normal 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
302
internal/store/results.go
Normal 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
31
internal/store/runs.go
Normal 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
|
||||
}
|
||||
450
internal/store/store_test.go
Normal file
450
internal/store/store_test.go
Normal 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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user