Repository navigation
Expand file tree
/
Copy pathlatest.go
More file actions
274 lines (255 loc) · 8.55 KB
/
Copy pathlatest.go
File metadata and controls
274 lines (255 loc) · 8.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
package main
import (
"encoding/json"
"fmt"
"math"
"net/http"
"sort"
"time"
"github.com/labstack/echo/v4"
)
// raceWindow: races are capped at 3h incl. stoppages, almost all finish inside 2.
const raceWindow = 2 * time.Hour
var sessionLabels = map[string]string{"fp1": "Free Practice 1", "fp2": "Free Practice 2", "fp3": "Free Practice 3", "sprintQualy": "Sprint Qualifying", "sprintRace": "Sprint Race", "qualy": "Qualifying", "race": "Race"}
var openF1SessionNames = map[string]string{"fp1": "Practice 1", "fp2": "Practice 2", "fp3": "Practice 3", "sprintQualy": "Sprint Qualifying", "sprintRace": "Sprint", "qualy": "Qualifying", "race": "Race"}
// sessionWindows keep a running session from being picked as "latest".
var sessionWindows = map[string]time.Duration{"fp1": time.Hour, "fp2": time.Hour, "fp3": time.Hour, "sprintQualy": 45 * time.Minute, "sprintRace": 45 * time.Minute, "qualy": time.Hour}
func matchOpenF1Session(sessions []openF1Session, key string, at time.Time) *openF1Session {
var match *openF1Session
best := 48 * time.Hour
for i := range sessions {
start, err := time.Parse(time.RFC3339, sessions[i].Start)
delta := start.Sub(at)
if delta < 0 {
delta = -delta
}
if err == nil && sessions[i].Name == openF1SessionNames[key] && delta < best {
match, best = &sessions[i], delta
}
}
return match
}
type finishedSession struct {
race *race
key string
at time.Time
}
// finishedSessions lists the weekend's finished non-race sessions, newest first.
func finishedSessions(races []race, now time.Time) []finishedSession {
var found []finishedSession
for i := range races {
for _, key := range sessionKeys {
if key == "race" {
continue // covered by /f1/last_race/
}
at := rawSessionDateTime(races[i].Schedule[key])
if at.IsZero() || at.Add(sessionWindows[key]).After(now) {
continue
}
found = append(found, finishedSession{&races[i], key, at})
}
}
sort.Slice(found, func(i, j int) bool { return found[i].at.After(found[j].at) })
return found
}
func sessionResultsKey(year int, s finishedSession) string {
return fmt.Sprintf("session_results:%d:%d:%s", year, s.race.Round, s.key)
}
func (a *app) sessionResponse(year int, s finishedSession, results any) map[string]any {
return map[string]any{"season": year, "round": s.race.Round, "raceName": s.race.RaceName, "url": s.race.URL, "session": sessionLabels[s.key], "key": s.key, "date": formatRFC3339(s.at.In(a.config.timezone)), "results": results}
}
func (a *app) latestSession(c echo.Context) error {
if cached, ok := a.cache.get("f1:latest_session", a.now()); ok {
return c.JSON(http.StatusOK, cached)
}
now := a.now()
year := now.In(a.config.timezone).Year()
races, err := a.fetchSchedule(year)
if err != nil {
return c.JSON(http.StatusOK, map[string]string{"error": "Exception while fetching: " + err.Error()})
}
sessions := finishedSessions(races, now)
if len(sessions) == 0 {
return c.JSON(http.StatusOK, map[string]string{"message": "No completed session yet"})
}
latest := sessions[0]
result := a.sessionResponse(year, latest, []any{})
ttl, have := 2*time.Minute, false
// cached for good: OpenF1's free tier locks out all access while any session is live
cacheKey := sessionResultsKey(year, latest)
if cached, ok := a.cache.getDurable(cacheKey, now); ok {
result["results"], ttl, have = cached, 5*time.Minute, true
} else if rows, ended, fetchErr := a.fetchSessionResults(latest.key, latest.at, now); fetchErr != nil {
result["upstream_error"] = fetchErr.Error()
} else {
result["results"], ttl = rows, 5*time.Minute
have = len(rows) > 0
if ended && have {
a.keep(cacheKey, rows, now)
}
}
if !have {
// no latest results yet (post-session lockout): show the newest one we hold
for _, s := range sessions[1:] {
if cached, ok := a.cache.getDurable(sessionResultsKey(year, s), now); ok {
fallback := a.sessionResponse(year, s, cached)
fallback["pending"] = result["session"]
if upstreamErr, ok := result["upstream_error"]; ok {
fallback["upstream_error"] = upstreamErr
}
result, ttl = fallback, 2*time.Minute
break
}
}
}
a.cache.set("f1:latest_session", result, now.Add(ttl))
return c.JSON(http.StatusOK, result)
}
func (a *app) fetchSessionResults(key string, at, now time.Time) ([]map[string]any, bool, error) {
var sessions []openF1Session
if err := a.fetchOpenF1(fmt.Sprintf("%s/sessions?year=%d", a.config.openF1Base, at.Year()), &sessions); err != nil {
return nil, false, err
}
match := matchOpenF1Session(sessions, key, at)
if match == nil {
return nil, false, fmt.Errorf("session not on OpenF1 yet")
}
var results []struct {
Position sessionPosition `json:"position"`
DriverNumber int `json:"driver_number"`
Duration json.RawMessage `json:"duration"`
Gap json.RawMessage `json:"gap_to_leader"`
Laps int `json:"number_of_laps"`
DNF bool `json:"dnf"`
DNS bool `json:"dns"`
DSQ bool `json:"dsq"`
}
if err := a.fetchOpenF1(fmt.Sprintf("%s/session_result?session_key=%d", a.config.openF1Base, match.Key), &results); err != nil {
return nil, false, err
}
var drivers []struct {
Number int `json:"driver_number"`
Acronym string `json:"name_acronym"`
LastName string `json:"last_name"`
Team string `json:"team_name"`
}
if err := a.fetchOpenF1(fmt.Sprintf("%s/drivers?session_key=%d", a.config.openF1Base, match.Key), &drivers); err != nil {
return nil, false, err
}
byNumber := make(map[int]int, len(drivers))
for i, d := range drivers {
byNumber[d.Number] = i
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].Position.n == nil || results[j].Position.n == nil {
return results[i].Position.n != nil
}
return *results[i].Position.n < *results[j].Position.n
})
rows := make([]map[string]any, 0, len(results))
for _, r := range results {
driver := fmt.Sprint(r.DriverNumber)
row := map[string]any{"position": nil, "driver": driver, "surname": driver, "team": "", "laps": r.Laps}
if i, ok := byNumber[r.DriverNumber]; ok {
row["driver"], row["surname"], row["team"] = drivers[i].Acronym, drivers[i].LastName, shortTeamName(drivers[i].Team)
}
if r.Position.n != nil {
row["position"] = *r.Position.n
}
row["time"] = sessionTimeCell(r.Duration, r.Gap, r.DNF, r.DNS, r.DSQ, r.Position.n != nil && *r.Position.n == 1)
if row["time"] == "" && r.Position.text != "" {
row["time"] = r.Position.text
}
rows = append(rows, row)
}
end, err := time.Parse(time.RFC3339, match.End)
return rows, err == nil && end.Before(now), nil
}
// sessionPosition is OpenF1's `position`: an integer, null, or a status string
// like "RT" for a driver who didn't set a time. One string used to fail the
// whole result list.
type sessionPosition struct {
n *int
text string
}
func (p *sessionPosition) UnmarshalJSON(raw []byte) error {
if string(raw) == "null" {
return nil
}
var n int
if json.Unmarshal(raw, &n) == nil {
p.n = &n
return nil
}
var text string
if json.Unmarshal(raw, &text) == nil {
p.text = text
}
return nil
}
func sessionTimeCell(duration, gap json.RawMessage, dnf, dns, dsq, leader bool) string {
switch {
case dsq:
return "DSQ"
case dns:
return "DNS"
case dnf:
return "DNF"
}
if leader {
if v, ok := lastNumber(duration); ok {
return formatSessionDuration(v)
}
}
var text string
if json.Unmarshal(gap, &text) == nil && text != "" {
return text
}
if v, ok := lastNumber(gap); ok {
return fmt.Sprintf("+%.3f", v)
}
if v, ok := lastNumber(duration); ok {
return formatSessionDuration(v)
}
return ""
}
// lastNumber reads a number or the last non-null array entry (qualifying is [Q1, Q2, Q3]).
func lastNumber(raw json.RawMessage) (float64, bool) {
if len(raw) == 0 || string(raw) == "null" {
return 0, false
}
var one float64
if json.Unmarshal(raw, &one) == nil {
return one, true
}
var many []*float64
if json.Unmarshal(raw, &many) == nil {
for i := len(many) - 1; i >= 0; i-- {
if many[i] != nil {
return *many[i], true
}
}
}
return 0, false
}
func formatSessionDuration(seconds float64) string {
total := int(math.Round(seconds * 1000))
ms, s := total%1000, total/1000
h, m, s := s/3600, (s%3600)/60, s%60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d.%03d", h, m, s, ms)
}
return fmt.Sprintf("%d:%02d.%03d", m, s, ms)
}
// shortTeamName maps OpenF1 team names onto the API's Ergast-derived short names.
func shortTeamName(name string) string {
switch name {
case "Red Bull Racing":
return "Red Bull"
case "Racing Bulls":
return "RB"
case "Haas F1 Team":
return "Haas"
}
return name
}