mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 11:54:59 -04:00
feat(live): add team radio ticker
Subscribe to TeamRadio on the F1 live feed, parse defensive capture snapshots and patches, and relay capped captures through live snapshots with the SessionInfo static path. Add the frontend CDN URL helper and ticker using one shared audio element.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) {
|
||||
state.ProcessTopic("SessionInfo", json.RawMessage(`{
|
||||
"Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}},
|
||||
"Name": "Race",
|
||||
"Type": "Race"
|
||||
"Type": "Race",
|
||||
"Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/"
|
||||
}`))
|
||||
s := state.Snapshot().Session
|
||||
if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" {
|
||||
t.Fatalf("session = %+v", s)
|
||||
}
|
||||
if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" {
|
||||
t.Fatalf("session path = %q", s.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicDriverList(t *testing.T) {
|
||||
@@ -285,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) {
|
||||
state := live.NewState()
|
||||
snapshot := json.RawMessage(`{
|
||||
"Captures": [
|
||||
{"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"},
|
||||
{"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"}
|
||||
]
|
||||
}`)
|
||||
|
||||
if !state.ProcessTopic("TeamRadio", snapshot) {
|
||||
t.Fatal("TeamRadio snapshot should update state")
|
||||
}
|
||||
|
||||
patch := json.RawMessage(`{
|
||||
"Captures": {
|
||||
"2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"}
|
||||
}
|
||||
}`)
|
||||
if !state.ProcessTopic("TeamRadio", patch) {
|
||||
t.Fatal("TeamRadio keyed patch should update state")
|
||||
}
|
||||
|
||||
radio := state.Snapshot().TeamRadio
|
||||
if len(radio) != 3 {
|
||||
t.Fatalf("radio captures = %d, want 3", len(radio))
|
||||
}
|
||||
if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" {
|
||||
t.Fatalf("radio order = %+v", radio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioMalformed(t *testing.T) {
|
||||
state := live.NewState()
|
||||
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) {
|
||||
t.Fatal("incomplete TeamRadio capture should not update state")
|
||||
}
|
||||
if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) {
|
||||
t.Fatal("unexpected TeamRadio captures shape should not update state")
|
||||
}
|
||||
if len(state.Snapshot().TeamRadio) != 0 {
|
||||
t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) {
|
||||
state := live.NewState()
|
||||
for i := 0; i < 25; i++ {
|
||||
payload := json.RawMessage([]byte(fmt.Sprintf(`{
|
||||
"Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}]
|
||||
}`, i, i, i)))
|
||||
state.ProcessTopic("TeamRadio", payload)
|
||||
}
|
||||
|
||||
radio := state.Snapshot().TeamRadio
|
||||
if len(radio) != 20 {
|
||||
t.Fatalf("radio captures = %d, want 20", len(radio))
|
||||
}
|
||||
if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" {
|
||||
t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessTopicWeatherData(t *testing.T) {
|
||||
state := live.NewState()
|
||||
state.ProcessTopic("WeatherData", json.RawMessage(`{
|
||||
|
||||
@@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error {
|
||||
"RaceControlMessages",
|
||||
"WeatherData",
|
||||
"SessionInfo",
|
||||
"TeamRadio",
|
||||
"CurrentTyres",
|
||||
"TimingAppData",
|
||||
"TimingStats",
|
||||
@@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error {
|
||||
return err
|
||||
}
|
||||
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
|
||||
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`)
|
||||
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -23,6 +25,7 @@ type State struct {
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
TrackStatus string
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
@@ -34,6 +37,7 @@ type State struct {
|
||||
}
|
||||
|
||||
const signalRRecordSeparator = byte(0x1e)
|
||||
const maxTeamRadioCaptures = 20
|
||||
|
||||
// NewState returns an empty live timing accumulator.
|
||||
func NewState() *State {
|
||||
@@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
}
|
||||
cpyRC := make([]LiveRCMessage, len(s.RCMessages))
|
||||
copy(cpyRC, s.RCMessages)
|
||||
cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio))
|
||||
copy(cpyRadio, s.TeamRadio)
|
||||
cpyStints := make(map[string][]LiveStintData, len(s.Stints))
|
||||
for k, v := range s.Stints {
|
||||
st := make([]LiveStintData, len(v))
|
||||
@@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData {
|
||||
RCMessages: cpyRC,
|
||||
Weather: s.Weather,
|
||||
Session: s.Session,
|
||||
TeamRadio: cpyRadio,
|
||||
TrackStatus: s.TrackStatus,
|
||||
CurrentLap: s.CurrentLap,
|
||||
TotalLaps: s.TotalLaps,
|
||||
@@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
} `json:"Meeting"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Path string `json:"Path"`
|
||||
}
|
||||
if json.Unmarshal(data, &si) == nil {
|
||||
if si.Meeting.Name != "" {
|
||||
@@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool {
|
||||
if si.Type != "" {
|
||||
s.Session.SessionType = si.Type
|
||||
}
|
||||
if si.Path != "" {
|
||||
s.Session.Path = si.Path
|
||||
}
|
||||
updated = true
|
||||
}
|
||||
case "TeamRadio":
|
||||
updated = s.updateTeamRadio(data)
|
||||
case "CurrentTyres":
|
||||
var ct map[string]json.RawMessage
|
||||
if json.Unmarshal(data, &ct) == nil {
|
||||
@@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) {
|
||||
return out, err == nil
|
||||
}
|
||||
|
||||
func (s *State) updateTeamRadio(data json.RawMessage) bool {
|
||||
var payload struct {
|
||||
Captures json.RawMessage `json:"Captures"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
log.Printf("live: skipping malformed TeamRadio payload: %v", err)
|
||||
return false
|
||||
}
|
||||
if len(payload.Captures) == 0 || string(payload.Captures) == "null" {
|
||||
log.Printf("live: skipping TeamRadio payload without Captures")
|
||||
return false
|
||||
}
|
||||
|
||||
captures := indexedRawValues(payload.Captures)
|
||||
if len(captures) == 0 {
|
||||
log.Printf("live: skipping TeamRadio payload with unexpected Captures shape")
|
||||
return false
|
||||
}
|
||||
|
||||
updated := false
|
||||
for _, captureRaw := range captures {
|
||||
var capture LiveRadioCapture
|
||||
if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil {
|
||||
log.Printf("live: skipping malformed TeamRadio capture: %v", err)
|
||||
continue
|
||||
}
|
||||
if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" {
|
||||
log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path)
|
||||
continue
|
||||
}
|
||||
if s.hasTeamRadioCapture(capture) {
|
||||
continue
|
||||
}
|
||||
s.TeamRadio = append(s.TeamRadio, capture)
|
||||
updated = true
|
||||
}
|
||||
if updated {
|
||||
sort.SliceStable(s.TeamRadio, func(i, j int) bool {
|
||||
return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc
|
||||
})
|
||||
if len(s.TeamRadio) > maxTeamRadioCaptures {
|
||||
s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...)
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool {
|
||||
for _, existing := range s.TeamRadio {
|
||||
if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *State) updatePositions(data json.RawMessage) bool {
|
||||
var payload struct {
|
||||
Position json.RawMessage `json:"Position"`
|
||||
@@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw {
|
||||
fmt.Sscanf(k, "%d", &i)
|
||||
values = append(values, indexedRaw{Index: i, Raw: v})
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return values[i].Index < values[j].Index
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ type LiveSessionMeta struct {
|
||||
CircuitName string
|
||||
SessionType string
|
||||
SessionName string
|
||||
Path string
|
||||
}
|
||||
|
||||
// LivePositionData is the latest raw F1 GPS position for one driver.
|
||||
@@ -144,6 +145,13 @@ type LiveStintData struct {
|
||||
Laps int
|
||||
}
|
||||
|
||||
// LiveRadioCapture is one team radio audio clip from the live timing feed.
|
||||
type LiveRadioCapture struct {
|
||||
Utc string
|
||||
RacingNumber string
|
||||
Path string
|
||||
}
|
||||
|
||||
// LiveStreamData is an immutable snapshot of all live timing state.
|
||||
type LiveStreamData struct {
|
||||
Drivers map[string]LiveDriverData
|
||||
@@ -153,6 +161,7 @@ type LiveStreamData struct {
|
||||
RCMessages []LiveRCMessage
|
||||
Weather LiveWeatherData
|
||||
Session LiveSessionMeta
|
||||
TeamRadio []LiveRadioCapture
|
||||
TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC
|
||||
CurrentLap int
|
||||
TotalLaps int
|
||||
|
||||
Reference in New Issue
Block a user