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.
@@ -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 deliveryFetchConcurrency = 12
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 int
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.client.ResolveRefChain(ctx, scorecardRef)
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
- return s.deliveryEventsFromRoute(ctx, detailsRef, lookup.warnings)
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
- return s.deliveryEventsFromRoute(ctx, playsRef, lookup.warnings)
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
- if len(lookup.warnings) > 0 {
221
- result = NewPartialResult(EntityMatchSituation, situation, lookup.warnings...)
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
- resolved, err := s.client.ResolveRefChain(ctx, "/events")
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, "/events", err), nil
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
- for _, eventRef := range page.Items {
440
- if len(matches) >= limit {
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
- eventMatches, eventWarnings, eventErr := s.matchesFromEventRef(ctx, eventRef.URL)
445
- if eventErr != nil {
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, eventWarnings...)
1017
+ warnings = append(warnings, eventResult.warnings...)
450
1018
 
451
- for _, eventMatch := range eventMatches {
452
- match := eventMatch
1019
+ for i := range eventResult.matches {
1020
+ match := eventResult.matches[i]
453
1021
  s.enrichMatchTeamsFromIndex(&match)
454
- if liveOnly && !isLiveMatch(match) {
455
- warnings = append(warnings, s.hydrateMatchStatusOnly(ctx, &match, statusCache)...)
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
- if len(searchResult.Entities) == 0 {
536
- result := NormalizedResult{
537
- Kind: EntityMatch,
538
- Status: ResultStatusEmpty,
539
- Message: fmt.Sprintf("no matches found for %q", query),
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
- return nil, &result
1488
+ if _, ok := seen[value]; ok {
1489
+ return
1490
+ }
1491
+ seen[value] = struct{}{}
1492
+ variants = append(variants, value)
542
1493
  }
543
1494
 
544
- entity := searchResult.Entities[0]
545
- ref := buildMatchRef(entity)
546
- if ref == "" {
547
- result := NormalizedResult{
548
- Kind: EntityMatch,
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
- resolved, err := s.client.ResolveRefChain(ctx, ref)
556
- if err != nil {
557
- result := NewTransportErrorResult(EntityMatch, ref, err)
558
- return nil, &result
1502
+ for _, alias := range knownIPLTeamAliases[base] {
1503
+ add(alias)
559
1504
  }
560
1505
 
561
- match, err := NormalizeMatch(resolved.Body)
562
- if err != nil {
563
- result := NormalizedResult{
564
- Kind: EntityMatch,
565
- Status: ResultStatusError,
566
- Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err),
567
- }
568
- return nil, &result
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
- return &matchLookup{
572
- match: match,
573
- resolved: resolved,
574
- warnings: searchResult.Warnings,
575
- }, nil
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
- itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
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
- deliveries = append(deliveries, *result.delivery)
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
- items := make([]any, 0, len(pageItems))
966
- for _, item := range pageItems {
967
- itemRef := strings.TrimSpace(item.URL)
968
- if itemRef == "" {
969
- warnings = append(warnings, "skip item with empty ref")
970
- continue
971
- }
2089
+ type normalizedItemResult struct {
2090
+ index int
2091
+ item any
2092
+ warning string
2093
+ }
972
2094
 
973
- itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
974
- if itemErr != nil {
975
- warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, itemErr))
976
- continue
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
- normalized, normalizeErr := normalize(itemResolved.Body)
980
- if normalizeErr != nil {
981
- warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, normalizeErr))
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
- items = append(items, normalized)
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
- warnings := make([]string, 0)
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
- pageRef := pagedRef(baseRef, pageIndex)
1008
- if pageRef == "" {
1009
- warnings = append(warnings, fmt.Sprintf("page %d unavailable for %s", pageIndex, baseRef))
1010
- continue
1011
- }
1012
- pageDoc, pageErr := s.resolveRefChainResilient(ctx, pageRef)
1013
- if pageErr != nil {
1014
- warnings = append(warnings, fmt.Sprintf("page %d %s: %v", pageIndex, pageRef, pageErr))
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
- nextPage, decodeErr := DecodePage[Ref](pageDoc.Body)
1018
- if decodeErr != nil {
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 {