cricinfo-cli-go 0.1.0 → 0.1.2

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.
@@ -19,6 +19,7 @@ type matchCommandService interface {
19
19
  Details(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
20
20
  Plays(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
21
21
  Situation(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
22
+ Phases(ctx context.Context, query string, opts cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error)
22
23
  Innings(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
23
24
  Partnerships(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
24
25
  FallOfWicket(ctx context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error)
@@ -77,7 +78,10 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
77
78
  Args: cobra.NoArgs,
78
79
  RunE: func(cmd *cobra.Command, _ []string) error {
79
80
  return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
80
- return service.List(ctx, cricinfo.MatchListOptions{Limit: opts.limit})
81
+ return service.List(ctx, cricinfo.MatchListOptions{
82
+ Limit: opts.limit,
83
+ LeagueID: opts.leagueID,
84
+ })
81
85
  })
82
86
  },
83
87
  }
@@ -98,7 +102,10 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
98
102
  Args: cobra.NoArgs,
99
103
  RunE: func(cmd *cobra.Command, _ []string) error {
100
104
  return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
101
- return service.Live(ctx, cricinfo.MatchListOptions{Limit: opts.limit})
105
+ return service.Live(ctx, cricinfo.MatchListOptions{
106
+ Limit: opts.limit,
107
+ LeagueID: opts.leagueID,
108
+ })
102
109
  })
103
110
  },
104
111
  }
@@ -224,6 +231,26 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
224
231
  },
225
232
  }
226
233
 
