Skip to content

Commit a932987

Browse files
committed
Refactoring
1 parent 26248ea commit a932987

39 files changed

Lines changed: 7394 additions & 7308 deletions

backend/app_helpers.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
)
10+
11+
func parseYearRound(w http.ResponseWriter, r *http.Request) (int, int, bool) {
12+
year, err1 := strconv.Atoi(r.PathValue("year"))
13+
round, err2 := strconv.Atoi(r.PathValue("round"))
14+
if err1 != nil || err2 != nil {
15+
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "Invalid path"})
16+
return 0, 0, false
17+
}
18+
return year, round, true
19+
}
20+
21+
func defaultString(v, def string) string {
22+
if strings.TrimSpace(v) == "" {
23+
return def
24+
}
25+
return v
26+
}
27+
28+
func asString(v any) string {
29+
switch x := v.(type) {
30+
case string:
31+
return x
32+
case fmt.Stringer:
33+
return x.String()
34+
default:
35+
return ""
36+
}
37+
}
38+
39+
func asInt(v any) int {
40+
switch x := v.(type) {
41+
case float64:
42+
return int(x)
43+
case int:
44+
return x
45+
case json.Number:
46+
i, _ := x.Int64()
47+
return int(i)
48+
case string:
49+
i, _ := strconv.Atoi(x)
50+
return i
51+
default:
52+
return 0
53+
}
54+
}
55+
56+
func writeJSON(w http.ResponseWriter, status int, v any) {
57+
w.Header().Set("Content-Type", "application/json")
58+
w.WriteHeader(status)
59+
_ = json.NewEncoder(w).Encode(v)
60+
}
61+
62+
func asFloat(v any, def float64) float64 {
63+
switch x := v.(type) {
64+
case float64:
65+
return x
66+
case int:
67+
return float64(x)
68+
case json.Number:
69+
f, _ := x.Float64()
70+
return f
71+
case string:
72+
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
73+
if err == nil {
74+
return f
75+
}
76+
}
77+
return def
78+
}
79+
80+
func asBool(v any) bool {
81+
switch x := v.(type) {
82+
case bool:
83+
return x
84+
case string:
85+
return isTrue(x)
86+
default:
87+
return false
88+
}
89+
}
90+
91+
func parseGap(gap string) (float64, bool) {
92+
gap = strings.TrimSpace(gap)
93+
if gap == "" || strings.HasPrefix(gap, "LAP ") {
94+
return 0, false
95+
}
96+
if strings.Contains(strings.ToUpper(gap), "LAP") {
97+
return 0, false
98+
}
99+
gap = strings.TrimPrefix(gap, "+")
100+
v, err := strconv.ParseFloat(gap, 64)
101+
if err != nil {
102+
return 0, false
103+
}
104+
return v, true
105+
}
106+
107+
func roundTo(v float64, places int) float64 {
108+
pow := 1.0
109+
for i := 0; i < places; i++ {
110+
pow *= 10
111+
}
112+
return float64(int(v*pow+0.5)) / pow
113+
}

