cricinfo-cli-go 0.1.2 → 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.
@@ -13,9 +13,16 @@ import (
13
13
  )
14
14
 
15
15
  const defaultMatchListLimit = 20
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
16
22
  const deliveryFetchConcurrency = 96
17
23
  const detailSubresourceFetchConcurrency = 24
18
24
  const detailItemFetchTimeout = 3 * time.Second
25
+ const liveViewRecentDeliveryFetchCount = 60
19
26
  const matchTeamQueryScanRange = 6
20
27
  const maxTeamQueryEventCandidates = 36
21
28
  const teamQueryEventFetchTimeout = 1500 * time.Millisecond
@@ -45,6 +52,13 @@ type MatchInningsOptions struct {
45
52
  Period int
46
53
  }
47
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
+
48
62
  // MatchService implements domain-level match discovery and lookup commands.
49
63
  type MatchService struct {
50
64
  client *Client
@@ -99,6 +113,124 @@ func (s *MatchService) Live(ctx context.Context, opts MatchListOptions) (Normali
99
113
  return s.listFromEvents(ctx, opts, true)
100
114
  }
101
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
+
102
234
  // Show resolves and returns one match with normalized summary fields.
103
235
  func (s *MatchService) Show(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
104
236
  return s.lookupMatch(ctx, query, opts, false)
@@ -116,6 +248,10 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
116
248
  passthrough.Kind = EntityMatchScorecard
117
249
  return *passthrough, nil
118
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)
119
255
 
120
256
  scorecardRef := matchSubresourceRef(*lookup.match, "matchcards", "matchcards")
121
257
  if scorecardRef == "" {
@@ -126,8 +262,26 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
126
262
  }, nil
127
263
  }
128
264
 
129
- resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
265
+ resolved, err := s.resolveRefChainResilient(ctx, scorecardRef)
130
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
+ }
131
285
  return NewTransportErrorResult(EntityMatchScorecard, scorecardRef, err), nil
132
286
  }
133
287
 
@@ -135,8 +289,17 @@ func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLo
135
289
  if err != nil {
136
290
  return NormalizedResult{}, fmt.Errorf("normalize matchcards %q: %w", resolved.CanonicalRef, err)
137
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
+ }
138
299
 
139
300
  warnings := append([]string{}, lookup.warnings...)
301
+ warnings = append(warnings, hydrationWarnings...)
302
+ warnings = append(warnings, enrichmentWarnings...)
140
303
  result := NewDataResult(EntityMatchScorecard, scorecard)
141
304
  if len(warnings) > 0 {
142
305
  result = NewPartialResult(EntityMatchScorecard, scorecard, warnings...)
@@ -163,7 +326,23 @@ func (s *MatchService) Details(ctx context.Context, query string, opts MatchLook
163
326
  }, nil
164
327
  }
165
328
 
166
- 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
167
346
  }
168
347
 
169
348
  // Plays resolves and returns normalized delivery events from the plays route.
@@ -183,7 +362,23 @@ func (s *MatchService) Plays(ctx context.Context, query string, opts MatchLookup
183
362
  }, nil
184
363
  }
185
364
 
186
- 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
187
382
  }
188
383
 
189
384
  // Situation resolves and returns normalized match situation data.
@@ -194,6 +389,11 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
194
389
  return *passthrough, nil
195
390
  }
196
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
+
197
397
  situationRef := matchSubresourceRef(*lookup.match, "situation", "situation")
198
398
  if situationRef == "" {
199
399
  return NormalizedResult{
@@ -214,6 +414,19 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
214
414
  }
215
415
 
216
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
+ }
217
430
  result := NormalizedResult{
218
431
  Kind: EntityMatchSituation,
219
432
  Status: ResultStatusEmpty,
@@ -225,14 +438,211 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
225
438
  }
226
439
 
227
440
  result := NewDataResult(EntityMatchSituation, situation)
228
- if len(lookup.warnings) > 0 {
229
- 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...)
230
445
  }
231
446
  result.RequestedRef = resolved.RequestedRef