234
+ phasesCmd := &cobra.Command{
235
+ Use: "phases <match>",
236
+ Short: "Show powerplay, middle, and death-over phase splits for each innings",
237
+ Long: strings.Join([]string{
238
+ "Resolve a match and show fan-friendly phase splits (powerplay/middle/death) with momentum markers.",
239
+ "",
240
+ "Next steps:",
241
+ " cricinfo matches scorecard <match>",
242
+ " cricinfo matches deliveries <match> --team <team> --innings <n> --period <n>",
243
+ " cricinfo matches fow <match> --team <team> --innings <n> --period <n>",
244
+ }, "\n"),
245
+ Args: cobra.MinimumNArgs(1),
246
+ RunE: func(cmd *cobra.Command, args []string) error {
247
+ query := strings.TrimSpace(strings.Join(args, " "))
248
+ return runMatchCommand(cmd, global, func(ctx context.Context, service matchCommandService) (cricinfo.NormalizedResult, error) {
249
+ return service.Phases(ctx, query, cricinfo.MatchLookupOptions{LeagueID: opts.leagueID})
250
+ })
251
+ },
252
+ }
253
+
227
254
  inningsCmd := &cobra.Command{
228
255
  Use: "innings <match>",
229
256
  Short: "Show innings summaries with over and wicket timelines",
@@ -369,6 +396,7 @@ func newMatchesCommand(global *globalOptions) *cobra.Command {
369
396
  detailsCmd,
370
397
  playsCmd,
371
398
  situationCmd,
399
+ phasesCmd,
372
400
  inningsCmd,
373
401
  partnershipsCmd,
374
402
  fowCmd,
@@ -19,6 +19,7 @@ type fakeMatchService struct {
19
19
  detailsResult cricinfo.NormalizedResult
20
20
  playsResult cricinfo.NormalizedResult
21
21
  situationResult cricinfo.NormalizedResult
22
+ phasesResult cricinfo.NormalizedResult
22
23
  inningsResult cricinfo.NormalizedResult
23
24
  partnerships cricinfo.NormalizedResult
24
25
  fowResult cricinfo.NormalizedResult
@@ -68,6 +69,10 @@ func (f *fakeMatchService) Situation(context.Context, string, cricinfo.MatchLook
68
69
  return f.situationResult, nil
69
70
  }
70
71
 
72
+ func (f *fakeMatchService) Phases(context.Context, string, cricinfo.MatchLookupOptions) (cricinfo.NormalizedResult, error) {
73
+ return f.phasesResult, nil
74
+ }
75
+
71
76
  func (f *fakeMatchService) Innings(_ context.Context, query string, opts cricinfo.MatchInningsOptions) (cricinfo.NormalizedResult, error) {
72
77
  f.inningsQueries = append(f.inningsQueries, query)
73
78
  f.inningsOpts = append(f.inningsOpts, opts)
@@ -150,6 +155,12 @@ func TestMatchesCommandsRenderTextAndJSON(t *testing.T) {
150
155
  detailsResult: cricinfo.NewListResult(cricinfo.EntityDeliveryEvent, []any{delivery}),
151
156
  playsResult: cricinfo.NewListResult(cricinfo.EntityDeliveryEvent, []any{delivery}),
152
157
  situationResult: cricinfo.NewDataResult(cricinfo.EntityMatchSituation, situation),
158
+ phasesResult: cricinfo.NewDataResult(cricinfo.EntityMatchPhases, cricinfo.MatchPhases{
159
+ MatchID: "1529474",
160
+ Innings: []cricinfo.MatchPhaseInning{
161
+ {TeamName: "BOOST", InningsNumber: 1, Period: 3, Score: "69/2 (19 ov)"},
162
+ },
163
+ }),
153
164
  inningsResult: cricinfo.NewListResult(cricinfo.EntityInnings, []any{
154
165
  cricinfo.Innings{
155
166
  TeamName: "BOOST",
@@ -467,54 +467,62 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
467
467
 
468
468
  agg := map[string]*analysisAggregate{}
469
469
  warnings := append([]string{}, run.warnings...)
470
- for _, match := range run.session.ScopedMatches() {
471
- seasonID := seasonForMatch(match, run.seasonHint)
472
- players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
473
- if hydrateErr != nil {
474
- if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
475
- return *statusErr, nil
476
- }
477
- warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
478
- continue
470
+ if run.mode == analysisScopeMatch {
471
+ if fastAgg, fastWarnings, used := s.hydrateMatchScopeBattingFromScorecard(ctx, run, filters, groupBy); used {
472
+ agg = fastAgg
473
+ warnings = append(warnings, fastWarnings...)
479
474
  }
480
- warnings = append(warnings, playerWarnings...)
481
-
482
- for _, player := range players {
483
- totals := extractBattingTotals(player)
484
- playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
485
- teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
486
- row := analysisSourceRow{
487
- MatchID: strings.TrimSpace(player.MatchID),
488
- LeagueID: strings.TrimSpace(player.LeagueID),
489
- SeasonID: seasonID,
490
- TeamID: strings.TrimSpace(player.TeamID),
491
- TeamName: strings.TrimSpace(teamName),
492
- PlayerID: strings.TrimSpace(player.PlayerID),
493
- PlayerName: strings.TrimSpace(playerName),
494
- CountValue: 1,
495
- Fours: totals.fours,
496
- BattingSixes: totals.sixes,
497
- RunsScored: totals.runs,
498
- BallsFaced: totals.balls,
499
- StrikeSample: totals.strikeRate,
500
- }
501
- if !filters.matches(row) {
475
+ }
476
+ if len(agg) == 0 {
477
+ for _, match := range run.session.ScopedMatches() {
478
+ seasonID := seasonForMatch(match, run.seasonHint)
479
+ players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
480
+ if hydrateErr != nil {
481
+ if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
482
+ return *statusErr, nil
483
+ }
484
+ warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
502
485
  continue
503
486
  }
504
- key, dims := buildAnalysisGroup(row, groupBy)
505
- entry := agg[key]
506
- if entry == nil {
507
- entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
508
- agg[key] = entry
509
- }
510
- entry.matchIDs[row.MatchID] = struct{}{}
511
- entry.fours += row.Fours
512
- entry.battingSixes += row.BattingSixes
513
- entry.runsScored += row.RunsScored
514
- entry.ballsFaced += row.BallsFaced
515
- if row.StrikeSample > 0 {
516
- entry.strikeRateTotal += row.StrikeSample
517
- entry.strikeRateCount++
487
+ warnings = append(warnings, playerWarnings...)
488
+
489
+ for _, player := range players {
490
+ totals := extractBattingTotals(player)
491
+ playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
492
+ teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
493
+ row := analysisSourceRow{
494
+ MatchID: strings.TrimSpace(player.MatchID),
495
+ LeagueID: strings.TrimSpace(player.LeagueID),
496
+ SeasonID: seasonID,
497
+ TeamID: strings.TrimSpace(player.TeamID),
498
+ TeamName: strings.TrimSpace(teamName),
499
+ PlayerID: strings.TrimSpace(player.PlayerID),
500
+ PlayerName: strings.TrimSpace(playerName),
501
+ CountValue: 1,
502
+ Fours: totals.fours,
503
+ BattingSixes: totals.sixes,
504
+ RunsScored: totals.runs,
505
+ BallsFaced: totals.balls,
506
+ StrikeSample: totals.strikeRate,
507
+ }
508
+ if !filters.matches(row) {
509
+ continue
510
+ }
511
+ key, dims := buildAnalysisGroup(row, groupBy)
512
+ entry := agg[key]
513
+ if entry == nil {
514
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
515
+ agg[key] = entry
516
+ }
517
+ entry.matchIDs[row.MatchID] = struct{}{}
518
+ entry.fours += row.Fours
519
+ entry.battingSixes += row.BattingSixes
520
+ entry.runsScored += row.RunsScored
521
+ entry.ballsFaced += row.BallsFaced
522
+ if row.StrikeSample > 0 {
523
+ entry.strikeRateTotal += row.StrikeSample
524
+ entry.strikeRateCount++
525
+ }
518
526
  }
519
527
  }
520
528
  }
@@ -559,6 +567,133 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
559
567
  return analysisResult(EntityAnalysisBat, view, warnings), nil
560
568
  }
561
569
 
570
+ func (s *AnalysisService) hydrateMatchScopeBattingFromScorecard(
571
+ ctx context.Context,
572
+ run *analysisScopeRun,
573
+ filters analysisFilterSpec,
574
+ groupBy []string,
575
+ ) (map[string]*analysisAggregate, []string, bool) {
576
+ if run == nil || run.session == nil {
577
+ return nil, nil, false
578
+ }
579
+ matches := run.session.ScopedMatches()
580
+ if len(matches) != 1 {
581
+ return nil, nil, false
582
+ }
583
+
584
+ match := matches[0]
585
+ scorecardRef := matchSubresourceRef(match, "matchcards", "matchcards")
586
+ if strings.TrimSpace(scorecardRef) == "" {
587
+ return nil, nil, false
588
+ }
589
+
590
+ resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
591
+ if err != nil {
592
+ return nil, nil, false
593
+ }
594
+ scorecard, err := NormalizeMatchScorecard(resolved.Body, match)
595
+ if err != nil {
596
+ return nil, nil, false
597
+ }
598
+
599
+ teamIDByAlias := map[string]string{}
600
+ playerNameByID := map[string]string{}
601
+ for _, team := range match.Teams {
602
+ teamID := strings.TrimSpace(team.ID)
603
+ for _, raw := range []string{team.Name, team.ShortName, team.Abbreviation} {
604
+ alias := normalizeAlias(raw)
605
+ if alias == "" {
606
+ continue
607
+ }
608
+ if _, exists := teamIDByAlias[alias]; !exists {
609
+ teamIDByAlias[alias] = teamID
610
+ }
611
+ }
612
+
613
+ rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
614
+ if strings.TrimSpace(rosterRef) == "" {
615
+ continue
616
+ }
617
+ rosterDoc, rosterErr := s.client.ResolveRefChain(ctx, rosterRef)
618
+ if rosterErr != nil {
619
+ continue
620
+ }
621
+ entries, normalizeErr := NormalizeTeamRosterEntries(rosterDoc.Body, team, TeamScopeMatch, match.ID)
622
+ if normalizeErr != nil {
623
+ continue
624
+ }
625
+ for _, entry := range entries {
626
+ playerID := strings.TrimSpace(entry.PlayerID)
627
+ playerName := strings.TrimSpace(entry.DisplayName)
628
+ if playerID == "" || playerName == "" {
629
+ continue
630
+ }
631
+ if _, exists := playerNameByID[playerID]; !exists {
632
+ playerNameByID[playerID] = playerName
633
+ }
634
+ }
635
+ }
636
+
637
+ seasonID := seasonForMatch(match, run.seasonHint)
638
+ agg := map[string]*analysisAggregate{}
639
+ for _, card := range scorecard.BattingCards {
640
+ teamName := strings.TrimSpace(card.TeamName)
641
+ teamID := teamIDByAlias[normalizeAlias(teamName)]
642
+ teamName = analysisDisplayTeamName(s.resolver, teamID, teamName)
643
+
644
+ for _, player := range card.Players {
645
+ runs := analysisNumericString(player.Runs)
646
+ balls := analysisNumericString(player.BallsFaced)
647
+ fours := analysisNumericString(player.Fours)
648
+ sixes := analysisNumericString(player.Sixes)
649
+ strikeRate := 0.0
650
+ if balls > 0 {
651
+ strikeRate = (float64(runs) * 100.0) / float64(balls)
652
+ }
653
+
654
+ row := analysisSourceRow{
655
+ MatchID: strings.TrimSpace(match.ID),
656
+ LeagueID: strings.TrimSpace(nonEmpty(match.LeagueID, run.scope.League.ID)),
657
+ SeasonID: seasonID,
658
+ TeamID: teamID,
659
+ TeamName: teamName,
660
+ PlayerID: strings.TrimSpace(player.PlayerID),
661
+ PlayerName: analysisDisplayPlayerName(
662
+ s.resolver,
663
+ player.PlayerID,
664
+ nonEmpty(playerNameByID[strings.TrimSpace(player.PlayerID)], player.PlayerName),
665
+ ),
666
+ CountValue: 1,
667
+ Fours: fours,
668
+ BattingSixes: sixes,
669
+ RunsScored: runs,
670
+ BallsFaced: balls,
671
+ StrikeSample: strikeRate,
672
+ }
673
+ if !filters.matches(row) {
674
+ continue
675
+ }
676
+
677
+ key, dims := buildAnalysisGroup(row, groupBy)
678
+ entry := agg[key]
679
+ if entry == nil {
680
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
681
+ agg[key] = entry
682
+ }
683
+ entry.matchIDs[row.MatchID] = struct{}{}
684
+ entry.fours += row.Fours
685
+ entry.battingSixes += row.BattingSixes
686
+ entry.runsScored += row.RunsScored
687
+ entry.ballsFaced += row.BallsFaced
688
+ if row.StrikeSample > 0 {
689
+ entry.strikeRateTotal += row.StrikeSample
690
+ entry.strikeRateCount++
691
+ }
692
+ }
693
+ }
694
+ return agg, nil, true
695
+ }
696
+
562
697
  // Partnerships ranks partnerships over match or season scope.
563
698
  func (s *AnalysisService) Partnerships(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
564
699
  groupBy, err := parseGroupBy(opts.GroupBy, []string{"innings"}, analysisGroupPartnershipAllowed)
@@ -1191,7 +1326,7 @@ type battingTotals struct {
1191
1326
  }
1192
1327
 
1193
1328
  func extractBattingTotals(player PlayerMatch) battingTotals {
1194
- totals := battingTotals{strikeRate: player.Summary.StrikeRate, balls: player.Summary.BallsFaced}
1329
+ totals := battingTotals{strikeRate: player.Summary.StrikeRate}
1195
1330
  for _, category := range player.Batting {
1196
1331
  for _, stat := range category.Stats {
1197
1332
  switch normalizeStatName(stat.Name) {
@@ -1210,6 +1345,12 @@ func extractBattingTotals(player PlayerMatch) battingTotals {
1210
1345
  }
1211
1346
  }
1212
1347
  }
1348
+
1349
+ // Batting summary fields are frequently derived from the same stats payload.
1350
+ // Prefer the larger value rather than summing to avoid duplicate counting.
1351
+ if player.Summary.BallsFaced > 0 {
1352
+ totals.balls = analysisMaxInt(totals.balls, player.Summary.BallsFaced)
1353
+ }
1213
1354
  return totals
1214
1355
  }
1215
1356
 
@@ -1267,6 +1408,35 @@ func analysisMaxInt(a, b int) int {
1267
1408
  return b
1268
1409
  }
1269
1410
 
1411
+ func analysisNumericString(raw string) int {
1412
+ raw = strings.TrimSpace(raw)
1413
+ if raw == "" {
1414
+ return 0
1415
+ }
1416
+ start := -1
1417
+ end := -1
1418
+ for i, r := range raw {
1419
+ if r >= '0' && r <= '9' {
1420
+ if start == -1 {
1421
+ start = i
1422
+ }
1423
+ end = i + 1
1424
+ continue
1425
+ }
1426
+ if start != -1 {
1427
+ break
1428
+ }
1429
+ }
1430
+ if start == -1 || end == -1 {
1431
+ return 0
1432
+ }
1433
+ value, err := strconv.Atoi(raw[start:end])
1434
+ if err != nil {
1435
+ return 0
1436
+ }
1437
+ return value
1438
+ }
1439
+
1270
1440
  func analysisDisplayPlayerName(resolver *Resolver, playerID, fallback string) string {
1271
1441
  name := strings.TrimSpace(fallback)
1272
1442
  if name != "" {
@@ -203,6 +203,44 @@ func TestPhase15BowlingActivityFilterSkipsNonBowlers(t *testing.T) {
203
203
  }
204
204
  }
205
205
 
206
+ func TestPhase15ExtractBattingTotalsDoesNotDoubleCountSummaryBalls(t *testing.T) {
207
+ t.Parallel()
208
+
209
+ player := PlayerMatch{
210
+ PlayerID: "1408688",
211
+ PlayerName: "Vaibhav Sooryavanshi",
212
+ Batting: []StatCategory{
213
+ {
214
+ Name: "general",
215
+ Stats: []StatValue{
216
+ {Name: "runs", Value: "78"},
217
+ {Name: "ballsFaced", Value: "26"},
218
+ {Name: "fours", Value: "8"},
219
+ {Name: "sixes", Value: "7"},
220
+ {Name: "strikeRate", Value: "300"},
221
+ },
222
+ },
223
+ },
224
+ Summary: PlayerMatchSummary{
225
+ BallsFaced: 26,
226
+ StrikeRate: 300,
227
+ },
228
+ }
229
+
230
+ totals := extractBattingTotals(player)
231
+ if totals.runs != 78 || totals.balls != 26 {
232
+ t.Fatalf("expected 78/26 batting totals, got runs=%d balls=%d", totals.runs, totals.balls)
233
+ }
234
+
235
+ agg := &analysisAggregate{
236
+ runsScored: totals.runs,
237
+ ballsFaced: totals.balls,
238
+ }
239
+ if got := strikeRateFromAggregate(agg); got != 300 {
240
+ t.Fatalf("expected strike rate 300, got %.2f", got)
241
+ }
242
+ }
243
+
206
244
  func TestLivePhase15SmallHistoricalScope(t *testing.T) {
207
245
  t.Parallel()
208
246
  requireLiveMatrix(t)
@@ -6,6 +6,7 @@ import (
6
6
  "sort"
7
7
  "strconv"
8
8
  "strings"
9
+ "sync"
9
10
  "time"
10
11
  )
11
12
 
@@ -133,6 +134,8 @@ type HistoricalScopeSession struct {
133
134
  partnershipWarnByMatch map[string][]string
134
135
 
135
136
  metrics HydrationMetrics
137
+
138
+ resolveMu sync.Mutex
136
139
  }
137
140
 
138
141
  func newHistoricalScopeSession(client *Client, resolver *Resolver, opts HistoricalScopeOptions) *HistoricalScopeSession {
@@ -292,52 +295,78 @@ func (s *HistoricalScopeSession) HydratePlayerMatchSummaries(ctx context.Context
292
295
  continue
293
296
  }
294
297
 
295
- for _, entry := range entries {
296
- playerID := strings.TrimSpace(entry.PlayerID)
297
- if playerID == "" {
298
- continue
299
- }
300
- if strings.TrimSpace(entry.DisplayName) == "" && s.resolver != nil {
301
- _ = s.resolver.seedPlayerByID(ctx, playerID, match.LeagueID, match.ID)
302
- }
303
- entry = s.enrichRosterEntryFromIndex(entry)
298
+ type playerHydrationResult struct {
299
+ item *PlayerMatch
300
+ warning string
301
+ }
302
+ results := make([]playerHydrationResult, len(entries))
303
+ sem := make(chan struct{}, detailSubresourceFetchConcurrency)
304
+ var wg sync.WaitGroup
305
+ for i, entry := range entries {
306
+ wg.Add(1)
307
+ go func(index int, entry TeamRosterEntry, team Team) {
308
+ defer wg.Done()
309
+ sem <- struct{}{}
310
+ defer func() { <-sem }()
304
311
 
305
- statsRef := rosterPlayerStatisticsRef(match, team, entry)
306
- if statsRef == "" {
307
- warnings = append(warnings, fmt.Sprintf("player %s has no match statistics route", playerID))
308
- continue
309
- }
312
+ playerID := strings.TrimSpace(entry.PlayerID)
313
+ if playerID == "" {
314
+ return
315
+ }
316
+ if strings.TrimSpace(entry.DisplayName) == "" && s.resolver != nil {
317
+ _ = s.resolver.seedPlayerByID(ctx, playerID, match.LeagueID, match.ID)
318
+ }
319
+ entry = s.enrichRosterEntryFromIndex(entry)
310
320
 
311
- statsDoc, err := s.resolve(ctx, statsRef)
312
- if err != nil {
313
- warnings = append(warnings, fmt.Sprintf("player statistics %s: %v", statsRef, err))
314
- continue
315
- }
321
+ statsRef := rosterPlayerStatisticsRef(match, team, entry)
322
+ if statsRef == "" {
323
+ results[index] = playerHydrationResult{warning: fmt.Sprintf("player %s has no match statistics route", playerID)}
324
+ return
325
+ }
316
326
 
317
- categories, err := NormalizeStatCategories(statsDoc.Body)
318
- if err != nil {
319
- warnings = append(warnings, fmt.Sprintf("player statistics %s: %v", statsDoc.CanonicalRef, err))
327
+ statsDoc, err := s.resolve(ctx, statsRef)
328
+ if err != nil {
329
+ results[index] = playerHydrationResult{warning: fmt.Sprintf("player statistics %s: %v", statsRef, err)}
330
+ return
331
+ }
332
+
333
+ categories, err := NormalizeStatCategories(statsDoc.Body)
334
+ if err != nil {
335
+ results[index] = playerHydrationResult{warning: fmt.Sprintf("player statistics %s: %v", statsDoc.CanonicalRef, err)}
336
+ return
337
+ }
338
+
339
+ batting, bowling, fielding := splitPlayerStatCategories(categories)
340
+ playerMatch := PlayerMatch{
341
+ PlayerID: playerID,
342
+ PlayerRef: entry.PlayerRef,
343
+ PlayerName: nonEmpty(entry.DisplayName, "Unknown Player"),
344
+ MatchID: match.ID,
345
+ CompetitionID: nonEmpty(match.CompetitionID, match.ID),
346
+ EventID: match.EventID,
347
+ LeagueID: match.LeagueID,
348
+ TeamID: team.ID,
349
+ TeamName: nonEmpty(team.ShortName, team.Name, "Unknown Team"),
350
+ StatisticsRef: statsDoc.CanonicalRef,
351
+ LinescoresRef: rosterPlayerLinescoresRef(match, team, entry),
352
+ Batting: batting,
353
+ Bowling: bowling,
354
+ Fielding: fielding,
355
+ Summary: summarizePlayerMatchCategories(categories),
356
+ }
357
+ results[index] = playerHydrationResult{item: &playerMatch}
358
+ }(i, entry, team)
359
+ }
360
+ wg.Wait()
361
+
362
+ for _, result := range results {
363
+ if strings.TrimSpace(result.warning) != "" {
364
+ warnings = append(warnings, result.warning)
320
365
  continue
321
366
  }
322
-
323
- batting, bowling, fielding := splitPlayerStatCategories(categories)
324
- items = append(items, PlayerMatch{
325
- PlayerID: playerID,
326
- PlayerRef: entry.PlayerRef,
327
- PlayerName: nonEmpty(entry.DisplayName, "Unknown Player"),
328
- MatchID: match.ID,
329
- CompetitionID: nonEmpty(match.CompetitionID, match.ID),
330
- EventID: match.EventID,
331
- LeagueID: match.LeagueID,
332
- TeamID: team.ID,
333
- TeamName: nonEmpty(team.ShortName, team.Name, "Unknown Team"),
334
- StatisticsRef: statsDoc.CanonicalRef,
335
- LinescoresRef: rosterPlayerLinescoresRef(match, team, entry),
336
- Batting: batting,
337
- Bowling: bowling,
338
- Fielding: fielding,
339
- Summary: summarizePlayerMatchCategories(categories),
340
- })
367
+ if result.item != nil {
368
+ items = append(items, *result.item)
369
+ }
341
370
  }
342
371
  }
343
372
 
@@ -1397,23 +1426,34 @@ func (s *HistoricalScopeSession) resolve(ctx context.Context, ref string) (*Reso
1397
1426
  return nil, fmt.Errorf("ref is empty")
1398
1427
  }
1399
1428
 
1429
+ s.resolveMu.Lock()
1400
1430
  if cached, ok := s.resolvedDocs[ref]; ok {
1401
1431
  s.metrics.ResolveCacheHits++
1432
+ s.resolveMu.Unlock()
1402
1433
  return cached, nil
1403
1434
  }
1435
+ s.resolveMu.Unlock()
1404
1436
 
1405
1437
  resolved, err := s.client.ResolveRefChain(ctx, ref)
1406
1438
  if err != nil {
1407
1439
  return nil, err
1408
1440
  }
1409
- s.metrics.ResolveCacheMisses++
1410
1441
 
1411
1442
  copied := *resolved
1412
1443
  pointer := &copied
1413
1444
  keys := compactWarnings([]string{ref, copied.RequestedRef, copied.CanonicalRef})
1445
+
1446
+ s.resolveMu.Lock()
1447
+ if cached, ok := s.resolvedDocs[ref]; ok {
1448
+ s.metrics.ResolveCacheHits++
1449
+ s.resolveMu.Unlock()
1450
+ return cached, nil
1451
+ }
1452
+ s.metrics.ResolveCacheMisses++
1414
1453
  for _, key := range keys {
1415
1454
  s.resolvedDocs[key] = pointer
1416
1455
  }
1456
+ s.resolveMu.Unlock()
1417
1457
  return pointer, nil
1418
1458
  }
1419
1459