cricinfo-cli-go 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/internal/cli/matches.go +126 -2
- package/internal/cli/matches_test.go +82 -0
- package/internal/cli/search.go +156 -0
- package/internal/cricinfo/analysis.go +393 -93
- package/internal/cricinfo/analysis_phase15_test.go +38 -0
- package/internal/cricinfo/client.go +23 -2
- package/internal/cricinfo/coverage_ledger_test.go +2 -22
- package/internal/cricinfo/entity_index.go +27 -0
- package/internal/cricinfo/historical_hydration.go +82 -42
- package/internal/cricinfo/matches.go +1641 -88
- package/internal/cricinfo/matches_phase7_test.go +11 -4
- package/internal/cricinfo/normalize_entities.go +83 -35
- package/internal/cricinfo/players.go +236 -2
- package/internal/cricinfo/render_contract.go +191 -49
- package/internal/cricinfo/renderer.go +613 -19
- package/internal/cricinfo/resolver.go +134 -13
- package/internal/cricinfo/teams.go +109 -6
- package/internal/cricinfo/testdata/coverage/cricinfo-field-path-catalog.txt +2536 -0
- package/internal/cricinfo/testdata/coverage/cricinfo-working-templates.tsv +56 -0
- package/package.json +1 -1
|
@@ -5,13 +5,27 @@ import (
|
|
|
5
5
|
"errors"
|
|
6
6
|
"fmt"
|
|
7
7
|
"net/url"
|
|
8
|
+
"sort"
|
|
8
9
|
"strconv"
|
|
9
10
|
"strings"
|
|
10
11
|
"sync"
|
|
12
|
+
"time"
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
const defaultMatchListLimit = 20
|
|
14
|
-
const
|
|
16
|
+
const matchListEventFetchConcurrency = 3
|
|
17
|
+
const matchListStatusFetchConcurrency = 3
|
|
18
|
+
const matchListEventFetchTimeout = 4500 * time.Millisecond
|
|
19
|
+
const matchListStatusFetchTimeout = 4 * time.Second
|
|
20
|
+
const matchLineupRosterFetchConcurrency = 2
|
|
21
|
+
const matchLineupRosterFetchTimeout = 5 * time.Second
|
|
22
|
+
const deliveryFetchConcurrency = 96
|
|
23
|
+
const detailSubresourceFetchConcurrency = 24
|
|
24
|
+
const detailItemFetchTimeout = 3 * time.Second
|
|
25
|
+
const liveViewRecentDeliveryFetchCount = 60
|
|
26
|
+
const matchTeamQueryScanRange = 6
|
|
27
|
+
const maxTeamQueryEventCandidates = 36
|
|
28
|
+
const teamQueryEventFetchTimeout = 1500 * time.Millisecond
|
|
15
29
|
|
|
16
30
|
// MatchServiceConfig configures match discovery and lookup behavior.
|
|
17
31
|
type MatchServiceConfig struct {
|
|
@@ -21,7 +35,8 @@ type MatchServiceConfig struct {
|
|
|
21
35
|
|
|
22
36
|
// MatchListOptions controls list/live traversal behavior.
|
|
23
37
|
type MatchListOptions struct {
|
|
24
|
-
Limit
|
|
38
|
+
Limit int
|
|
39
|
+
LeagueID string
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
// MatchLookupOptions controls resolver-backed single match lookup.
|
|
@@ -37,6 +52,13 @@ type MatchInningsOptions struct {
|
|
|
37
52
|
Period int
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
// MatchDuelOptions controls batter-vs-bowler matchup lookup behavior.
|
|
56
|
+
type MatchDuelOptions struct {
|
|
57
|
+
LeagueID string
|
|
58
|
+
BatterQuery string
|
|
59
|
+
BowlerQuery string
|
|
60
|
+
}
|
|
61
|
+
|
|
40
62
|
// MatchService implements domain-level match discovery and lookup commands.
|
|
41
63
|
type MatchService struct {
|
|
42
64
|
client *Client
|
|
@@ -91,6 +113,124 @@ func (s *MatchService) Live(ctx context.Context, opts MatchListOptions) (Normali
|
|
|
91
113
|
return s.listFromEvents(ctx, opts, true)
|
|
92
114
|
}
|
|
93
115
|
|
|
116
|
+
// Lineups resolves one match and returns match-scoped roster entries for both teams.
|
|
117
|
+
func (s *MatchService) Lineups(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
118
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
119
|
+
if passthrough != nil {
|
|
120
|
+
passthrough.Kind = EntityTeamRoster
|
|
121
|
+
return *passthrough, nil
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if len(lookup.match.Teams) == 0 {
|
|
125
|
+
return NormalizedResult{
|
|
126
|
+
Kind: EntityTeamRoster,
|
|
127
|
+
Status: ResultStatusEmpty,
|
|
128
|
+
Message: fmt.Sprintf("no teams found for match %q", lookup.match.ID),
|
|
129
|
+
}, nil
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type lineupLoadResult struct {
|
|
133
|
+
entries []TeamRosterEntry
|
|
134
|
+
warns []string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
results := make([]lineupLoadResult, len(lookup.match.Teams))
|
|
138
|
+
sem := make(chan struct{}, matchLineupRosterFetchConcurrency)
|
|
139
|
+
var wg sync.WaitGroup
|
|
140
|
+
teamCache := map[string]teamIdentity{}
|
|
141
|
+
|
|
142
|
+
teamService := &TeamService{
|
|
143
|
+
client: s.client,
|
|
144
|
+
resolver: s.resolver,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for i := range lookup.match.Teams {
|
|
148
|
+
team := lookup.match.Teams[i]
|
|
149
|
+
teamID := strings.TrimSpace(team.ID)
|
|
150
|
+
if teamID == "" {
|
|
151
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
|
|
152
|
+
}
|
|
153
|
+
if teamID == "" {
|
|
154
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
|
|
155
|
+
}
|
|
156
|
+
if teamID == "" {
|
|
157
|
+
results[i].warns = []string{fmt.Sprintf("skip team with missing id/ref in match %q", lookup.match.ID)}
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
team.ID = teamID
|
|
161
|
+
if strings.TrimSpace(team.Name) == "" || strings.TrimSpace(team.ShortName) == "" {
|
|
162
|
+
identity, err := s.fetchTeamIdentity(ctx, &team, teamCache)
|
|
163
|
+
if err != nil {
|
|
164
|
+
results[i].warns = append(results[i].warns, fmt.Sprintf("team %s: %v", nonEmpty(team.Ref, team.ID), err))
|
|
165
|
+
} else {
|
|
166
|
+
team.Name = nonEmpty(team.Name, identity.name)
|
|
167
|
+
team.ShortName = nonEmpty(team.ShortName, identity.shortName)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
wg.Add(1)
|
|
172
|
+
go func(index int, team Team) {
|
|
173
|
+
defer wg.Done()
|
|
174
|
+
sem <- struct{}{}
|
|
175
|
+
defer func() { <-sem }()
|
|
176
|
+
|
|
177
|
+
rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(*lookup.match, team.ID, "roster"))
|
|
178
|
+
if rosterRef == "" {
|
|
179
|
+
results[index].warns = []string{fmt.Sprintf("roster route unavailable for team %q", team.ID)}
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
reqCtx, cancel := context.WithTimeout(ctx, matchLineupRosterFetchTimeout)
|
|
184
|
+
resolved, err := s.client.ResolveRefChain(reqCtx, rosterRef)
|
|
185
|
+
cancel()
|
|
186
|
+
if err != nil {
|
|
187
|
+
results[index].warns = []string{fmt.Sprintf("roster %s: %v", rosterRef, err)}
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
entries, err := NormalizeTeamRosterEntries(resolved.Body, team, TeamScopeMatch, lookup.match.ID)
|
|
192
|
+
if err != nil {
|
|
193
|
+
results[index].warns = []string{fmt.Sprintf("roster %s: %v", resolved.CanonicalRef, err)}
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for i := range entries {
|
|
198
|
+
entries[i].TeamName = nonEmpty(entries[i].TeamName, team.ShortName, team.Name, team.ID)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
reqCtx, cancel = context.WithTimeout(ctx, matchLineupRosterFetchTimeout)
|
|
202
|
+
hydrateWarnings := teamService.enrichRosterEntries(reqCtx, entries)
|
|
203
|
+
cancel()
|
|
204
|
+
results[index] = lineupLoadResult{
|
|
205
|
+
entries: entries,
|
|
206
|
+
warns: hydrateWarnings,
|
|
207
|
+
}
|
|
208
|
+
}(i, team)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
wg.Wait()
|
|
212
|
+
|
|
213
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
214
|
+
items := make([]any, 0)
|
|
215
|
+
for i := range results {
|
|
216
|
+
warnings = append(warnings, results[i].warns...)
|
|
217
|
+
for _, entry := range results[i].entries {
|
|
218
|
+
items = append(items, entry)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result := NewListResult(EntityTeamRoster, items)
|
|
223
|
+
if len(warnings) > 0 {
|
|
224
|
+
result = NewPartialListResult(EntityTeamRoster, items, compactWarnings(warnings)...)
|
|
225
|
+
}
|
|
226
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
227
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
228
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
229
|
+
result.Message = "no lineup entries found for this match"
|
|
230
|
+
}
|
|
231
|
+
return result, nil
|
|
232
|
+
}
|
|
233
|
+
|
|
94
234
|
// Show resolves and returns one match with normalized summary fields.
|
|
95
235
|
func (s *MatchService) Show(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
96
236
|
return s.lookupMatch(ctx, query, opts, false)
|
|
@@ -108,6 +248,10 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
|
|
|
108
248
|
passthrough.Kind = EntityMatchScorecard
|
|
109
249
|
return *passthrough, nil
|
|
110
250
|
}
|
|
251
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
252
|
+
teamCache := map[string]teamIdentity{}
|
|
253
|
+
scoreCache := map[string]string{}
|
|
254
|
+
hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
|
|
111
255
|
|
|
112
256
|
scorecardRef := matchSubresourceRef(*lookup.match, "matchcards", "matchcards")
|
|
113
257
|
if scorecardRef == "" {
|
|
@@ -118,8 +262,26 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
|
|
|
118
262
|
}, nil
|
|
119
263
|
}
|
|
120
264
|
|
|
121
|
-
resolved, err := s.
|
|
265
|
+
resolved, err := s.resolveRefChainResilient(ctx, scorecardRef)
|
|
122
266
|
if err != nil {
|
|
267
|
+
if live, liveWarnings := s.buildLiveView(ctx, *lookup.match); live != nil {
|
|
268
|
+
scorecard := &MatchScorecard{
|
|
269
|
+
Ref: scorecardRef,
|
|
270
|
+
LeagueID: lookup.match.LeagueID,
|
|
271
|
+
EventID: lookup.match.EventID,
|
|
272
|
+
CompetitionID: lookup.match.CompetitionID,
|
|
273
|
+
MatchID: lookup.match.ID,
|
|
274
|
+
}
|
|
275
|
+
augmentScorecardFromLive(scorecard, live)
|
|
276
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
277
|
+
warnings = append(warnings, hydrationWarnings...)
|
|
278
|
+
warnings = append(warnings, liveWarnings...)
|
|
279
|
+
warnings = append(warnings, fmt.Sprintf("scorecard fallback used after %v", err))
|
|
280
|
+
result := NewPartialResult(EntityMatchScorecard, scorecard, warnings...)
|
|
281
|
+
result.RequestedRef = scorecardRef
|
|
282
|
+
result.CanonicalRef = scorecardRef
|
|
283
|
+
return result, nil
|
|
284
|
+
}
|
|
123
285
|
return NewTransportErrorResult(EntityMatchScorecard, scorecardRef, err), nil
|
|
124
286
|
}
|
|
125
287
|
|
|
@@ -127,8 +289,17 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
|
|
|
127
289
|
if err != nil {
|
|
128
290
|
return NormalizedResult{}, fmt.Errorf("normalize matchcards %q: %w", resolved.CanonicalRef, err)
|
|
129
291
|
}
|
|
292
|
+
enrichmentWarnings := []string{}
|
|
293
|
+
if len(scorecard.BattingCards) == 0 || len(scorecard.BowlingCards) == 0 {
|
|
294
|
+
if live, warns := s.buildLiveView(ctx, *lookup.match); live != nil {
|
|
295
|
+
enrichmentWarnings = append(enrichmentWarnings, warns...)
|
|
296
|
+
augmentScorecardFromLive(scorecard, live)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
130
299
|
|
|
131
300
|
warnings := append([]string{}, lookup.warnings...)
|
|
301
|
+
warnings = append(warnings, hydrationWarnings...)
|
|
302
|
+
warnings = append(warnings, enrichmentWarnings...)
|
|
132
303
|
result := NewDataResult(EntityMatchScorecard, scorecard)
|
|
133
304
|
if len(warnings) > 0 {
|
|
134
305
|
result = NewPartialResult(EntityMatchScorecard, scorecard, warnings...)
|
|
@@ -155,7 +326,23 @@ func (s *MatchService) Details(ctx context.Context, query string, opts MatchLook
|
|
|
155
326
|
}, nil
|
|
156
327
|
}
|
|
157
328
|
|
|
158
|
-
|
|
329
|
+
events, warnings, err := s.deliveryEventsForMatchRefs(ctx, detailsRef, matchSubresourceRef(*lookup.match, "plays", "plays"))
|
|
330
|
+
if err != nil {
|
|
331
|
+
return NewTransportErrorResult(EntityDeliveryEvent, detailsRef, err), nil
|
|
332
|
+
}
|
|
333
|
+
warnings = append(lookup.warnings, warnings...)
|
|
334
|
+
|
|
335
|
+
items := make([]any, 0, len(events))
|
|
336
|
+
for _, delivery := range events {
|
|
337
|
+
items = append(items, delivery)
|
|
338
|
+
}
|
|
339
|
+
result := NewListResult(EntityDeliveryEvent, items)
|
|
340
|
+
if len(warnings) > 0 {
|
|
341
|
+
result = NewPartialListResult(EntityDeliveryEvent, items, warnings...)
|
|
342
|
+
}
|
|
343
|
+
result.RequestedRef = detailsRef
|
|
344
|
+
result.CanonicalRef = detailsRef
|
|
345
|
+
return result, nil
|
|
159
346
|
}
|
|
160
347
|
|
|
161
348
|
// Plays resolves and returns normalized delivery events from the plays route.
|
|
@@ -175,7 +362,23 @@ func (s *MatchService) Plays(ctx context.Context, query string, opts MatchLookup
|
|
|
175
362
|
}, nil
|
|
176
363
|
}
|
|
177
364
|
|
|
178
|
-
|
|
365
|
+
events, warnings, err := s.deliveryEventsForMatchRefs(ctx, playsRef, nonEmpty(strings.TrimSpace(lookup.match.DetailsRef), matchSubresourceRef(*lookup.match, "details", "details")))
|
|
366
|
+
if err != nil {
|
|
367
|
+
return NewTransportErrorResult(EntityDeliveryEvent, playsRef, err), nil
|
|
368
|
+
}
|
|
369
|
+
warnings = append(lookup.warnings, warnings...)
|
|
370
|
+
|
|
371
|
+
items := make([]any, 0, len(events))
|
|
372
|
+
for _, delivery := range events {
|
|
373
|
+
items = append(items, delivery)
|
|
374
|
+
}
|
|
375
|
+
result := NewListResult(EntityDeliveryEvent, items)
|
|
376
|
+
if len(warnings) > 0 {
|
|
377
|
+
result = NewPartialListResult(EntityDeliveryEvent, items, warnings...)
|
|
378
|
+
}
|
|
379
|
+
result.RequestedRef = playsRef
|
|
380
|
+
result.CanonicalRef = playsRef
|
|
381
|
+
return result, nil
|
|
179
382
|
}
|
|
180
383
|
|
|
181
384
|
// Situation resolves and returns normalized match situation data.
|
|
@@ -186,6 +389,11 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
|
|
|
186
389
|
return *passthrough, nil
|
|
187
390
|
}
|
|
188
391
|
|
|
392
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
393
|
+
teamCache := map[string]teamIdentity{}
|
|
394
|
+
scoreCache := map[string]string{}
|
|
395
|
+
hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
|
|
396
|
+
|
|
189
397
|
situationRef := matchSubresourceRef(*lookup.match, "situation", "situation")
|
|
190
398
|
if situationRef == "" {
|
|
191
399
|
return NormalizedResult{
|
|
@@ -206,6 +414,19 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
|
|
|
206
414
|
}
|
|
207
415
|
|
|
208
416
|
if isSparseSituation(situation) {
|
|
417
|
+
if live, warnings := s.buildLiveView(ctx, *lookup.match); live != nil {
|
|
418
|
+
situation.Live = live
|
|
419
|
+
result := NewDataResult(EntityMatchSituation, situation)
|
|
420
|
+
combinedWarnings := append([]string{}, lookup.warnings...)
|
|
421
|
+
combinedWarnings = append(combinedWarnings, hydrationWarnings...)
|
|
422
|
+
combinedWarnings = append(combinedWarnings, warnings...)
|
|
423
|
+
if len(combinedWarnings) > 0 {
|
|
424
|
+
result = NewPartialResult(EntityMatchSituation, situation, combinedWarnings...)
|
|
425
|
+
}
|
|
426
|
+
result.RequestedRef = resolved.RequestedRef
|
|
427
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
428
|
+
return result, nil
|
|
429
|
+
}
|
|
209
430
|
result := NormalizedResult{
|
|
210
431
|
Kind: EntityMatchSituation,
|
|
211
432
|
Status: ResultStatusEmpty,
|
|
@@ -217,14 +438,356 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
|
|
|
217
438
|
}
|
|
218
439
|
|
|
219
440
|
result := NewDataResult(EntityMatchSituation, situation)
|
|
220
|
-
|
|
221
|
-
|
|
441
|
+
combinedWarnings := append([]string{}, lookup.warnings...)
|
|
442
|
+
combinedWarnings = append(combinedWarnings, hydrationWarnings...)
|
|
443
|
+
if len(combinedWarnings) > 0 {
|
|
444
|
+
result = NewPartialResult(EntityMatchSituation, situation, combinedWarnings...)
|
|
222
445
|
}
|
|
223
446
|
result.RequestedRef = resolved.RequestedRef
|
|
224
447
|
result.CanonicalRef = resolved.CanonicalRef
|
|
225
448
|
return result, nil
|
|
226
449
|
}
|
|
227
450
|
|
|
451
|
+
// LiveView resolves and returns a fan-first live view synthesized from delivery details.
|
|
452
|
+
func (s *MatchService) LiveView(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
453
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
454
|
+
if passthrough != nil {
|
|
455
|
+
passthrough.Kind = EntityMatchSituation
|
|
456
|
+
return *passthrough, nil
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
460
|
+
teamCache := map[string]teamIdentity{}
|
|
461
|
+
scoreCache := map[string]string{}
|
|
462
|
+
hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
|
|
463
|
+
|
|
464
|
+
live, liveWarnings := s.buildLiveView(ctx, *lookup.match)
|
|
465
|
+
if live == nil {
|
|
466
|
+
fallback, fallbackErr := s.Situation(ctx, query, opts)
|
|
467
|
+
if fallbackErr == nil && fallback.Status != ResultStatusError {
|
|
468
|
+
combinedWarnings := append([]string{}, fallback.Warnings...)
|
|
469
|
+
combinedWarnings = append(combinedWarnings, lookup.warnings...)
|
|
470
|
+
combinedWarnings = append(combinedWarnings, hydrationWarnings...)
|
|
471
|
+
combinedWarnings = append(combinedWarnings, liveWarnings...)
|
|
472
|
+
combinedWarnings = append(combinedWarnings, "live-view fallback: showing situation data")
|
|
473
|
+
combinedWarnings = compactWarnings(combinedWarnings)
|
|
474
|
+
|
|
475
|
+
if fallback.Data != nil && len(combinedWarnings) > 0 {
|
|
476
|
+
partial := NewPartialResult(EntityMatchSituation, fallback.Data, combinedWarnings...)
|
|
477
|
+
partial.RequestedRef = nonEmpty(fallback.RequestedRef, lookup.resolved.RequestedRef)
|
|
478
|
+
partial.CanonicalRef = nonEmpty(fallback.CanonicalRef, lookup.resolved.CanonicalRef)
|
|
479
|
+
return partial, nil
|
|
480
|
+
}
|
|
481
|
+
return fallback, nil
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
result := NormalizedResult{
|
|
485
|
+
Kind: EntityMatchSituation,
|
|
486
|
+
Status: ResultStatusEmpty,
|
|
487
|
+
Message: fmt.Sprintf("no live view data available for match %q", lookup.match.ID),
|
|
488
|
+
}
|
|
489
|
+
warnings := compactWarnings(append(append(append([]string{}, lookup.warnings...), hydrationWarnings...), liveWarnings...))
|
|
490
|
+
if len(warnings) > 0 {
|
|
491
|
+
result.Status = ResultStatusPartial
|
|
492
|
+
result.Message = "live view unavailable"
|
|
493
|
+
result.Warnings = warnings
|
|
494
|
+
}
|
|
495
|
+
return result, nil
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
situation := &MatchSituation{
|
|
499
|
+
Ref: matchSubresourceRef(*lookup.match, "situation", "situation"),
|
|
500
|
+
LeagueID: lookup.match.LeagueID,
|
|
501
|
+
EventID: lookup.match.EventID,
|
|
502
|
+
CompetitionID: lookup.match.CompetitionID,
|
|
503
|
+
MatchID: lookup.match.ID,
|
|
504
|
+
Live: live,
|
|
505
|
+
}
|
|
506
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
507
|
+
warnings = append(warnings, hydrationWarnings...)
|
|
508
|
+
warnings = append(warnings, liveWarnings...)
|
|
509
|
+
|
|
510
|
+
result := NewDataResult(EntityMatchSituation, situation)
|
|
511
|
+
if len(warnings) > 0 {
|
|
512
|
+
result = NewPartialResult(EntityMatchSituation, situation, warnings...)
|
|
513
|
+
}
|
|
514
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
515
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
516
|
+
return result, nil
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Duel resolves and returns a batter-vs-bowler matchup summary for one match.
|
|
520
|
+
func (s *MatchService) Duel(ctx context.Context, query string, opts MatchDuelOptions) (NormalizedResult, error) {
|
|
521
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
|
|
522
|
+
if passthrough != nil {
|
|
523
|
+
passthrough.Kind = EntityMatchDuel
|
|
524
|
+
return *passthrough, nil
|
|
525
|
+
}
|
|
526
|
+
if strings.TrimSpace(opts.BatterQuery) == "" || strings.TrimSpace(opts.BowlerQuery) == "" {
|
|
527
|
+
return NormalizedResult{
|
|
528
|
+
Kind: EntityMatchDuel,
|
|
529
|
+
Status: ResultStatusEmpty,
|
|
530
|
+
Message: "--batter and --bowler are required",
|
|
531
|
+
}, nil
|
|
532
|
+
}
|
|
533
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
534
|
+
teamCache := map[string]teamIdentity{}
|
|
535
|
+
scoreCache := map[string]string{}
|
|
536
|
+
hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
|
|
537
|
+
|
|
538
|
+
detailsRef := nonEmpty(strings.TrimSpace(lookup.match.DetailsRef), matchSubresourceRef(*lookup.match, "details", "details"))
|
|
539
|
+
playsRef := matchSubresourceRef(*lookup.match, "plays", "plays")
|
|
540
|
+
primaryRef := nonEmpty(detailsRef, playsRef)
|
|
541
|
+
deliveries, warnings, err := s.deliveryEventsForMatchRefs(ctx, primaryRef, playsRef)
|
|
542
|
+
if err != nil {
|
|
543
|
+
return NewTransportErrorResult(EntityMatchDuel, primaryRef, err), nil
|
|
544
|
+
}
|
|
545
|
+
if len(deliveries) == 0 {
|
|
546
|
+
return NormalizedResult{
|
|
547
|
+
Kind: EntityMatchDuel,
|
|
548
|
+
Status: ResultStatusEmpty,
|
|
549
|
+
Message: "no delivery data available for duel analysis",
|
|
550
|
+
}, nil
|
|
551
|
+
}
|
|
552
|
+
matchLatest := deliveries[len(deliveries)-1]
|
|
553
|
+
|
|
554
|
+
batterID, batterName := resolveDuelIdentity(deliveries, strings.TrimSpace(opts.BatterQuery), true)
|
|
555
|
+
bowlerID, bowlerName := resolveDuelIdentity(deliveries, strings.TrimSpace(opts.BowlerQuery), false)
|
|
556
|
+
if batterID == "" && normalizeAlias(batterName) == "" {
|
|
557
|
+
return NormalizedResult{Kind: EntityMatchDuel, Status: ResultStatusEmpty, Message: fmt.Sprintf("batter %q not found in this match stream", opts.BatterQuery)}, nil
|
|
558
|
+
}
|
|
559
|
+
if bowlerID == "" && normalizeAlias(bowlerName) == "" {
|
|
560
|
+
return NormalizedResult{Kind: EntityMatchDuel, Status: ResultStatusEmpty, Message: fmt.Sprintf("bowler %q not found in this match stream", opts.BowlerQuery)}, nil
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
runs := 0
|
|
564
|
+
dots := 0
|
|
565
|
+
fours := 0
|
|
566
|
+
sixes := 0
|
|
567
|
+
wickets := 0
|
|
568
|
+
balls := 0
|
|
569
|
+
recent := make([]DeliveryEvent, 0, 8)
|
|
570
|
+
lastUpdate := int64(0)
|
|
571
|
+
for _, delivery := range deliveries {
|
|
572
|
+
if !deliveryMatchesDuel(delivery, batterID, batterName, bowlerID, bowlerName) {
|
|
573
|
+
continue
|
|
574
|
+
}
|
|
575
|
+
balls++
|
|
576
|
+
if delivery.ScoreValue > 0 {
|
|
577
|
+
runs += delivery.ScoreValue
|
|
578
|
+
} else {
|
|
579
|
+
dots++
|
|
580
|
+
}
|
|
581
|
+
short := strings.ToUpper(strings.TrimSpace(delivery.ShortText))
|
|
582
|
+
if strings.Contains(short, "FOUR") {
|
|
583
|
+
fours++
|
|
584
|
+
}
|
|
585
|
+
if strings.Contains(short, "SIX") {
|
|
586
|
+
sixes++
|
|
587
|
+
}
|
|
588
|
+
if truthyField(delivery.Dismissal, "dismissal") {
|
|
589
|
+
wickets++
|
|
590
|
+
}
|
|
591
|
+
recent = append(recent, delivery)
|
|
592
|
+
if delivery.BBBTimestamp > lastUpdate {
|
|
593
|
+
lastUpdate = delivery.BBBTimestamp
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if balls == 0 {
|
|
597
|
+
return NormalizedResult{
|
|
598
|
+
Kind: EntityMatchDuel,
|
|
599
|
+
Status: ResultStatusEmpty,
|
|
600
|
+
Message: fmt.Sprintf("no deliveries found for %s vs %s", nonEmpty(batterName, opts.BatterQuery), nonEmpty(bowlerName, opts.BowlerQuery)),
|
|
601
|
+
}, nil
|
|
602
|
+
}
|
|
603
|
+
if len(recent) > 8 {
|
|
604
|
+
recent = recent[len(recent)-8:]
|
|
605
|
+
}
|
|
606
|
+
liveScore := firstNonEmpty(matchScoreLabel(matchLatest.HomeScore), matchScoreLabel(matchLatest.AwayScore), lookup.match.ScoreSummary)
|
|
607
|
+
liveOver := overBallString(matchLatest.OverNumber, matchLatest.BallNumber)
|
|
608
|
+
duelScore := liveScore
|
|
609
|
+
if liveScore != "" && liveOver != "" {
|
|
610
|
+
duelScore = fmt.Sprintf("%s (%s ov)", liveScore, liveOver)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
duel := MatchDuel{
|
|
614
|
+
MatchID: lookup.match.ID,
|
|
615
|
+
Fixture: nonEmpty(lookup.match.ShortDescription, lookup.match.Description),
|
|
616
|
+
Score: duelScore,
|
|
617
|
+
BatterID: batterID,
|
|
618
|
+
BatterName: nonEmpty(batterName, opts.BatterQuery),
|
|
619
|
+
BowlerID: bowlerID,
|
|
620
|
+
BowlerName: nonEmpty(bowlerName, opts.BowlerQuery),
|
|
621
|
+
Balls: balls,
|
|
622
|
+
Runs: runs,
|
|
623
|
+
Dots: dots,
|
|
624
|
+
Fours: fours,
|
|
625
|
+
Sixes: sixes,
|
|
626
|
+
Wickets: wickets,
|
|
627
|
+
StrikeRate: strikeRate(runs, balls),
|
|
628
|
+
RecentBalls: recent,
|
|
629
|
+
LastUpdateMS: lastUpdate,
|
|
630
|
+
SnapshotAt: time.Now().UTC().Format(time.RFC3339),
|
|
631
|
+
SourceRoute: primaryRef,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
allWarnings := append([]string{}, lookup.warnings...)
|
|
635
|
+
allWarnings = append(allWarnings, hydrationWarnings...)
|
|
636
|
+
allWarnings = append(allWarnings, warnings...)
|
|
637
|
+
result := NewDataResult(EntityMatchDuel, duel)
|
|
638
|
+
if len(allWarnings) > 0 {
|
|
639
|
+
result = NewPartialResult(EntityMatchDuel, duel, allWarnings...)
|
|
640
|
+
}
|
|
641
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
642
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
643
|
+
return result, nil
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Phases resolves and returns fan-oriented innings phase splits (powerplay/middle/death).
|
|
647
|
+
func (s *MatchService) Phases(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
648
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
649
|
+
if passthrough != nil {
|
|
650
|
+
passthrough.Kind = EntityMatchPhases
|
|
651
|
+
return *passthrough, nil
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
655
|
+
teamCache := map[string]teamIdentity{}
|
|
656
|
+
scoreCache := map[string]string{}
|
|
657
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
658
|
+
warnings = append(warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
|
|
659
|
+
|
|
660
|
+
teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, "", opts.LeagueID)
|
|
661
|
+
if teamResult != nil {
|
|
662
|
+
teamResult.Kind = EntityMatchPhases
|
|
663
|
+
return *teamResult, nil
|
|
664
|
+
}
|
|
665
|
+
warnings = append(warnings, teamWarnings...)
|
|
666
|
+
|
|
667
|
+
report := MatchPhases{
|
|
668
|
+
MatchID: lookup.match.ID,
|
|
669
|
+
LeagueID: lookup.match.LeagueID,
|
|
670
|
+
EventID: lookup.match.EventID,
|
|
671
|
+
CompetitionID: nonEmpty(lookup.match.CompetitionID, lookup.match.ID),
|
|
672
|
+
Fixture: nonEmpty(lookup.match.ShortDescription, lookup.match.Description),
|
|
673
|
+
Result: nonEmpty(lookup.match.MatchState, lookup.match.Note),
|
|
674
|
+
Innings: make([]MatchPhaseInning, 0),
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
for _, team := range teams {
|
|
678
|
+
inningsList, _, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
|
|
679
|
+
warnings = append(warnings, inningsWarnings...)
|
|
680
|
+
for i := range inningsList {
|
|
681
|
+
innings := inningsList[i]
|
|
682
|
+
statsWarnings := s.hydrateInningsTimelines(ctx, &innings)
|
|
683
|
+
warnings = append(warnings, statsWarnings...)
|
|
684
|
+
if !isMeaningfulPhaseInnings(innings) {
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
phaseInnings := buildPhaseInnings(team, innings)
|
|
689
|
+
if !phaseInningsHasData(phaseInnings) {
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
report.Innings = append(report.Innings, phaseInnings)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
result := NewDataResult(EntityMatchPhases, report)
|
|
697
|
+
if len(warnings) > 0 {
|
|
698
|
+
result = NewPartialResult(EntityMatchPhases, report, compactWarnings(warnings)...)
|
|
699
|
+
}
|
|
700
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
701
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
702
|
+
return result, nil
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
func isMeaningfulPhaseInnings(innings Innings) bool {
|
|
706
|
+
if strings.TrimSpace(innings.Score) != "" {
|
|
707
|
+
return true
|
|
708
|
+
}
|
|
709
|
+
if innings.Runs > 0 || innings.Wickets > 0 || innings.Target > 0 {
|
|
710
|
+
return true
|
|
711
|
+
}
|
|
712
|
+
return len(innings.OverTimeline) > 0 || len(innings.WicketTimeline) > 0
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
func buildPhaseInnings(team Team, innings Innings) MatchPhaseInning {
|
|
716
|
+
out := MatchPhaseInning{
|
|
717
|
+
TeamID: nonEmpty(strings.TrimSpace(team.ID), strings.TrimSpace(innings.TeamID)),
|
|
718
|
+
TeamName: nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), strings.TrimSpace(innings.TeamName), strings.TrimSpace(innings.TeamID)),
|
|
719
|
+
InningsNumber: innings.InningsNumber,
|
|
720
|
+
Period: innings.Period,
|
|
721
|
+
Score: innings.Score,
|
|
722
|
+
Target: innings.Target,
|
|
723
|
+
Powerplay: PhaseSummary{Name: "Powerplay"},
|
|
724
|
+
Middle: PhaseSummary{Name: "Middle"},
|
|
725
|
+
Death: PhaseSummary{Name: "Death"},
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
bestRuns := -1
|
|
729
|
+
for _, over := range innings.OverTimeline {
|
|
730
|
+
phase := phaseBucket(over.Number)
|
|
731
|
+
switch phase {
|
|
732
|
+
case "Powerplay":
|
|
733
|
+
accumulatePhase(&out.Powerplay, over)
|
|
734
|
+
case "Middle":
|
|
735
|
+
accumulatePhase(&out.Middle, over)
|
|
736
|
+
case "Death":
|
|
737
|
+
accumulatePhase(&out.Death, over)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if over.Runs > bestRuns {
|
|
741
|
+
bestRuns = over.Runs
|
|
742
|
+
out.BestScoringOver = over.Number
|
|
743
|
+
out.BestScoringOverRuns = over.Runs
|
|
744
|
+
}
|
|
745
|
+
if over.WicketCount > out.CollapseWickets {
|
|
746
|
+
out.CollapseWickets = over.WicketCount
|
|
747
|
+
out.CollapseOver = over.Number
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
finalizePhase(&out.Powerplay)
|
|
752
|
+
finalizePhase(&out.Middle)
|
|
753
|
+
finalizePhase(&out.Death)
|
|
754
|
+
|
|
755
|
+
return out
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
func phaseBucket(overNumber int) string {
|
|
759
|
+
switch {
|
|
760
|
+
case overNumber >= 1 && overNumber <= 6:
|
|
761
|
+
return "Powerplay"
|
|
762
|
+
case overNumber >= 7 && overNumber <= 15:
|
|
763
|
+
return "Middle"
|
|
764
|
+
case overNumber >= 16:
|
|
765
|
+
return "Death"
|
|
766
|
+
default:
|
|
767
|
+
return "Middle"
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
func accumulatePhase(phase *PhaseSummary, over InningsOver) {
|
|
772
|
+
if phase == nil {
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
phase.Runs += over.Runs
|
|
776
|
+
phase.Wickets += over.WicketCount
|
|
777
|
+
phase.Overs += 1
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
func finalizePhase(phase *PhaseSummary) {
|
|
781
|
+
if phase == nil || phase.Overs <= 0 {
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
phase.RunRate = float64(phase.Runs) / phase.Overs
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
func phaseInningsHasData(innings MatchPhaseInning) bool {
|
|
788
|
+
return innings.Powerplay.Overs > 0 || innings.Middle.Overs > 0 || innings.Death.Overs > 0
|
|
789
|
+
}
|
|
790
|
+
|
|
228
791
|
// Innings resolves and returns innings summaries with over and wicket timelines when period statistics are available.
|
|
229
792
|
func (s *MatchService) Innings(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
230
793
|
lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
|
|
@@ -417,9 +980,14 @@ func (s *MatchService) Deliveries(ctx context.Context, query string, opts MatchI
|
|
|
417
980
|
}
|
|
418
981
|
|
|
419
982
|
func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions, liveOnly bool) (NormalizedResult, error) {
|
|
420
|
-
|
|
983
|
+
rootRef := "/events"
|
|
984
|
+
if leagueID := strings.TrimSpace(opts.LeagueID); leagueID != "" {
|
|
985
|
+
rootRef = "/leagues/" + leagueID + "/events"
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
resolved, err := s.client.ResolveRefChain(ctx, rootRef)
|
|
421
989
|
if err != nil {
|
|
422
|
-
return NewTransportErrorResult(EntityMatch,
|
|
990
|
+
return NewTransportErrorResult(EntityMatch, rootRef, err), nil
|
|
423
991
|
}
|
|
424
992
|
|
|
425
993
|
page, err := DecodePage[Ref](resolved.Body)
|
|
@@ -432,31 +1000,30 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
|
|
|
432
1000
|
limit = defaultMatchListLimit
|
|
433
1001
|
}
|
|
434
1002
|
|
|
435
|
-
statusCache := map[string]matchStatusSnapshot{}
|
|
436
|
-
|
|
437
1003
|
matches := make([]Match, 0, limit)
|
|
438
1004
|
warnings := make([]string, 0)
|
|
439
|
-
|
|
440
|
-
|
|
1005
|
+
eventResults := s.fetchEventMatchesConcurrent(ctx, page.Items)
|
|
1006
|
+
candidates := make([]*Match, 0, len(page.Items))
|
|
1007
|
+
|
|
1008
|
+
for _, eventResult := range eventResults {
|
|
1009
|
+
if len(matches) >= limit && !liveOnly {
|
|
441
1010
|
break
|
|
442
1011
|
}
|
|
443
1012
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
warnings = append(warnings, fmt.Sprintf("event %s: %v", strings.TrimSpace(eventRef.URL), eventErr))
|
|
1013
|
+
if eventResult.err != nil {
|
|
1014
|
+
warnings = append(warnings, fmt.Sprintf("event %s: %v", strings.TrimSpace(eventResult.ref), eventResult.err))
|
|
447
1015
|
continue
|
|
448
1016
|
}
|
|
449
|
-
warnings = append(warnings,
|
|
1017
|
+
warnings = append(warnings, eventResult.warnings...)
|
|
450
1018
|
|
|
451
|
-
for
|
|
452
|
-
match :=
|
|
1019
|
+
for i := range eventResult.matches {
|
|
1020
|
+
match := eventResult.matches[i]
|
|
453
1021
|
s.enrichMatchTeamsFromIndex(&match)
|
|
454
|
-
if liveOnly
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
if liveOnly && !isLiveMatch(match) {
|
|
1022
|
+
if liveOnly {
|
|
1023
|
+
candidates = append(candidates, &match)
|
|
458
1024
|
continue
|
|
459
1025
|
}
|
|
1026
|
+
|
|
460
1027
|
match.ScoreSummary = matchScoreSummary(match.Teams)
|
|
461
1028
|
matches = append(matches, match)
|
|
462
1029
|
if len(matches) >= limit {
|
|
@@ -465,6 +1032,20 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
|
|
|
465
1032
|
}
|
|
466
1033
|
}
|
|
467
1034
|
|
|
1035
|
+
if liveOnly {
|
|
1036
|
+
warnings = append(warnings, s.hydrateMatchStatusesConcurrent(ctx, candidates)...)
|
|
1037
|
+
for _, candidate := range candidates {
|
|
1038
|
+
if candidate == nil || !isLiveMatch(*candidate) {
|
|
1039
|
+
continue
|
|
1040
|
+
}
|
|
1041
|
+
candidate.ScoreSummary = matchScoreSummary(candidate.Teams)
|
|
1042
|
+
matches = append(matches, *candidate)
|
|
1043
|
+
if len(matches) >= limit {
|
|
1044
|
+
break
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
468
1049
|
items := make([]any, 0, len(matches))
|
|
469
1050
|
for i := range matches {
|
|
470
1051
|
items = append(items, matches[i])
|
|
@@ -479,6 +1060,86 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
|
|
|
479
1060
|
return result, nil
|
|
480
1061
|
}
|
|
481
1062
|
|
|
1063
|
+
type eventMatchesResult struct {
|
|
1064
|
+
ref string
|
|
1065
|
+
matches []Match
|
|
1066
|
+
warnings []string
|
|
1067
|
+
err error
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
func (s *MatchService) fetchEventMatchesConcurrent(ctx context.Context, refs []Ref) []eventMatchesResult {
|
|
1071
|
+
results := make([]eventMatchesResult, len(refs))
|
|
1072
|
+
sem := make(chan struct{}, matchListEventFetchConcurrency)
|
|
1073
|
+
var wg sync.WaitGroup
|
|
1074
|
+
|
|
1075
|
+
for i, item := range refs {
|
|
1076
|
+
wg.Add(1)
|
|
1077
|
+
go func(index int, item Ref) {
|
|
1078
|
+
defer wg.Done()
|
|
1079
|
+
sem <- struct{}{}
|
|
1080
|
+
defer func() { <-sem }()
|
|
1081
|
+
|
|
1082
|
+
ref := strings.TrimSpace(item.URL)
|
|
1083
|
+
if ref == "" {
|
|
1084
|
+
results[index] = eventMatchesResult{
|
|
1085
|
+
ref: ref,
|
|
1086
|
+
err: fmt.Errorf("empty event ref"),
|
|
1087
|
+
}
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
reqCtx, cancel := context.WithTimeout(ctx, matchListEventFetchTimeout)
|
|
1092
|
+
matches, warnings, err := s.matchesFromEventRef(reqCtx, ref)
|
|
1093
|
+
cancel()
|
|
1094
|
+
results[index] = eventMatchesResult{
|
|
1095
|
+
ref: ref,
|
|
1096
|
+
matches: matches,
|
|
1097
|
+
warnings: warnings,
|
|
1098
|
+
err: err,
|
|
1099
|
+
}
|
|
1100
|
+
}(i, item)
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
wg.Wait()
|
|
1104
|
+
return results
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
func (s *MatchService) hydrateMatchStatusesConcurrent(ctx context.Context, matches []*Match) []string {
|
|
1108
|
+
type statusHydrationResult struct {
|
|
1109
|
+
warnings []string
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
results := make([]statusHydrationResult, len(matches))
|
|
1113
|
+
sem := make(chan struct{}, matchListStatusFetchConcurrency)
|
|
1114
|
+
var wg sync.WaitGroup
|
|
1115
|
+
|
|
1116
|
+
for i, match := range matches {
|
|
1117
|
+
if match == nil || isLiveMatch(*match) || strings.TrimSpace(match.StatusRef) == "" {
|
|
1118
|
+
continue
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
wg.Add(1)
|
|
1122
|
+
go func(index int, match *Match) {
|
|
1123
|
+
defer wg.Done()
|
|
1124
|
+
sem <- struct{}{}
|
|
1125
|
+
defer func() { <-sem }()
|
|
1126
|
+
|
|
1127
|
+
reqCtx, cancel := context.WithTimeout(ctx, matchListStatusFetchTimeout)
|
|
1128
|
+
warnings := s.hydrateMatchStatusOnly(reqCtx, match, map[string]matchStatusSnapshot{})
|
|
1129
|
+
cancel()
|
|
1130
|
+
results[index] = statusHydrationResult{warnings: warnings}
|
|
1131
|
+
}(i, match)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
wg.Wait()
|
|
1135
|
+
|
|
1136
|
+
warnings := make([]string, 0)
|
|
1137
|
+
for _, result := range results {
|
|
1138
|
+
warnings = append(warnings, result.warnings...)
|
|
1139
|
+
}
|
|
1140
|
+
return compactWarnings(warnings)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
482
1143
|
func (s *MatchService) lookupMatch(ctx context.Context, query string, opts MatchLookupOptions, statusOnly bool) (NormalizedResult, error) {
|
|
483
1144
|
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
484
1145
|
if passthrough != nil {
|
|
@@ -532,47 +1193,367 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
|
|
|
532
1193
|
result := NewTransportErrorResult(EntityMatch, query, err)
|
|
533
1194
|
return nil, &result
|
|
534
1195
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1196
|
+
warnings := append([]string{}, searchResult.Warnings...)
|
|
1197
|
+
entity := IndexedEntity{}
|
|
1198
|
+
if len(searchResult.Entities) > 0 {
|
|
1199
|
+
entity = searchResult.Entities[0]
|
|
1200
|
+
} else {
|
|
1201
|
+
discovered, discoveryWarnings := s.discoverMatchByTeamQuery(ctx, query, strings.TrimSpace(opts.LeagueID))
|
|
1202
|
+
warnings = append(warnings, discoveryWarnings...)
|
|
1203
|
+
if discovered == nil {
|
|
1204
|
+
result := NormalizedResult{
|
|
1205
|
+
Kind: EntityMatch,
|
|
1206
|
+
Status: ResultStatusEmpty,
|
|
1207
|
+
Message: fmt.Sprintf("no matches found for %q", query),
|
|
1208
|
+
}
|
|
1209
|
+
return nil, &result
|
|
1210
|
+
}
|
|
1211
|
+
entity = *discovered
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
ref := buildMatchRef(entity)
|
|
1215
|
+
if ref == "" {
|
|
1216
|
+
result := NormalizedResult{
|
|
1217
|
+
Kind: EntityMatch,
|
|
1218
|
+
Status: ResultStatusEmpty,
|
|
1219
|
+
Message: fmt.Sprintf("unable to resolve match ref for %q", query),
|
|
1220
|
+
}
|
|
1221
|
+
return nil, &result
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
1225
|
+
if err != nil {
|
|
1226
|
+
result := NewTransportErrorResult(EntityMatch, ref, err)
|
|
1227
|
+
return nil, &result
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
match, err := NormalizeMatch(resolved.Body)
|
|
1231
|
+
if err != nil {
|
|
1232
|
+
result := NormalizedResult{
|
|
1233
|
+
Kind: EntityMatch,
|
|
1234
|
+
Status: ResultStatusError,
|
|
1235
|
+
Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err),
|
|
1236
|
+
}
|
|
1237
|
+
return nil, &result
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return &matchLookup{
|
|
1241
|
+
match: match,
|
|
1242
|
+
resolved: resolved,
|
|
1243
|
+
warnings: compactWarnings(warnings),
|
|
1244
|
+
}, nil
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
func (s *MatchService) discoverMatchByTeamQuery(ctx context.Context, query, leagueID string) (*IndexedEntity, []string) {
|
|
1248
|
+
left, right, ok := parseTeamVsQuery(query)
|
|
1249
|
+
if !ok {
|
|
1250
|
+
return nil, nil
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
preferredLeagueID := inferTeamQueryLeagueHint(left, right)
|
|
1254
|
+
candidates, err := s.buildTeamQueryEventCandidates(ctx, leagueID, preferredLeagueID)
|
|
1255
|
+
if err != nil {
|
|
1256
|
+
return nil, []string{fmt.Sprintf("team-query fallback unavailable: %v", err)}
|
|
1257
|
+
}
|
|
1258
|
+
if len(candidates) == 0 {
|
|
1259
|
+
return nil, []string{"team-query fallback found no event candidates"}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
for _, eventID := range candidates {
|
|
1263
|
+
reqCtx, cancel := context.WithTimeout(ctx, teamQueryEventFetchTimeout)
|
|
1264
|
+
resolved, resolveErr := s.client.ResolveRefChain(reqCtx, "/events/"+eventID)
|
|
1265
|
+
cancel()
|
|
1266
|
+
if resolveErr != nil {
|
|
1267
|
+
continue
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
payload, decodeErr := decodePayloadMap(resolved.Body)
|
|
1271
|
+
if decodeErr != nil {
|
|
1272
|
+
continue
|
|
1273
|
+
}
|
|
1274
|
+
if !eventMatchesTeamQuery(payload, left, right) {
|
|
1275
|
+
continue
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
competitions := mapSliceField(payload, "competitions")
|
|
1279
|
+
competitionID := ""
|
|
1280
|
+
competitionRef := ""
|
|
1281
|
+
if len(competitions) > 0 {
|
|
1282
|
+
competitionID = stringField(competitions[0], "id")
|
|
1283
|
+
competitionRef = stringField(competitions[0], "$ref")
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
competitionID = nonEmpty(competitionID, stringField(payload, "id"))
|
|
1287
|
+
if competitionID == "" {
|
|
1288
|
+
continue
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
refIDsMap := refIDs(nonEmpty(competitionRef, resolved.CanonicalRef, resolved.RequestedRef))
|
|
1292
|
+
eventIDResolved := nonEmpty(stringField(payload, "id"), refIDsMap["eventId"])
|
|
1293
|
+
leagueIDResolved := nonEmpty(refIDsMap["leagueId"], strings.TrimSpace(leagueID))
|
|
1294
|
+
if competitionRef == "" && leagueIDResolved != "" && eventIDResolved != "" {
|
|
1295
|
+
competitionRef = fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueIDResolved, eventIDResolved, competitionID)
|
|
1296
|
+
}
|
|
1297
|
+
if competitionRef == "" {
|
|
1298
|
+
continue
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
entity := IndexedEntity{
|
|
1302
|
+
Kind: EntityMatch,
|
|
1303
|
+
ID: competitionID,
|
|
1304
|
+
Ref: competitionRef,
|
|
1305
|
+
Name: nonEmpty(stringField(payload, "name"), stringField(payload, "shortDescription"), stringField(payload, "description")),
|
|
1306
|
+
ShortName: nonEmpty(stringField(payload, "shortName"), stringField(payload, "shortDescription")),
|
|
1307
|
+
LeagueID: leagueIDResolved,
|
|
1308
|
+
EventID: eventIDResolved,
|
|
1309
|
+
MatchID: competitionID,
|
|
1310
|
+
Aliases: []string{
|
|
1311
|
+
stringField(payload, "name"),
|
|
1312
|
+
stringField(payload, "shortName"),
|
|
1313
|
+
stringField(payload, "shortDescription"),
|
|
1314
|
+
stringField(payload, "description"),
|
|
1315
|
+
left,
|
|
1316
|
+
right,
|
|
1317
|
+
competitionID,
|
|
1318
|
+
eventIDResolved,
|
|
1319
|
+
},
|
|
1320
|
+
UpdatedAt: time.Now().UTC(),
|
|
1321
|
+
}
|
|
1322
|
+
if s.resolver != nil && s.resolver.index != nil {
|
|
1323
|
+
_ = s.resolver.index.Upsert(entity)
|
|
1324
|
+
}
|
|
1325
|
+
return &entity, []string{fmt.Sprintf("team-query fallback matched event %s", eventIDResolved)}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
return nil, []string{"team-query fallback scanned recent events with no match"}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
func (s *MatchService) buildTeamQueryEventCandidates(ctx context.Context, leagueID, preferredLeagueID string) ([]string, error) {
|
|
1332
|
+
rootRef := "/events"
|
|
1333
|
+
if strings.TrimSpace(leagueID) != "" {
|
|
1334
|
+
rootRef = "/leagues/" + strings.TrimSpace(leagueID) + "/events"
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
seedRefs := make([]Ref, 0)
|
|
1338
|
+
if refs, err := s.fetchEventRefs(ctx, rootRef); err == nil {
|
|
1339
|
+
seedRefs = append(seedRefs, refs...)
|
|
1340
|
+
}
|
|
1341
|
+
if len(seedRefs) == 0 && rootRef != "/events" {
|
|
1342
|
+
refs, err := s.fetchEventRefs(ctx, "/events")
|
|
1343
|
+
if err != nil {
|
|
1344
|
+
return nil, err
|
|
1345
|
+
}
|
|
1346
|
+
seedRefs = append(seedRefs, refs...)
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
seen := map[string]struct{}{}
|
|
1350
|
+
candidates := make([]string, 0, maxTeamQueryEventCandidates)
|
|
1351
|
+
type eventSeed struct {
|
|
1352
|
+
id int
|
|
1353
|
+
leagueID string
|
|
1354
|
+
}
|
|
1355
|
+
seeds := make([]eventSeed, 0, len(seedRefs))
|
|
1356
|
+
for _, item := range seedRefs {
|
|
1357
|
+
ids := refIDs(item.URL)
|
|
1358
|
+
eventID := strings.TrimSpace(ids["eventId"])
|
|
1359
|
+
if eventID == "" {
|
|
1360
|
+
continue
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
seed, err := strconv.Atoi(eventID)
|
|
1364
|
+
if err != nil {
|
|
1365
|
+
continue
|
|
1366
|
+
}
|
|
1367
|
+
seeds = append(seeds, eventSeed{id: seed, leagueID: strings.TrimSpace(ids["leagueId"])})
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
sort.Slice(seeds, func(i, j int) bool {
|
|
1371
|
+
iPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[i].leagueID == strings.TrimSpace(preferredLeagueID)
|
|
1372
|
+
jPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[j].leagueID == strings.TrimSpace(preferredLeagueID)
|
|
1373
|
+
if iPref != jPref {
|
|
1374
|
+
return iPref
|
|
1375
|
+
}
|
|
1376
|
+
return seeds[i].id > seeds[j].id
|
|
1377
|
+
})
|
|
1378
|
+
|
|
1379
|
+
for delta := 0; delta <= matchTeamQueryScanRange; delta++ {
|
|
1380
|
+
for _, seed := range seeds {
|
|
1381
|
+
down := strconv.Itoa(seed.id - delta)
|
|
1382
|
+
if _, ok := seen[down]; !ok {
|
|
1383
|
+
seen[down] = struct{}{}
|
|
1384
|
+
candidates = append(candidates, down)
|
|
1385
|
+
if len(candidates) >= maxTeamQueryEventCandidates {
|
|
1386
|
+
return candidates, nil
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if delta == 0 {
|
|
1391
|
+
continue
|
|
1392
|
+
}
|
|
1393
|
+
up := strconv.Itoa(seed.id + delta)
|
|
1394
|
+
if _, ok := seen[up]; !ok {
|
|
1395
|
+
seen[up] = struct{}{}
|
|
1396
|
+
candidates = append(candidates, up)
|
|
1397
|
+
if len(candidates) >= maxTeamQueryEventCandidates {
|
|
1398
|
+
return candidates, nil
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
return candidates, nil
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
func (s *MatchService) fetchEventRefs(ctx context.Context, ref string) ([]Ref, error) {
|
|
1408
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1409
|
+
if err != nil {
|
|
1410
|
+
return nil, err
|
|
1411
|
+
}
|
|
1412
|
+
page, err := DecodePage[Ref](resolved.Body)
|
|
1413
|
+
if err != nil {
|
|
1414
|
+
return nil, err
|
|
1415
|
+
}
|
|
1416
|
+
return page.Items, nil
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
func parseTeamVsQuery(query string) (string, string, bool) {
|
|
1420
|
+
normalized := normalizeAlias(query)
|
|
1421
|
+
if normalized == "" {
|
|
1422
|
+
return "", "", false
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
separators := []string{" versus ", " vs ", " v "}
|
|
1426
|
+
for _, sep := range separators {
|
|
1427
|
+
parts := strings.SplitN(normalized, sep, 2)
|
|
1428
|
+
if len(parts) != 2 {
|
|
1429
|
+
continue
|
|
1430
|
+
}
|
|
1431
|
+
left := strings.TrimSpace(parts[0])
|
|
1432
|
+
right := strings.TrimSpace(parts[1])
|
|
1433
|
+
if left == "" || right == "" {
|
|
1434
|
+
return "", "", false
|
|
1435
|
+
}
|
|
1436
|
+
return left, right, true
|
|
1437
|
+
}
|
|
1438
|
+
return "", "", false
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
func eventMatchesTeamQuery(payload map[string]any, left, right string) bool {
|
|
1442
|
+
parts := []string{
|
|
1443
|
+
stringField(payload, "name"),
|
|
1444
|
+
stringField(payload, "shortName"),
|
|
1445
|
+
stringField(payload, "shortDescription"),
|
|
1446
|
+
stringField(payload, "description"),
|
|
1447
|
+
}
|
|
1448
|
+
for _, competition := range mapSliceField(payload, "competitions") {
|
|
1449
|
+
parts = append(parts,
|
|
1450
|
+
stringField(competition, "name"),
|
|
1451
|
+
stringField(competition, "shortName"),
|
|
1452
|
+
stringField(competition, "shortDescription"),
|
|
1453
|
+
stringField(competition, "description"),
|
|
1454
|
+
stringField(competition, "note"),
|
|
1455
|
+
)
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
haystack := normalizeAlias(strings.Join(parts, " "))
|
|
1459
|
+
return teamQuerySideMatches(haystack, left) && teamQuerySideMatches(haystack, right)
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
func teamQuerySideMatches(haystack, side string) bool {
|
|
1463
|
+
if haystack == "" {
|
|
1464
|
+
return false
|
|
1465
|
+
}
|
|
1466
|
+
for _, variant := range teamQueryVariants(side) {
|
|
1467
|
+
if variant != "" && strings.Contains(haystack, variant) {
|
|
1468
|
+
return true
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
return false
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
func teamQueryVariants(side string) []string {
|
|
1475
|
+
base := normalizeAlias(side)
|
|
1476
|
+
if base == "" {
|
|
1477
|
+
return nil
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
seen := map[string]struct{}{base: {}}
|
|
1481
|
+
variants := []string{base}
|
|
1482
|
+
|
|
1483
|
+
add := func(value string) {
|
|
1484
|
+
value = normalizeAlias(value)
|
|
1485
|
+
if value == "" {
|
|
1486
|
+
return
|
|
540
1487
|
}
|
|
541
|
-
|
|
1488
|
+
if _, ok := seen[value]; ok {
|
|
1489
|
+
return
|
|
1490
|
+
}
|
|
1491
|
+
seen[value] = struct{}{}
|
|
1492
|
+
variants = append(variants, value)
|
|
542
1493
|
}
|
|
543
1494
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
Status: ResultStatusEmpty,
|
|
550
|
-
Message: fmt.Sprintf("unable to resolve match ref for %q", query),
|
|
551
|
-
}
|
|
552
|
-
return nil, &result
|
|
1495
|
+
if strings.Contains(base, "bangalore") {
|
|
1496
|
+
add(strings.ReplaceAll(base, "bangalore", "bengaluru"))
|
|
1497
|
+
}
|
|
1498
|
+
if strings.Contains(base, "bengaluru") {
|
|
1499
|
+
add(strings.ReplaceAll(base, "bengaluru", "bangalore"))
|
|
553
1500
|
}
|
|
554
1501
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
result := NewTransportErrorResult(EntityMatch, ref, err)
|
|
558
|
-
return nil, &result
|
|
1502
|
+
for _, alias := range knownIPLTeamAliases[base] {
|
|
1503
|
+
add(alias)
|
|
559
1504
|
}
|
|
560
1505
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
return
|
|
1506
|
+
return variants
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
func inferTeamQueryLeagueHint(left, right string) string {
|
|
1510
|
+
left = normalizeAlias(left)
|
|
1511
|
+
right = normalizeAlias(right)
|
|
1512
|
+
if left == "" || right == "" {
|
|
1513
|
+
return ""
|
|
1514
|
+
}
|
|
1515
|
+
_, leftKnown := knownIPLTeamAliases[left]
|
|
1516
|
+
_, rightKnown := knownIPLTeamAliases[right]
|
|
1517
|
+
if leftKnown && rightKnown {
|
|
1518
|
+
return "8048"
|
|
569
1519
|
}
|
|
1520
|
+
return ""
|
|
1521
|
+
}
|
|
570
1522
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
},
|
|
1523
|
+
var knownIPLTeamAliases = map[string][]string{
|
|
1524
|
+
"csk": {"chennai super kings", "chennai"},
|
|
1525
|
+
"chennai super kings": {"csk", "chennai"},
|
|
1526
|
+
"chennai": {"csk", "chennai super kings"},
|
|
1527
|
+
"dc": {"delhi capitals", "delhi"},
|
|
1528
|
+
"delhi capitals": {"dc", "delhi"},
|
|
1529
|
+
"delhi": {"dc", "delhi capitals"},
|
|
1530
|
+
"gt": {"gujarat titans", "gujarat"},
|
|
1531
|
+
"gujarat titans": {"gt", "gujarat"},
|
|
1532
|
+
"gujarat": {"gt", "gujarat titans"},
|
|
1533
|
+
"kkr": {"kolkata knight riders", "kolkata"},
|
|
1534
|
+
"kolkata knight riders": {"kkr", "kolkata"},
|
|
1535
|
+
"kolkata": {"kkr", "kolkata knight riders"},
|
|
1536
|
+
"lsg": {"lucknow super giants", "lucknow"},
|
|
1537
|
+
"lucknow super giants": {"lsg", "lucknow"},
|
|
1538
|
+
"lucknow": {"lsg", "lucknow super giants"},
|
|
1539
|
+
"mi": {"mumbai indians", "mumbai"},
|
|
1540
|
+
"mumbai indians": {"mi", "mumbai"},
|
|
1541
|
+
"mumbai": {"mi", "mumbai indians"},
|
|
1542
|
+
"pbks": {"punjab kings", "punjab"},
|
|
1543
|
+
"punjab kings": {"pbks", "punjab", "kxip"},
|
|
1544
|
+
"punjab": {"pbks", "punjab kings", "kxip"},
|
|
1545
|
+
"kxip": {"pbks", "punjab kings", "punjab"},
|
|
1546
|
+
"rcb": {"royal challengers bengaluru", "royal challengers bangalore", "bangalore", "bengaluru"},
|
|
1547
|
+
"royal challengers bengaluru": {"rcb", "royal challengers bangalore", "bangalore", "bengaluru"},
|
|
1548
|
+
"royal challengers bangalore": {"rcb", "royal challengers bengaluru", "bangalore", "bengaluru"},
|
|
1549
|
+
"bangalore": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bengaluru"},
|
|
1550
|
+
"bengaluru": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bangalore"},
|
|
1551
|
+
"rr": {"rajasthan royals", "rajasthan"},
|
|
1552
|
+
"rajasthan royals": {"rr", "rajasthan"},
|
|
1553
|
+
"rajasthan": {"rr", "rajasthan royals"},
|
|
1554
|
+
"srh": {"sunrisers hyderabad", "hyderabad"},
|
|
1555
|
+
"sunrisers hyderabad": {"srh", "hyderabad"},
|
|
1556
|
+
"hyderabad": {"srh", "sunrisers hyderabad"},
|
|
576
1557
|
}
|
|
577
1558
|
|
|
578
1559
|
func (s *MatchService) deliveryEventsFromRoute(ctx context.Context, ref string, baseWarnings []string) (NormalizedResult, error) {
|
|
@@ -628,7 +1609,9 @@ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]De
|
|
|
628
1609
|
return
|
|
629
1610
|
}
|
|
630
1611
|
|
|
631
|
-
|
|
1612
|
+
itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
|
|
1613
|
+
itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
|
|
1614
|
+
cancel()
|
|
632
1615
|
if itemErr != nil {
|
|
633
1616
|
results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, itemErr)}
|
|
634
1617
|
return
|
|
@@ -652,12 +1635,153 @@ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]De
|
|
|
652
1635
|
continue
|
|
653
1636
|
}
|
|
654
1637
|
if result.delivery != nil {
|
|
655
|
-
|
|
1638
|
+
if isRenderableDelivery(*result.delivery) {
|
|
1639
|
+
deliveries = append(deliveries, *result.delivery)
|
|
1640
|
+
}
|
|
656
1641
|
}
|
|
657
1642
|
}
|
|
658
1643
|
return deliveries, compactWarnings(warnings)
|
|
659
1644
|
}
|
|
660
1645
|
|
|
1646
|
+
func (s *MatchService) deliveryEventsForMatchRefs(ctx context.Context, primaryRef string, alternateRefs ...string) ([]DeliveryEvent, []string, error) {
|
|
1647
|
+
primaryRef = strings.TrimSpace(primaryRef)
|
|
1648
|
+
primaryEvents, primaryWarnings, primaryErr := s.loadDeliveryEventsFromRoute(ctx, primaryRef)
|
|
1649
|
+
if primaryErr == nil && len(primaryEvents) > 0 && len(primaryWarnings) == 0 {
|
|
1650
|
+
return primaryEvents, nil, nil
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
merged := append([]DeliveryEvent{}, primaryEvents...)
|
|
1654
|
+
warnings := append([]string{}, primaryWarnings...)
|
|
1655
|
+
if primaryErr != nil {
|
|
1656
|
+
warnings = append(warnings, fmt.Sprintf("delivery route %s: %v", primaryRef, primaryErr))
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
for _, ref := range alternateRefs {
|
|
1660
|
+
ref = strings.TrimSpace(ref)
|
|
1661
|
+
if ref == "" || ref == primaryRef {
|
|
1662
|
+
continue
|
|
1663
|
+
}
|
|
1664
|
+
events, routeWarnings, err := s.loadDeliveryEventsFromRoute(ctx, ref)
|
|
1665
|
+
if err != nil {
|
|
1666
|
+
warnings = append(warnings, fmt.Sprintf("delivery route %s: %v", ref, err))
|
|
1667
|
+
continue
|
|
1668
|
+
}
|
|
1669
|
+
warnings = append(warnings, routeWarnings...)
|
|
1670
|
+
merged = append(merged, events...)
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
merged = dedupeDeliveryEvents(merged)
|
|
1674
|
+
sortDeliveryEvents(merged)
|
|
1675
|
+
if len(merged) == 0 && primaryErr != nil {
|
|
1676
|
+
return nil, compactWarnings(warnings), primaryErr
|
|
1677
|
+
}
|
|
1678
|
+
return merged, compactWarnings(warnings), nil
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
func (s *MatchService) loadDeliveryEventsFromRoute(ctx context.Context, ref string) ([]DeliveryEvent, []string, error) {
|
|
1682
|
+
ref = strings.TrimSpace(ref)
|
|
1683
|
+
if ref == "" {
|
|
1684
|
+
return nil, nil, fmt.Errorf("empty delivery route")
|
|
1685
|
+
}
|
|
1686
|
+
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
1687
|
+
if err != nil {
|
|
1688
|
+
return nil, nil, err
|
|
1689
|
+
}
|
|
1690
|
+
pageItems, pageWarnings, err := s.resolvePageRefs(ctx, resolved)
|
|
1691
|
+
if err != nil {
|
|
1692
|
+
return nil, nil, err
|
|
1693
|
+
}
|
|
1694
|
+
loaded, loadWarnings := s.loadDeliveryEvents(ctx, pageItems)
|
|
1695
|
+
warnings := make([]string, 0, len(pageWarnings)+len(loadWarnings))
|
|
1696
|
+
warnings = append(warnings, pageWarnings...)
|
|
1697
|
+
warnings = append(warnings, loadWarnings...)
|
|
1698
|
+
return loaded, compactWarnings(warnings), nil
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
func (s *MatchService) loadRecentDeliveryEventsFromRoute(ctx context.Context, ref string, maxItems int) ([]DeliveryEvent, []string, error) {
|
|
1702
|
+
ref = strings.TrimSpace(ref)
|
|
1703
|
+
if ref == "" {
|
|
1704
|
+
return nil, nil, fmt.Errorf("empty delivery route")
|
|
1705
|
+
}
|
|
1706
|
+
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
1707
|
+
if err != nil {
|
|
1708
|
+
return nil, nil, err
|
|
1709
|
+
}
|
|
1710
|
+
pageItems, pageWarnings, err := s.resolvePageRefs(ctx, resolved)
|
|
1711
|
+
if err != nil {
|
|
1712
|
+
return nil, nil, err
|
|
1713
|
+
}
|
|
1714
|
+
if maxItems > 0 && len(pageItems) > maxItems {
|
|
1715
|
+
pageItems = append([]Ref(nil), pageItems[len(pageItems)-maxItems:]...)
|
|
1716
|
+
}
|
|
1717
|
+
loaded, loadWarnings := s.loadDeliveryEvents(ctx, pageItems)
|
|
1718
|
+
warnings := make([]string, 0, len(pageWarnings)+len(loadWarnings))
|
|
1719
|
+
warnings = append(warnings, pageWarnings...)
|
|
1720
|
+
warnings = append(warnings, loadWarnings...)
|
|
1721
|
+
return loaded, compactWarnings(warnings), nil
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
func isRenderableDelivery(delivery DeliveryEvent) bool {
|
|
1725
|
+
if delivery.OverNumber > 0 && delivery.BallNumber > 0 {
|
|
1726
|
+
return true
|
|
1727
|
+
}
|
|
1728
|
+
text := strings.TrimSpace(firstNonEmpty(delivery.ShortText, delivery.Text))
|
|
1729
|
+
if text == "" || text == "/" || text == "-" {
|
|
1730
|
+
return false
|
|
1731
|
+
}
|
|
1732
|
+
lowered := strings.ToLower(text)
|
|
1733
|
+
return !strings.Contains(lowered, "over - ball")
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
func sortDeliveryEvents(deliveries []DeliveryEvent) {
|
|
1737
|
+
sort.SliceStable(deliveries, func(i, j int) bool {
|
|
1738
|
+
if deliveries[i].Period != deliveries[j].Period {
|
|
1739
|
+
return deliveries[i].Period < deliveries[j].Period
|
|
1740
|
+
}
|
|
1741
|
+
if deliveries[i].OverNumber != deliveries[j].OverNumber {
|
|
1742
|
+
return deliveries[i].OverNumber < deliveries[j].OverNumber
|
|
1743
|
+
}
|
|
1744
|
+
if deliveries[i].BallNumber != deliveries[j].BallNumber {
|
|
1745
|
+
return deliveries[i].BallNumber < deliveries[j].BallNumber
|
|
1746
|
+
}
|
|
1747
|
+
if deliveries[i].Sequence != deliveries[j].Sequence {
|
|
1748
|
+
return deliveries[i].Sequence < deliveries[j].Sequence
|
|
1749
|
+
}
|
|
1750
|
+
return deliveries[i].BBBTimestamp < deliveries[j].BBBTimestamp
|
|
1751
|
+
})
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
func dedupeDeliveryEvents(deliveries []DeliveryEvent) []DeliveryEvent {
|
|
1755
|
+
if len(deliveries) <= 1 {
|
|
1756
|
+
return deliveries
|
|
1757
|
+
}
|
|
1758
|
+
seen := map[string]int{}
|
|
1759
|
+
out := make([]DeliveryEvent, 0, len(deliveries))
|
|
1760
|
+
for _, delivery := range deliveries {
|
|
1761
|
+
key := strings.TrimSpace(delivery.ID)
|
|
1762
|
+
if key == "" {
|
|
1763
|
+
key = fmt.Sprintf("%d|%d|%d|%d|%s|%s|%s",
|
|
1764
|
+
delivery.Period,
|
|
1765
|
+
delivery.OverNumber,
|
|
1766
|
+
delivery.BallNumber,
|
|
1767
|
+
delivery.Sequence,
|
|
1768
|
+
strings.TrimSpace(delivery.ShortText),
|
|
1769
|
+
strings.TrimSpace(delivery.HomeScore),
|
|
1770
|
+
strings.TrimSpace(delivery.AwayScore),
|
|
1771
|
+
)
|
|
1772
|
+
}
|
|
1773
|
+
if idx, ok := seen[key]; ok {
|
|
1774
|
+
if delivery.Sequence > out[idx].Sequence || delivery.BBBTimestamp > out[idx].BBBTimestamp {
|
|
1775
|
+
out[idx] = delivery
|
|
1776
|
+
}
|
|
1777
|
+
continue
|
|
1778
|
+
}
|
|
1779
|
+
seen[key] = len(out)
|
|
1780
|
+
out = append(out, delivery)
|
|
1781
|
+
}
|
|
1782
|
+
return out
|
|
1783
|
+
}
|
|
1784
|
+
|
|
661
1785
|
type selectedInningsContext struct {
|
|
662
1786
|
match Match
|
|
663
1787
|
team Team
|
|
@@ -962,26 +2086,55 @@ func (s *MatchService) fetchDetailedRefCollection(
|
|
|
962
2086
|
return nil, nil, nil, err
|
|
963
2087
|
}
|
|
964
2088
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
continue
|
|
971
|
-
}
|
|
2089
|
+
type normalizedItemResult struct {
|
|
2090
|
+
index int
|
|
2091
|
+
item any
|
|
2092
|
+
warning string
|
|
2093
|
+
}
|
|
972
2094
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
2095
|
+
results := make([]normalizedItemResult, len(pageItems))
|
|
2096
|
+
sem := make(chan struct{}, detailSubresourceFetchConcurrency)
|
|
2097
|
+
var wg sync.WaitGroup
|
|
2098
|
+
for i, item := range pageItems {
|
|
2099
|
+
wg.Add(1)
|
|
2100
|
+
go func(index int, item Ref) {
|
|
2101
|
+
defer wg.Done()
|
|
2102
|
+
sem <- struct{}{}
|
|
2103
|
+
defer func() { <-sem }()
|
|
2104
|
+
|
|
2105
|
+
itemRef := strings.TrimSpace(item.URL)
|
|
2106
|
+
if itemRef == "" {
|
|
2107
|
+
results[index] = normalizedItemResult{index: index, warning: "skip item with empty ref"}
|
|
2108
|
+
return
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
|
|
2112
|
+
itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
|
|
2113
|
+
cancel()
|
|
2114
|
+
if itemErr != nil {
|
|
2115
|
+
results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, itemErr)}
|
|
2116
|
+
return
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
normalized, normalizeErr := normalize(itemResolved.Body)
|
|
2120
|
+
if normalizeErr != nil {
|
|
2121
|
+
results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, normalizeErr)}
|
|
2122
|
+
return
|
|
2123
|
+
}
|
|
2124
|
+
results[index] = normalizedItemResult{index: index, item: normalized}
|
|
2125
|
+
}(i, item)
|
|
2126
|
+
}
|
|
2127
|
+
wg.Wait()
|
|
978
2128
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
2129
|
+
items := make([]any, 0, len(results))
|
|
2130
|
+
for _, result := range results {
|
|
2131
|
+
if strings.TrimSpace(result.warning) != "" {
|
|
2132
|
+
warnings = append(warnings, result.warning)
|
|
982
2133
|
continue
|
|
983
2134
|
}
|
|
984
|
-
|
|
2135
|
+
if result.item != nil {
|
|
2136
|
+
items = append(items, result.item)
|
|
2137
|
+
}
|
|
985
2138
|
}
|
|
986
2139
|
|
|
987
2140
|
return resolved, items, compactWarnings(warnings), nil
|
|
@@ -1001,25 +2154,55 @@ func (s *MatchService) resolvePageRefs(ctx context.Context, first *ResolvedDocum
|
|
|
1001
2154
|
return items, nil, nil
|
|
1002
2155
|
}
|
|
1003
2156
|
|
|
1004
|
-
|
|
2157
|
+
type pageLoadResult struct {
|
|
2158
|
+
page int
|
|
2159
|
+
items []Ref
|
|
2160
|
+
warning string
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
results := make([]pageLoadResult, page.PageCount+1)
|
|
2164
|
+
sem := make(chan struct{}, detailSubresourceFetchConcurrency)
|
|
2165
|
+
var wg sync.WaitGroup
|
|
1005
2166
|
baseRef := firstNonEmptyString(first.CanonicalRef, first.RequestedRef)
|
|
1006
2167
|
for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
2168
|
+
wg.Add(1)
|
|
2169
|
+
go func(index int) {
|
|
2170
|
+
defer wg.Done()
|
|
2171
|
+
sem <- struct{}{}
|
|
2172
|
+
defer func() { <-sem }()
|
|
2173
|
+
|
|
2174
|
+
pageRef := pagedRef(baseRef, index)
|
|
2175
|
+
if pageRef == "" {
|
|
2176
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d unavailable for %s", index, baseRef)}
|
|
2177
|
+
return
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
pageDoc, pageErr := s.resolveRefChainResilient(ctx, pageRef)
|
|
2181
|
+
if pageErr != nil {
|
|
2182
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageRef, pageErr)}
|
|
2183
|
+
return
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
nextPage, decodeErr := DecodePage[Ref](pageDoc.Body)
|
|
2187
|
+
if decodeErr != nil {
|
|
2188
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageDoc.CanonicalRef, decodeErr)}
|
|
2189
|
+
return
|
|
2190
|
+
}
|
|
2191
|
+
results[index] = pageLoadResult{page: index, items: nextPage.Items}
|
|
2192
|
+
}(pageIndex)
|
|
2193
|
+
}
|
|
2194
|
+
wg.Wait()
|
|
2195
|
+
|
|
2196
|
+
warnings := make([]string, 0)
|
|
2197
|
+
for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
|
|
2198
|
+
result := results[pageIndex]
|
|
2199
|
+
if strings.TrimSpace(result.warning) != "" {
|
|
2200
|
+
warnings = append(warnings, result.warning)
|
|
1015
2201
|
continue
|
|
1016
2202
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
warnings = append(warnings, fmt.Sprintf("page %d %s: %v", pageIndex, pageDoc.CanonicalRef, decodeErr))
|
|
1020
|
-
continue
|
|
2203
|
+
if len(result.items) > 0 {
|
|
2204
|
+
items = append(items, result.items...)
|
|
1021
2205
|
}
|
|
1022
|
-
items = append(items, nextPage.Items...)
|
|
1023
2206
|
}
|
|
1024
2207
|
|
|
1025
2208
|
return items, compactWarnings(warnings), nil
|
|
@@ -1449,11 +2632,381 @@ func extensionRef(extensions map[string]any, key string) string {
|
|
|
1449
2632
|
return strings.TrimSpace(stringField(refMap, "$ref"))
|
|
1450
2633
|
}
|
|
1451
2634
|
|
|
2635
|
+
func (s *MatchService) buildLiveView(ctx context.Context, match Match) (*MatchLiveView, []string) {
|
|
2636
|
+
detailsRef := nonEmpty(strings.TrimSpace(match.DetailsRef), matchSubresourceRef(match, "details", "details"))
|
|
2637
|
+
playsRef := matchSubresourceRef(match, "plays", "plays")
|
|
2638
|
+
if detailsRef == "" && playsRef == "" {
|
|
2639
|
+
return nil, nil
|
|
2640
|
+
}
|
|
2641
|
+
primaryRef := nonEmpty(detailsRef, playsRef)
|
|
2642
|
+
deliveries, warnings, err := s.loadRecentDeliveryEventsFromRoute(ctx, primaryRef, liveViewRecentDeliveryFetchCount)
|
|
2643
|
+
if err != nil {
|
|
2644
|
+
warnings = append(warnings, fmt.Sprintf("live deliveries %s: %v", primaryRef, err))
|
|
2645
|
+
deliveries = nil
|
|
2646
|
+
}
|
|
2647
|
+
if len(deliveries) < 6 || len(warnings) > 0 {
|
|
2648
|
+
alternate := strings.TrimSpace(playsRef)
|
|
2649
|
+
if alternate != "" && alternate != primaryRef {
|
|
2650
|
+
altDeliveries, altWarnings, altErr := s.loadRecentDeliveryEventsFromRoute(ctx, alternate, liveViewRecentDeliveryFetchCount)
|
|
2651
|
+
if altErr != nil {
|
|
2652
|
+
warnings = append(warnings, fmt.Sprintf("live deliveries %s: %v", alternate, altErr))
|
|
2653
|
+
} else if len(altDeliveries) > 0 {
|
|
2654
|
+
deliveries = append(deliveries, altDeliveries...)
|
|
2655
|
+
warnings = append(warnings, altWarnings...)
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
deliveries = dedupeDeliveryEvents(deliveries)
|
|
2660
|
+
sortDeliveryEvents(deliveries)
|
|
2661
|
+
if len(deliveries) == 0 {
|
|
2662
|
+
return nil, warnings
|
|
2663
|
+
}
|
|
2664
|
+
latest := deliveries[len(deliveries)-1]
|
|
2665
|
+
|
|
2666
|
+
nameMap := map[string]string{}
|
|
2667
|
+
for _, event := range deliveries {
|
|
2668
|
+
bowlerName, batsmanName := parseNamesFromDeliveryShortText(event.ShortText)
|
|
2669
|
+
if strings.TrimSpace(event.BowlerPlayerID) != "" && strings.TrimSpace(bowlerName) != "" {
|
|
2670
|
+
if _, ok := nameMap[event.BowlerPlayerID]; !ok {
|
|
2671
|
+
nameMap[event.BowlerPlayerID] = bowlerName
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
if strings.TrimSpace(event.BatsmanPlayerID) != "" && strings.TrimSpace(batsmanName) != "" {
|
|
2675
|
+
if _, ok := nameMap[event.BatsmanPlayerID]; !ok {
|
|
2676
|
+
nameMap[event.BatsmanPlayerID] = batsmanName
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
score := firstNonEmpty(matchScoreLabel(latest.HomeScore), matchScoreLabel(latest.AwayScore))
|
|
2681
|
+
if score == "" {
|
|
2682
|
+
score = match.ScoreSummary
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
battingTeam := teamLabelByID(match, latest.TeamID)
|
|
2686
|
+
bowlingTeam := otherTeamLabelByID(match, latest.TeamID)
|
|
2687
|
+
if battingTeam == "" {
|
|
2688
|
+
battingTeam = firstNonEmpty(matchTeamsLabelFromMatch(match), latest.TeamID)
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
batters := make([]LiveBatterView, 0, 2)
|
|
2692
|
+
if latest.BatsmanPlayerID != "" {
|
|
2693
|
+
batters = append(batters, LiveBatterView{
|
|
2694
|
+
PlayerID: latest.BatsmanPlayerID,
|
|
2695
|
+
PlayerName: firstNonEmpty(nameMap[latest.BatsmanPlayerID], latest.BatsmanPlayerID),
|
|
2696
|
+
Runs: latest.BatsmanTotalRuns,
|
|
2697
|
+
Balls: latest.BatsmanBalls,
|
|
2698
|
+
Fours: latest.BatsmanFours,
|
|
2699
|
+
Sixes: latest.BatsmanSixes,
|
|
2700
|
+
StrikeRate: strikeRate(latest.BatsmanTotalRuns, latest.BatsmanBalls),
|
|
2701
|
+
OnStrike: true,
|
|
2702
|
+
})
|
|
2703
|
+
}
|
|
2704
|
+
if latest.OtherBatsmanID != "" {
|
|
2705
|
+
batters = append(batters, LiveBatterView{
|
|
2706
|
+
PlayerID: latest.OtherBatsmanID,
|
|
2707
|
+
PlayerName: firstNonEmpty(nameMap[latest.OtherBatsmanID], latest.OtherBatsmanID),
|
|
2708
|
+
Runs: latest.OtherBatterRuns,
|
|
2709
|
+
Balls: latest.OtherBatterBalls,
|
|
2710
|
+
Fours: latest.OtherBatterFours,
|
|
2711
|
+
Sixes: latest.OtherBatterSixes,
|
|
2712
|
+
StrikeRate: strikeRate(latest.OtherBatterRuns, latest.OtherBatterBalls),
|
|
2713
|
+
})
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
bowlers := make([]LiveBowlerView, 0, 2)
|
|
2717
|
+
if latest.BowlerPlayerID != "" {
|
|
2718
|
+
bowlers = append(bowlers, LiveBowlerView{
|
|
2719
|
+
PlayerID: latest.BowlerPlayerID,
|
|
2720
|
+
PlayerName: firstNonEmpty(nameMap[latest.BowlerPlayerID], latest.BowlerPlayerID),
|
|
2721
|
+
Overs: latest.BowlerOvers,
|
|
2722
|
+
Balls: latest.BowlerBalls,
|
|
2723
|
+
Maidens: latest.BowlerMaidens,
|
|
2724
|
+
Conceded: latest.BowlerConceded,
|
|
2725
|
+
Wickets: latest.BowlerWickets,
|
|
2726
|
+
Economy: economy(latest.BowlerConceded, latest.BowlerBalls, latest.BowlerOvers),
|
|
2727
|
+
})
|
|
2728
|
+
}
|
|
2729
|
+
if latest.OtherBowlerID != "" && latest.OtherBowlerID != latest.BowlerPlayerID {
|
|
2730
|
+
bowlers = append(bowlers, LiveBowlerView{
|
|
2731
|
+
PlayerID: latest.OtherBowlerID,
|
|
2732
|
+
PlayerName: firstNonEmpty(nameMap[latest.OtherBowlerID], latest.OtherBowlerID),
|
|
2733
|
+
Overs: latest.OtherBowlerOvers,
|
|
2734
|
+
Balls: latest.OtherBowlerBalls,
|
|
2735
|
+
Maidens: latest.OtherBowlerMaidens,
|
|
2736
|
+
Conceded: latest.OtherBowlerConceded,
|
|
2737
|
+
Wickets: latest.OtherBowlerWickets,
|
|
2738
|
+
Economy: economy(latest.OtherBowlerConceded, latest.OtherBowlerBalls, latest.OtherBowlerOvers),
|
|
2739
|
+
})
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
startRecent := len(deliveries) - 6
|
|
2743
|
+
if startRecent < 0 {
|
|
2744
|
+
startRecent = 0
|
|
2745
|
+
}
|
|
2746
|
+
recent := append([]DeliveryEvent(nil), deliveries[startRecent:]...)
|
|
2747
|
+
currentOver := make([]DeliveryEvent, 0, 6)
|
|
2748
|
+
for _, event := range deliveries {
|
|
2749
|
+
if event.OverNumber == latest.OverNumber && event.Period == latest.Period {
|
|
2750
|
+
currentOver = append(currentOver, event)
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
view := &MatchLiveView{
|
|
2755
|
+
Fixture: nonEmpty(match.ShortDescription, match.Description),
|
|
2756
|
+
Status: nonEmpty(match.MatchState, match.Note),
|
|
2757
|
+
Score: score,
|
|
2758
|
+
Overs: overBallString(latest.OverNumber, latest.BallNumber),
|
|
2759
|
+
CurrentOver: latest.OverNumber,
|
|
2760
|
+
BallInOver: latest.BallNumber,
|
|
2761
|
+
BattingTeam: battingTeam,
|
|
2762
|
+
BowlingTeam: bowlingTeam,
|
|
2763
|
+
Batters: batters,
|
|
2764
|
+
Bowlers: bowlers,
|
|
2765
|
+
RecentBalls: recent,
|
|
2766
|
+
CurrentBalls: currentOver,
|
|
2767
|
+
LastDetailID: strings.TrimSpace(latest.ID),
|
|
2768
|
+
LastUpdateMS: latest.BBBTimestamp,
|
|
2769
|
+
SnapshotAt: time.Now().UTC().Format(time.RFC3339),
|
|
2770
|
+
SourceRoute: primaryRef,
|
|
2771
|
+
}
|
|
2772
|
+
stale, reason := detectLiveStaleness(match.ScoreSummary, score)
|
|
2773
|
+
view.Stale = stale
|
|
2774
|
+
view.StaleReason = reason
|
|
2775
|
+
return view, compactWarnings(warnings)
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
func augmentScorecardFromLive(scorecard *MatchScorecard, live *MatchLiveView) {
|
|
2779
|
+
if scorecard == nil || live == nil {
|
|
2780
|
+
return
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
if len(scorecard.BattingCards) == 0 && len(live.Batters) > 0 {
|
|
2784
|
+
card := BattingCard{
|
|
2785
|
+
InningsNumber: 1,
|
|
2786
|
+
TeamName: live.BattingTeam,
|
|
2787
|
+
Runs: live.Score,
|
|
2788
|
+
Players: make([]BattingCardEntry, 0, len(live.Batters)),
|
|
2789
|
+
}
|
|
2790
|
+
for _, batter := range live.Batters {
|
|
2791
|
+
card.Players = append(card.Players, BattingCardEntry{
|
|
2792
|
+
PlayerID: batter.PlayerID,
|
|
2793
|
+
PlayerName: batter.PlayerName,
|
|
2794
|
+
Runs: strconv.Itoa(batter.Runs),
|
|
2795
|
+
BallsFaced: strconv.Itoa(batter.Balls),
|
|
2796
|
+
Fours: strconv.Itoa(batter.Fours),
|
|
2797
|
+
Sixes: strconv.Itoa(batter.Sixes),
|
|
2798
|
+
})
|
|
2799
|
+
}
|
|
2800
|
+
scorecard.BattingCards = append(scorecard.BattingCards, card)
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
if len(scorecard.BowlingCards) == 0 && len(live.Bowlers) > 0 {
|
|
2804
|
+
card := BowlingCard{
|
|
2805
|
+
InningsNumber: 1,
|
|
2806
|
+
TeamName: live.BowlingTeam,
|
|
2807
|
+
Players: make([]BowlingCardEntry, 0, len(live.Bowlers)),
|
|
2808
|
+
}
|
|
2809
|
+
for _, bowler := range live.Bowlers {
|
|
2810
|
+
card.Players = append(card.Players, BowlingCardEntry{
|
|
2811
|
+
PlayerID: bowler.PlayerID,
|
|
2812
|
+
PlayerName: bowler.PlayerName,
|
|
2813
|
+
Overs: overFromBallsOrFloat(bowler.Balls, bowler.Overs),
|
|
2814
|
+
Maidens: strconv.Itoa(bowler.Maidens),
|
|
2815
|
+
Conceded: strconv.Itoa(bowler.Conceded),
|
|
2816
|
+
Wickets: strconv.Itoa(bowler.Wickets),
|
|
2817
|
+
EconomyRate: fmt.Sprintf("%.2f", bowler.Economy),
|
|
2818
|
+
})
|
|
2819
|
+
}
|
|
2820
|
+
scorecard.BowlingCards = append(scorecard.BowlingCards, card)
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
func strikeRate(runs, balls int) float64 {
|
|
2825
|
+
if balls <= 0 {
|
|
2826
|
+
return 0
|
|
2827
|
+
}
|
|
2828
|
+
return float64(runs) * 100.0 / float64(balls)
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
func economy(conceded, balls int, overs float64) float64 {
|
|
2832
|
+
if balls > 0 {
|
|
2833
|
+
return float64(conceded) / (float64(balls) / 6.0)
|
|
2834
|
+
}
|
|
2835
|
+
if overs > 0 {
|
|
2836
|
+
return float64(conceded) / overs
|
|
2837
|
+
}
|
|
2838
|
+
return 0
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
func overFromBallsOrFloat(balls int, overs float64) string {
|
|
2842
|
+
if balls > 0 {
|
|
2843
|
+
return fmt.Sprintf("%d.%d", balls/6, balls%6)
|
|
2844
|
+
}
|
|
2845
|
+
if overs > 0 {
|
|
2846
|
+
return fmt.Sprintf("%.1f", overs)
|
|
2847
|
+
}
|
|
2848
|
+
return "0.0"
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
func teamLabelByID(match Match, teamID string) string {
|
|
2852
|
+
teamID = strings.TrimSpace(teamID)
|
|
2853
|
+
for _, team := range match.Teams {
|
|
2854
|
+
if strings.TrimSpace(team.ID) == teamID {
|
|
2855
|
+
return nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), teamID)
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
return ""
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
func otherTeamLabelByID(match Match, teamID string) string {
|
|
2862
|
+
teamID = strings.TrimSpace(teamID)
|
|
2863
|
+
for _, team := range match.Teams {
|
|
2864
|
+
if strings.TrimSpace(team.ID) != teamID {
|
|
2865
|
+
return nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), strings.TrimSpace(team.ID))
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
return ""
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
func matchTeamsLabelFromMatch(match Match) string {
|
|
2872
|
+
parts := make([]string, 0, len(match.Teams))
|
|
2873
|
+
for _, team := range match.Teams {
|
|
2874
|
+
label := nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), strings.TrimSpace(team.ID))
|
|
2875
|
+
if label != "" {
|
|
2876
|
+
parts = append(parts, label)
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return strings.Join(parts, ", ")
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
func parseNamesFromDeliveryShortText(shortText string) (string, string) {
|
|
2883
|
+
shortText = strings.TrimSpace(shortText)
|
|
2884
|
+
if shortText == "" {
|
|
2885
|
+
return "", ""
|
|
2886
|
+
}
|
|
2887
|
+
toParts := strings.SplitN(shortText, " to ", 2)
|
|
2888
|
+
if len(toParts) != 2 {
|
|
2889
|
+
return "", ""
|
|
2890
|
+
}
|
|
2891
|
+
bowler := strings.TrimSpace(toParts[0])
|
|
2892
|
+
right := toParts[1]
|
|
2893
|
+
commaParts := strings.SplitN(right, ",", 2)
|
|
2894
|
+
if len(commaParts) == 0 {
|
|
2895
|
+
return "", ""
|
|
2896
|
+
}
|
|
2897
|
+
batsman := strings.TrimSpace(commaParts[0])
|
|
2898
|
+
return bowler, batsman
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
func detectLiveStaleness(matchScore, liveScore string) (bool, string) {
|
|
2902
|
+
matchRuns, matchWkts, okMatch := parseRunsWkts(matchScore)
|
|
2903
|
+
liveRuns, liveWkts, okLive := parseRunsWkts(liveScore)
|
|
2904
|
+
if !okMatch || !okLive {
|
|
2905
|
+
return false, ""
|
|
2906
|
+
}
|
|
2907
|
+
if liveRuns < matchRuns || liveWkts < matchWkts {
|
|
2908
|
+
return true, fmt.Sprintf("live snapshot %d/%d trails match summary %d/%d", liveRuns, liveWkts, matchRuns, matchWkts)
|
|
2909
|
+
}
|
|
2910
|
+
return false, ""
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
func parseRunsWkts(raw string) (int, int, bool) {
|
|
2914
|
+
raw = strings.TrimSpace(raw)
|
|
2915
|
+
if raw == "" {
|
|
2916
|
+
return 0, 0, false
|
|
2917
|
+
}
|
|
2918
|
+
idx := strings.Index(raw, "/")
|
|
2919
|
+
if idx <= 0 || idx >= len(raw)-1 {
|
|
2920
|
+
return 0, 0, false
|
|
2921
|
+
}
|
|
2922
|
+
left := idx - 1
|
|
2923
|
+
for left >= 0 && raw[left] >= '0' && raw[left] <= '9' {
|
|
2924
|
+
left--
|
|
2925
|
+
}
|
|
2926
|
+
right := idx + 1
|
|
2927
|
+
for right < len(raw) && raw[right] >= '0' && raw[right] <= '9' {
|
|
2928
|
+
right++
|
|
2929
|
+
}
|
|
2930
|
+
runStr := strings.TrimSpace(raw[left+1 : idx])
|
|
2931
|
+
wktStr := strings.TrimSpace(raw[idx+1 : right])
|
|
2932
|
+
runs, err1 := strconv.Atoi(runStr)
|
|
2933
|
+
wkts, err2 := strconv.Atoi(wktStr)
|
|
2934
|
+
if err1 != nil || err2 != nil {
|
|
2935
|
+
return 0, 0, false
|
|
2936
|
+
}
|
|
2937
|
+
return runs, wkts, true
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
func resolveDuelIdentity(deliveries []DeliveryEvent, query string, isBatter bool) (string, string) {
|
|
2941
|
+
q := normalizeAlias(query)
|
|
2942
|
+
if q == "" {
|
|
2943
|
+
return "", ""
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
type candidate struct {
|
|
2947
|
+
id string
|
|
2948
|
+
name string
|
|
2949
|
+
score int
|
|
2950
|
+
}
|
|
2951
|
+
best := candidate{}
|
|
2952
|
+
for _, delivery := range deliveries {
|
|
2953
|
+
bowlerName, batsmanName := parseNamesFromDeliveryShortText(delivery.ShortText)
|
|
2954
|
+
id := delivery.BowlerPlayerID
|
|
2955
|
+
name := bowlerName
|
|
2956
|
+
if isBatter {
|
|
2957
|
+
id = delivery.BatsmanPlayerID
|
|
2958
|
+
name = batsmanName
|
|
2959
|
+
}
|
|
2960
|
+
if strings.TrimSpace(id) == "" && strings.TrimSpace(name) == "" {
|
|
2961
|
+
continue
|
|
2962
|
+
}
|
|
2963
|
+
score := aliasMatchScore(normalizeAlias(nonEmpty(name, id)), q, strings.Fields(q))
|
|
2964
|
+
if normalizeAlias(id) == q {
|
|
2965
|
+
score = 1000
|
|
2966
|
+
}
|
|
2967
|
+
if score > best.score {
|
|
2968
|
+
best = candidate{id: strings.TrimSpace(id), name: strings.TrimSpace(name), score: score}
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
if best.score >= 300 || best.score == 1000 {
|
|
2972
|
+
return best.id, best.name
|
|
2973
|
+
}
|
|
2974
|
+
return "", ""
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
func deliveryMatchesDuel(delivery DeliveryEvent, batterID, batterName, bowlerID, bowlerName string) bool {
|
|
2978
|
+
shortBowler, shortBatter := parseNamesFromDeliveryShortText(delivery.ShortText)
|
|
2979
|
+
batterMatch := strings.TrimSpace(batterID) != "" && strings.TrimSpace(delivery.BatsmanPlayerID) == strings.TrimSpace(batterID)
|
|
2980
|
+
if !batterMatch && normalizeAlias(batterName) != "" {
|
|
2981
|
+
batterMatch = normalizeAlias(shortBatter) == normalizeAlias(batterName)
|
|
2982
|
+
}
|
|
2983
|
+
if !batterMatch {
|
|
2984
|
+
return false
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
bowlerMatch := strings.TrimSpace(bowlerID) != "" && strings.TrimSpace(delivery.BowlerPlayerID) == strings.TrimSpace(bowlerID)
|
|
2988
|
+
if !bowlerMatch && normalizeAlias(bowlerName) != "" {
|
|
2989
|
+
bowlerMatch = normalizeAlias(shortBowler) == normalizeAlias(bowlerName)
|
|
2990
|
+
}
|
|
2991
|
+
return bowlerMatch
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
func matchScoreLabel(raw string) string {
|
|
2995
|
+
raw = strings.TrimSpace(raw)
|
|
2996
|
+
if raw == "" {
|
|
2997
|
+
return ""
|
|
2998
|
+
}
|
|
2999
|
+
if strings.Contains(raw, "/") {
|
|
3000
|
+
return raw
|
|
3001
|
+
}
|
|
3002
|
+
return ""
|
|
3003
|
+
}
|
|
3004
|
+
|
|
1452
3005
|
func isSparseSituation(situation *MatchSituation) bool {
|
|
1453
3006
|
if situation == nil {
|
|
1454
3007
|
return true
|
|
1455
3008
|
}
|
|
1456
|
-
return len(situation.Data) == 0
|
|
3009
|
+
return len(situation.Data) == 0 && situation.Live == nil
|
|
1457
3010
|
}
|
|
1458
3011
|
|
|
1459
3012
|
func isLiveMatch(match Match) bool {
|