232
447
  result.CanonicalRef = resolved.CanonicalRef
233
448
  return result, nil
234
449
  }
235
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
+
236
646
  // Phases resolves and returns fan-oriented innings phase splits (powerplay/middle/death).
237
647
  func (s *MatchService) Phases(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
238
648
  lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
@@ -590,31 +1000,30 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
590
1000
  limit = defaultMatchListLimit
591
1001
  }
592
1002
 
593
- statusCache := map[string]matchStatusSnapshot{}
594
-
595
1003
  matches := make([]Match, 0, limit)
596
1004
  warnings := make([]string, 0)
597
- for _, eventRef := range page.Items {
598
- 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 {
599
1010
  break
600
1011
  }
601
1012
 
602
- eventMatches, eventWarnings, eventErr := s.matchesFromEventRef(ctx, eventRef.URL)
603
- if eventErr != nil {
604
- 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))
605
1015
  continue
606
1016
  }
607
- warnings = append(warnings, eventWarnings...)
1017
+ warnings = append(warnings, eventResult.warnings...)
608
1018
 
609
- for _, eventMatch := range eventMatches {
610
- match := eventMatch
1019
+ for i := range eventResult.matches {
1020
+ match := eventResult.matches[i]
611
1021
  s.enrichMatchTeamsFromIndex(&match)
612
- if liveOnly && !isLiveMatch(match) {
613
- warnings = append(warnings, s.hydrateMatchStatusOnly(ctx, &match, statusCache)...)
614
- }
615
- if liveOnly && !isLiveMatch(match) {
1022
+ if liveOnly {
1023
+ candidates = append(candidates, &match)
616
1024
  continue
617
1025
  }
1026
+
618
1027
  match.ScoreSummary = matchScoreSummary(match.Teams)
619
1028
  matches = append(matches, match)
620
1029
  if len(matches) >= limit {
@@ -623,6 +1032,20 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
623
1032
  }
624
1033
  }
625
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
+
626
1049
  items := make([]any, 0, len(matches))
627
1050
  for i := range matches {
628
1051
  items = append(items, matches[i])
@@ -637,6 +1060,86 @@ func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions
637
1060
  return result, nil
638
1061
  }
639
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
+
640
1143
  func (s *MatchService) lookupMatch(ctx context.Context, query string, opts MatchLookupOptions, statusOnly bool) (NormalizedResult, error) {
641
1144
  lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
642
1145
  if passthrough != nil {
@@ -718,7 +1221,7 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
718
1221
  return nil, &result
719
1222
  }
720
1223
 
721
- resolved, err := s.client.ResolveRefChain(ctx, ref)
1224
+ resolved, err := s.resolveRefChainResilient(ctx, ref)
722
1225
  if err != nil {
723
1226
  result := NewTransportErrorResult(EntityMatch, ref, err)
724
1227
  return nil, &result
@@ -1132,12 +1635,153 @@ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]De
1132
1635
  continue
1133
1636
  }
1134
1637
  if result.delivery != nil {
1135
- deliveries = append(deliveries, *result.delivery)
1638
+ if isRenderableDelivery(*result.delivery) {
1639
+ deliveries = append(deliveries, *result.delivery)
1640
+ }
1136
1641
  }
1137
1642
  }
1138
1643
  return deliveries, compactWarnings(warnings)
1139
1644
  }
1140
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
+
1141
1785
  type selectedInningsContext struct {
1142
1786
  match Match
1143
1787
  team Team
@@ -1988,11 +2632,381 @@ func extensionRef(extensions map[string]any, key string) string {
1988
2632
  return strings.TrimSpace(stringField(refMap, "$ref"))
1989
2633
  }
1990
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
+
1991
3005
  func isSparseSituation(situation *MatchSituation) bool {
1992
3006
  if situation == nil {
1993
3007
  return true
1994
3008
  }
1995
- return len(situation.Data) == 0
3009
+ return len(situation.Data) == 0 && situation.Live == nil
1996
3010
  }
1997
3011
 
1998
3012
  func isLiveMatch(match Match) bool {