backend/app_service.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
"time"
13+
)
14+
15+
func (a *app) ensureSchedule(year int) error {
16+
if a.fileExists(filepath.Join("seasons", strconv.Itoa(year), "schedule.json")) {
17+
return nil
18+
}
19+
20+
a.scheduleLockMu.Lock()
21+
lock := a.scheduleLocks[year]
22+
if lock == nil {
23+
lock = &sync.Mutex{}
24+
a.scheduleLocks[year] = lock
25+
}
26+
a.scheduleLockMu.Unlock()
27+
28+
lock.Lock()
29+
defer lock.Unlock()
30+
31+
if a.fileExists(filepath.Join("seasons", strconv.Itoa(year), "schedule.json")) {
32+
return nil
33+
}
34+
35+
if a.processor == nil {
36+
return errors.New("session processor is not initialized")
37+
}
38+
return a.processor.EnsureSchedule(context.Background(), year)
39+
}
40+
41+
func (a *app) ensureSessionData(year, round int, sessionType string, onStatus func(string)) error {
42+
path := filepath.Join("sessions", strconv.Itoa(year), strconv.Itoa(round), sessionType, "replay.json")
43+
if a.fileExists(path) {
44+
return nil
45+
}
46+
47+
key := fmt.Sprintf("%d_%d_%s", year, round, sessionType)
48+
a.sessionLockMu.Lock()
49+
lock := a.sessionLocks[key]
50+
if lock == nil {
51+
lock = &sync.Mutex{}
52+
a.sessionLocks[key] = lock
53+
}
54+
a.sessionLockMu.Unlock()
55+
56+
lock.Lock()
57+
defer lock.Unlock()
58+
59+
if a.fileExists(path) {
60+
return nil
61+
}
62+
63+
if a.processor == nil {
64+
return errors.New("session processor is not initialized")
65+
}
66+
return a.processor.ProcessSession(context.Background(), year, round, sessionType, onStatus)
67+
}
68+
69+
func (a *app) buildEvents(year int) (map[string]any, error) {
70+
raw, err := a.readJSONAny(filepath.Join("seasons", strconv.Itoa(year), "schedule.json"))
71+
if err != nil {
72+
return nil, err
73+
}
74+
root, ok := raw.(map[string]any)
75+
if !ok {
76+
return nil, errors.New("invalid schedule format")
77+
}
78+
b, _ := json.Marshal(root)
79+
_ = json.Unmarshal(b, &root)
80+
81+
events, _ := root["events"].([]any)
82+
now := time.Now().UTC()
83+
lastPast := -1
84+
85+
for i, evtAny := range events {
86+
evt, ok := evtAny.(map[string]any)
87+
if !ok {
88+
continue
89+
}
90+
hasPast := false
91+
sessions, _ := evt["sessions"].([]any)
92+
for _, sAny := range sessions {
93+
s, ok := sAny.(map[string]any)
94+
if !ok {
95+
continue
96+
}
97+
dateStr := asString(s["date_utc"])
98+
available := false
99+
if ts, ok := parseDateMaybe(dateStr); ok {
100+
available = ts.Before(now)
101+
if available {
102+
hasPast = true
103+
}
104+
if !strings.HasSuffix(dateStr, "Z") {
105+
s["date_utc"] = strings.ReplaceAll(dateStr, " ", "T") + "Z"
106+
}
107+
}
108+
s["available"] = available
109+
110+
sessionType := normalizeSessionType(asString(s["session_type"]))
111+
if sessionType == "" {
112+
sessionType = normalizeSessionType(sessionNameToType[asString(s["name"])])
113+
}
114+
if sessionType != "" {
115+
s["session_type"] = sessionType
116+
st := a.sessionDownloadStatus(year, asInt(evt["round_number"]), sessionType)
117+
s["download_state"] = st.DownloadState
118+
s["downloaded"] = st.Downloaded
119+
if strings.TrimSpace(st.LastError) != "" {
120+
s["last_error"] = st.LastError
121+
}
122+
if strings.TrimSpace(st.UpdatedAt) != "" {
123+
s["updated_at"] = st.UpdatedAt
124+
}
125+
}
126+
}
127+
if hasPast {
128+
evt["status"] = "available"
129+
lastPast = i
130+
} else {
131+
evt["status"] = "future"
132+
}
133+
}
134+
if lastPast >= 0 {
135+
if evt, ok := events[lastPast].(map[string]any); ok {
136+
evt["status"] = "latest"
137+
}
138+
}
139+
return root, nil
140+
}
141+
142+
func parseDateMaybe(s string) (time.Time, bool) {
143+
s = strings.TrimSpace(s)
144+
if s == "" {
145+
return time.Time{}, false
146+
}
147+
if t, err := time.Parse(time.RFC3339, strings.ReplaceAll(s, " ", "T")); err == nil {
148+
return t.UTC(), true
149+
}
150+
if strings.HasSuffix(s, "Z") {
151+
if t, err := time.Parse("2006-01-02T15:04:05Z", strings.ReplaceAll(s, " ", "T")); err == nil {
152+
return t.UTC(), true
153+
}
154+
}
155+
return time.Time{}, false
156+
}
157+
158+
func (a *app) readJSONAny(rel string) (any, error) {
159+
if a.store == nil {
160+
return nil, errors.New("sqlite store is not configured")
161+
}
162+
b, err := a.store.GetJSONArtifact(context.Background(), filepath.ToSlash(rel))
163+
if err != nil {
164+
return nil, err
165+
}
166+
var out any
167+
if err := json.Unmarshal(b, &out); err != nil {
168+
return nil, err
169+
}
170+
return out, nil
171+
}
172+
173+
func (a *app) fileExists(rel string) bool {
174+
if a.store == nil {
175+
return false
176+
}
177+
_, err := a.store.GetJSONArtifact(context.Background(), filepath.ToSlash(rel))
178+
return err == nil
179+
}

backend/app_types.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package main
2+
3+
import (
4+
"sync"
5+
"time"
6+
7+
"f1replaytiming/backend/storage"
8+
)
9+
10+
var (
11+
sessionTypePriority = []string{"R", "Q", "S", "SQ", "FP1", "FP2", "FP3"}
12+
sessionTypeLabels = map[string]string{
13+
"R": "Race",
14+
"Q": "Qualifying",
15+
"S": "Sprint",
16+
"SQ": "Sprint Qualifying",
17+
"FP1": "Practice 1",
18+
"FP2": "Practice 2",
19+
"FP3": "Practice 3",
20+
}
21+
availableSeasons = []int{2024, 2025, 2026, 2027, 2028}
22+
)
23+
24+
type app struct {
25+
dataDir string
26+
processor SessionProcessor
27+
store *storage.Store
28+
replayChunkSize int
29+
replayCache *replayCache
30+
downloads *downloadManager
31+
allowedOrigins map[string]struct{}
32+
authEnabled bool
33+
authPassphrase string
34+
sessionLockMu sync.Mutex
35+
sessionLocks map[string]*sync.Mutex
36+
scheduleLockMu sync.Mutex
37+
scheduleLocks map[int]*sync.Mutex
38+
pitLossMu sync.Mutex
39+
pitLossRaw map[string]any
40+
pitLossLoadedAt time.Time
41+
}
42+
43+
type replayCache struct {
44+
mu sync.Mutex
45+
entries map[string]*replayCacheEntry
46+
maxBytes int64
47+
ttl time.Duration
48+
current int64
49+
}
50+
51+
type replayCacheEntry struct {
52+
key string
53+
sizeBytes int64
54+
sessionID int64
55+
frames []replayFrameMeta
56+
totalLaps int
57+
totalTime float64
58+
qualiPhases []map[string]any
59+
clients int
60+
lastAccess time.Time
61+
evictTimer *time.Timer
62+
}
63+
64+
type replayFrameMeta struct {
65+
Start int64 `json:"start,omitempty"`
66+
End int64 `json:"end,omitempty"`
67+
FrameSeq int `json:"frame_seq"`
68+
TimestampMS int64 `json:"ts_ms"`
69+
Timestamp float64 `json:"timestamp"`
70+
Lap int `json:"lap"`
71+
ChunkSeq int `json:"chunk_seq"`
72+
FrameInChunk int `json:"frame_in_chunk"`
73+
}
74+
75+
type replayIndexFile struct {
76+
Version int `json:"version"`
77+
ReplaySize int64 `json:"replay_size"`
78+
ReplayModUnix int64 `json:"replay_mod_unix"`
79+
Frames []replayFrameMeta `json:"frames"`
80+
TotalLaps int `json:"total_laps"`
81+
TotalTime float64 `json:"total_time"`
82+
QualiPhases []map[string]any `json:"quali_phases"`
83+
}
84+
85+
type pitLossValues struct {
86+
Green float64
87+
SC float64
88+
VSC float64
89+
}

0 commit comments

Comments
 (0)