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.
@@ -5,13 +5,20 @@ 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 deliveryFetchConcurrency = 96
17
+ const detailSubresourceFetchConcurrency = 24
18
+ const detailItemFetchTimeout = 3 * time.Second
19
+ const matchTeamQueryScanRange = 6
20
+ const maxTeamQueryEventCandidates = 36
21
+ const teamQueryEventFetchTimeout = 1500 * time.Millisecond
15
22
 
16
23
  // MatchServiceConfig configures match discovery and lookup behavior.
17
24
  type MatchServiceConfig struct {
@@ -21,7 +28,8 @@ type MatchServiceConfig struct {
21
28
 
22
29
  // MatchListOptions controls list/live traversal behavior.
23
30
  type MatchListOptions struct {
24
- Limit int
31
+ Limit int
32
+ LeagueID string
25
33
  }
26
34
 
27
35
  // MatchLookupOptions controls resolver-backed single match lookup.
@@ -225,6 +233,151 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
225
233
  return result, nil
226
234
  }
227
235
 
236
+ // Phases resolves and returns fan-oriented innings phase splits (powerplay/middle/death).
237
+ func (s *MatchService) Phases(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
238
+ lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
239
+ if passthrough != nil {
240
+ passthrough.Kind = EntityMatchPhases
241
+ return *passthrough, nil
242
+ }
243
+
244
+ statusCache := map[string]matchStatusSnapshot{}
245
+ teamCache := map[string]teamIdentity{}
246
+ scoreCache := map[string]string{}
247
+ warnings := append([]string{}, lookup.warnings...)
248
+ warnings = append(warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
249
+
250
+ teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, "", opts.LeagueID)
251
+ if teamResult != nil {
252
+ teamResult.Kind = EntityMatchPhases
253
+ return *teamResult, nil
254
+ }
255
+ warnings = append(warnings, teamWarnings...)
256
+
257
+ report := MatchPhases{
258
+ MatchID: lookup.match.ID,
259
+ LeagueID: lookup.match.LeagueID,
260
+ EventID: lookup.match.EventID,
261
+ CompetitionID: nonEmpty(lookup.match.CompetitionID, lookup.match.ID),
262
+ Fixture: nonEmpty(lookup.match.ShortDescription, lookup.match.Description),
263
+ Result: nonEmpty(lookup.match.MatchState, lookup.match.Note),
264
+ Innings: make([]MatchPhaseInning, 0),
265
+ }
266
+
267
+ for _, team := range teams {
268
+ inningsList, _, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
269
+ warnings = append(warnings, inningsWarnings...)
270
+ for i := range inningsList {
271
+ innings := inningsList[i]
272
+ statsWarnings := s.hydrateInningsTimelines(ctx, &innings)
273
+ warnings = append(warnings, statsWarnings...)
274
+ if !isMeaningfulPhaseInnings(innings) {
275
+ continue
276
+ }
277
+
278
+ phaseInnings := buildPhaseInnings(team, innings)
279
+ if !phaseInningsHasData(phaseInnings) {
280
+ continue
281
+ }
282
+ report.Innings = append(report.Innings, phaseInnings)
283
+ }
284
+ }
285
+
286
+ result := NewDataResult(EntityMatchPhases, report)
287
+ if len(warnings) > 0 {
288
+ result = NewPartialResult(EntityMatchPhases, report, compactWarnings(warnings)...)
289
+ }
290
+ result.RequestedRef = lookup.resolved.RequestedRef
291
+ result.CanonicalRef = lookup.resolved.CanonicalRef
292
+ return result, nil
293
+ }
294
+
295
+ func isMeaningfulPhaseInnings(innings Innings) bool {
296
+ if strings.TrimSpace(innings.Score) != "" {
297
+ return true
298
+ }
299
+ if innings.Runs > 0 || innings.Wickets > 0 || innings.Target > 0 {
300
+ return true
301
+ }
302
+ return len(innings.OverTimeline) > 0 || len(innings.WicketTimeline) > 0
303
+ }
304
+
305
+ func buildPhaseInnings(team Team, innings Innings) MatchPhaseInning {
306
+ out := MatchPhaseInning{
307
+ TeamID: nonEmpty(strings.TrimSpace(team.ID), strings.TrimSpace(innings.TeamID)),
308
+ TeamName: nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), strings.TrimSpace(innings.TeamName), strings.TrimSpace(innings.TeamID)),
309
+ InningsNumber: innings.InningsNumber,
310
+ Period: innings.Period,
311
+ Score: innings.Score,
312
+ Target: innings.Target,
313
+ Powerplay: PhaseSummary{Name: "Powerplay"},
314
+ Middle: PhaseSummary{Name: "Middle"},
315
+ Death: PhaseSummary{Name: "Death"},
316
+ }
317
+
318
+ bestRuns := -1
319
+ for _, over := range innings.OverTimeline {
320
+ phase := phaseBucket(over.Number)
321
+ switch phase {
322
+ case "Powerplay":
323
+ accumulatePhase(&out.Powerplay, over)
324
+ case "Middle":
325
+ accumulatePhase(&out.Middle, over)
326
+ case "Death":
327
+ accumulatePhase(&out.Death, over)
328
+ }
329
+
330
+ if over.Runs > bestRuns {
331
+ bestRuns = over.Runs
332
+ out.BestScoringOver = over.Number
333
+ out.BestScoringOverRuns = over.Runs
334
+ }
335
+ if over.WicketCount > out.CollapseWickets {
336
+ out.CollapseWickets = over.WicketCount
337
+ out.CollapseOver = over.Number
338
+ }
339
+ }
340
+
341
+ finalizePhase(&out.Powerplay)
342
+ finalizePhase(&out.Middle)
343
+ finalizePhase(&out.Death)
344
+
345
+ return out
346
+ }
347
+
348
+ func phaseBucket(overNumber int) string {
349
+ switch {
350
+ case overNumber >= 1 && overNumber <= 6:
351
+ return "Powerplay"
352
+ case overNumber >= 7 && overNumber <= 15:
353
+ return "Middle"
354
+ case overNumber >= 16:
355
+ return "Death"
356
+ default:
357
+ return "Middle"
358
+ }
359
+ }
360
+
361
+ func accumulatePhase(phase *PhaseSummary, over InningsOver) {
362
+ if phase == nil {
363
+ return
364
+ }
365
+ phase.Runs += over.Runs
366
+ phase.Wickets += over.WicketCount
367
+ phase.Overs += 1
368
+ }
369
+
370
+ func finalizePhase(phase *PhaseSummary) {
371
+ if phase == nil || phase.Overs <= 0 {
372
+ return
373
+ }
374
+ phase.RunRate = float64(phase.Runs) / phase.Overs
375
+ }
376
+
377
+ func phaseInningsHasData(innings MatchPhaseInning) bool {
378
+ return innings.Powerplay.Overs > 0 || innings.Middle.Overs > 0 || innings.Death.Overs > 0
379
+ }
380
+
228
381
  // Innings resolves and returns innings summaries with over and wicket timelines when period statistics are available.
229
382
  func (s *MatchService) Innings(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
230
383
  lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
@@ -417,9 +570,14 @@ func (s *MatchService) Deliveries(ctx context.Context, query string, opts MatchI
417
570
  }
418
571
 
419
572
  func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions, liveOnly bool) (NormalizedResult, error) {
420
- resolved, err := s.client.ResolveRefChain(ctx, "/events")
573
+ rootRef := "/events"
574
+ if leagueID := strings.TrimSpace(opts.LeagueID); leagueID != "" {
575
+ rootRef = "/leagues/" + leagueID + "/events"
576
+ }
577
+
578
+ resolved, err := s.client.ResolveRefChain(ctx, rootRef)
421
579
  if err != nil {
422
- return NewTransportErrorResult(EntityMatch, "/events", err), nil
580
+ return NewTransportErrorResult(EntityMatch, rootRef, err), nil
423
581
  }
424
582
 
425
583
  page, err := DecodePage[Ref](resolved.Body)
@@ -532,16 +690,24 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
532
690
  result := NewTransportErrorResult(EntityMatch, query, err)
533
691
  return nil, &result
534
692
  }
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),
693
+ warnings := append([]string{}, searchResult.Warnings...)
694
+ entity := IndexedEntity{}
695
+ if len(searchResult.Entities) > 0 {
696
+ entity = searchResult.Entities[0]
697
+ } else {
698
+ discovered, discoveryWarnings := s.discoverMatchByTeamQuery(ctx, query, strings.TrimSpace(opts.LeagueID))
699
+ warnings = append(warnings, discoveryWarnings...)
700
+ if discovered == nil {
701
+ result := NormalizedResult{
702
+ Kind: EntityMatch,
703
+ Status: ResultStatusEmpty,
704
+ Message: fmt.Sprintf("no matches found for %q", query),
705
+ }
706
+ return nil, &result
540
707
  }
541
- return nil, &result
708
+ entity = *discovered
542
709
  }
543
710
 
544
- entity := searchResult.Entities[0]
545
711
  ref := buildMatchRef(entity)
546
712
  if ref == "" {
547
713
  result := NormalizedResult{
@@ -571,10 +737,322 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
571
737
  return &matchLookup{
572
738
  match: match,
573
739
  resolved: resolved,
574
- warnings: searchResult.Warnings,
740
+ warnings: compactWarnings(warnings),
575
741
  }, nil
576
742
  }
577
743
 
744
+ func (s *MatchService) discoverMatchByTeamQuery(ctx context.Context, query, leagueID string) (*IndexedEntity, []string) {
745
+ left, right, ok := parseTeamVsQuery(query)
746
+ if !ok {
747
+ return nil, nil
748
+ }
749
+
750
+ preferredLeagueID := inferTeamQueryLeagueHint(left, right)
751
+ candidates, err := s.buildTeamQueryEventCandidates(ctx, leagueID, preferredLeagueID)
752
+ if err != nil {
753
+ return nil, []string{fmt.Sprintf("team-query fallback unavailable: %v", err)}
754
+ }
755
+ if len(candidates) == 0 {
756
+ return nil, []string{"team-query fallback found no event candidates"}
757
+ }
758
+
759
+ for _, eventID := range candidates {
760
+ reqCtx, cancel := context.WithTimeout(ctx, teamQueryEventFetchTimeout)
761
+ resolved, resolveErr := s.client.ResolveRefChain(reqCtx, "/events/"+eventID)
762
+ cancel()
763
+ if resolveErr != nil {
764
+ continue
765
+ }
766
+
767
+ payload, decodeErr := decodePayloadMap(resolved.Body)
768
+ if decodeErr != nil {
769
+ continue
770
+ }
771
+ if !eventMatchesTeamQuery(payload, left, right) {
772
+ continue
773
+ }
774
+
775
+ competitions := mapSliceField(payload, "competitions")
776
+ competitionID := ""
777
+ competitionRef := ""
778
+ if len(competitions) > 0 {
779
+ competitionID = stringField(competitions[0], "id")
780
+ competitionRef = stringField(competitions[0], "$ref")
781
+ }
782
+
783
+ competitionID = nonEmpty(competitionID, stringField(payload, "id"))
784
+ if competitionID == "" {
785
+ continue
786
+ }
787
+
788
+ refIDsMap := refIDs(nonEmpty(competitionRef, resolved.CanonicalRef, resolved.RequestedRef))
789
+ eventIDResolved := nonEmpty(stringField(payload, "id"), refIDsMap["eventId"])
790
+ leagueIDResolved := nonEmpty(refIDsMap["leagueId"], strings.TrimSpace(leagueID))
791
+ if competitionRef == "" && leagueIDResolved != "" && eventIDResolved != "" {
792
+ competitionRef = fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueIDResolved, eventIDResolved, competitionID)
793
+ }
794
+ if competitionRef == "" {
795
+ continue
796
+ }
797
+
798
+ entity := IndexedEntity{
799
+ Kind: EntityMatch,
800
+ ID: competitionID,
801
+ Ref: competitionRef,
802
+ Name: nonEmpty(stringField(payload, "name"), stringField(payload, "shortDescription"), stringField(payload, "description")),
803
+ ShortName: nonEmpty(stringField(payload, "shortName"), stringField(payload, "shortDescription")),
804
+ LeagueID: leagueIDResolved,
805
+ EventID: eventIDResolved,
806
+ MatchID: competitionID,
807
+ Aliases: []string{
808
+ stringField(payload, "name"),
809
+ stringField(payload, "shortName"),
810
+ stringField(payload, "shortDescription"),
811
+ stringField(payload, "description"),
812
+ left,
813
+ right,
814
+ competitionID,
815
+ eventIDResolved,
816
+ },
817
+ UpdatedAt: time.Now().UTC(),
818
+ }
819
+ if s.resolver != nil && s.resolver.index != nil {
820
+ _ = s.resolver.index.Upsert(entity)
821
+ }
822
+ return &entity, []string{fmt.Sprintf("team-query fallback matched event %s", eventIDResolved)}
823
+ }
824
+
825
+ return nil, []string{"team-query fallback scanned recent events with no match"}
826
+ }
827
+
828
+ func (s *MatchService) buildTeamQueryEventCandidates(ctx context.Context, leagueID, preferredLeagueID string) ([]string, error) {
829
+ rootRef := "/events"
830
+ if strings.TrimSpace(leagueID) != "" {
831
+ rootRef = "/leagues/" + strings.TrimSpace(leagueID) + "/events"
832
+ }
833
+
834
+ seedRefs := make([]Ref, 0)
835
+ if refs, err := s.fetchEventRefs(ctx, rootRef); err == nil {
836
+ seedRefs = append(seedRefs, refs...)
837
+ }
838
+ if len(seedRefs) == 0 && rootRef != "/events" {
839
+ refs, err := s.fetchEventRefs(ctx, "/events")
840
+ if err != nil {
841
+ return nil, err
842
+ }
843
+ seedRefs = append(seedRefs, refs...)
844
+ }
845
+
846
+ seen := map[string]struct{}{}
847
+ candidates := make([]string, 0, maxTeamQueryEventCandidates)
848
+ type eventSeed struct {
849
+ id int
850
+ leagueID string
851
+ }
852
+ seeds := make([]eventSeed, 0, len(seedRefs))
853
+ for _, item := range seedRefs {
854
+ ids := refIDs(item.URL)
855
+ eventID := strings.TrimSpace(ids["eventId"])
856
+ if eventID == "" {
857
+ continue
858
+ }
859
+
860
+ seed, err := strconv.Atoi(eventID)
861
+ if err != nil {
862
+ continue
863
+ }
864
+ seeds = append(seeds, eventSeed{id: seed, leagueID: strings.TrimSpace(ids["leagueId"])})
865
+ }
866
+
867
+ sort.Slice(seeds, func(i, j int) bool {
868
+ iPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[i].leagueID == strings.TrimSpace(preferredLeagueID)
869
+ jPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[j].leagueID == strings.TrimSpace(preferredLeagueID)
870
+ if iPref != jPref {
871
+ return iPref
872
+ }
873
+ return seeds[i].id > seeds[j].id
874
+ })
875
+
876
+ for delta := 0; delta <= matchTeamQueryScanRange; delta++ {
877
+ for _, seed := range seeds {
878
+ down := strconv.Itoa(seed.id - delta)
879
+ if _, ok := seen[down]; !ok {
880
+ seen[down] = struct{}{}
881
+ candidates = append(candidates, down)
882
+ if len(candidates) >= maxTeamQueryEventCandidates {
883
+ return candidates, nil
884
+ }
885
+ }
886
+
887
+ if delta == 0 {
888
+ continue
889
+ }
890
+ up := strconv.Itoa(seed.id + delta)
891
+ if _, ok := seen[up]; !ok {
892
+ seen[up] = struct{}{}
893
+ candidates = append(candidates, up)
894
+ if len(candidates) >= maxTeamQueryEventCandidates {
895
+ return candidates, nil
896
+ }
897
+ }
898
+ }
899
+ }
900
+
901
+ return candidates, nil
902
+ }
903
+
904
+ func (s *MatchService) fetchEventRefs(ctx context.Context, ref string) ([]Ref, error) {
905
+ resolved, err := s.client.ResolveRefChain(ctx, ref)
906
+ if err != nil {
907
+ return nil, err
908
+ }
909
+ page, err := DecodePage[Ref](resolved.Body)
910
+ if err != nil {
911
+ return nil, err
912
+ }
913
+ return page.Items, nil
914
+ }
915
+
916
+ func parseTeamVsQuery(query string) (string, string, bool) {
917
+ normalized := normalizeAlias(query)
918
+ if normalized == "" {
919
+ return "", "", false
920
+ }
921
+
922
+ separators := []string{" versus ", " vs ", " v "}
923
+ for _, sep := range separators {
924
+ parts := strings.SplitN(normalized, sep, 2)
925
+ if len(parts) != 2 {
926
+ continue
927
+ }
928
+ left := strings.TrimSpace(parts[0])
929
+ right := strings.TrimSpace(parts[1])
930
+ if left == "" || right == "" {
931
+ return "", "", false
932
+ }
933
+ return left, right, true
934
+ }
935
+ return "", "", false
936
+ }
937
+
938
+ func eventMatchesTeamQuery(payload map[string]any, left, right string) bool {
939
+ parts := []string{
940
+ stringField(payload, "name"),
941
+ stringField(payload, "shortName"),
942
+ stringField(payload, "shortDescription"),
943
+ stringField(payload, "description"),
944
+ }
945
+ for _, competition := range mapSliceField(payload, "competitions") {
946
+ parts = append(parts,
947
+ stringField(competition, "name"),
948
+ stringField(competition, "shortName"),
949
+ stringField(competition, "shortDescription"),
950
+ stringField(competition, "description"),
951
+ stringField(competition, "note"),
952
+ )
953
+ }
954
+
955
+ haystack := normalizeAlias(strings.Join(parts, " "))
956
+ return teamQuerySideMatches(haystack, left) && teamQuerySideMatches(haystack, right)
957
+ }
958
+
959
+ func teamQuerySideMatches(haystack, side string) bool {
960
+ if haystack == "" {
961
+ return false
962
+ }
963
+ for _, variant := range teamQueryVariants(side) {
964
+ if variant != "" && strings.Contains(haystack, variant) {
965
+ return true
966
+ }
967
+ }
968
+ return false
969
+ }
970
+
971
+ func teamQueryVariants(side string) []string {
972
+ base := normalizeAlias(side)
973
+ if base == "" {
974
+ return nil
975
+ }
976
+
977
+ seen := map[string]struct{}{base: {}}
978
+ variants := []string{base}
979
+
980
+ add := func(value string) {
981
+ value = normalizeAlias(value)
982
+ if value == "" {
983
+ return
984
+ }
985
+ if _, ok := seen[value]; ok {
986
+ return
987
+ }
988
+ seen[value] = struct{}{}
989
+ variants = append(variants, value)
990
+ }
991
+
992
+ if strings.Contains(base, "bangalore") {
993
+ add(strings.ReplaceAll(base, "bangalore", "bengaluru"))
994
+ }
995
+ if strings.Contains(base, "bengaluru") {
996
+ add(strings.ReplaceAll(base, "bengaluru", "bangalore"))
997
+ }
998
+
999
+ for _, alias := range knownIPLTeamAliases[base] {
1000
+ add(alias)
1001
+ }
1002
+
1003
+ return variants
1004
+ }
1005
+
1006
+ func inferTeamQueryLeagueHint(left, right string) string {
1007
+ left = normalizeAlias(left)
1008
+ right = normalizeAlias(right)
1009
+ if left == "" || right == "" {
1010
+ return ""
1011
+ }
1012
+ _, leftKnown := knownIPLTeamAliases[left]
1013
+ _, rightKnown := knownIPLTeamAliases[right]
1014
+ if leftKnown && rightKnown {
1015
+ return "8048"
1016
+ }
1017
+ return ""
1018
+ }
1019
+
1020
+ var knownIPLTeamAliases = map[string][]string{
1021
+ "csk": {"chennai super kings", "chennai"},
1022
+ "chennai super kings": {"csk", "chennai"},
1023
+ "chennai": {"csk", "chennai super kings"},
1024
+ "dc": {"delhi capitals", "delhi"},
1025
+ "delhi capitals": {"dc", "delhi"},
1026
+ "delhi": {"dc", "delhi capitals"},
1027
+ "gt": {"gujarat titans", "gujarat"},
1028
+ "gujarat titans": {"gt", "gujarat"},
1029
+ "gujarat": {"gt", "gujarat titans"},
1030
+ "kkr": {"kolkata knight riders", "kolkata"},
1031
+ "kolkata knight riders": {"kkr", "kolkata"},
1032
+ "kolkata": {"kkr", "kolkata knight riders"},
1033
+ "lsg": {"lucknow super giants", "lucknow"},
1034
+ "lucknow super giants": {"lsg", "lucknow"},
1035
+ "lucknow": {"lsg", "lucknow super giants"},
1036
+ "mi": {"mumbai indians", "mumbai"},
1037
+ "mumbai indians": {"mi", "mumbai"},
1038
+ "mumbai": {"mi", "mumbai indians"},
1039
+ "pbks": {"punjab kings", "punjab"},
1040
+ "punjab kings": {"pbks", "punjab", "kxip"},
1041
+ "punjab": {"pbks", "punjab kings", "kxip"},
1042
+ "kxip": {"pbks", "punjab kings", "punjab"},
1043
+ "rcb": {"royal challengers bengaluru", "royal challengers bangalore", "bangalore", "bengaluru"},
1044
+ "royal challengers bengaluru": {"rcb", "royal challengers bangalore", "bangalore", "bengaluru"},
1045
+ "royal challengers bangalore": {"rcb", "royal challengers bengaluru", "bangalore", "bengaluru"},
1046
+ "bangalore": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bengaluru"},
1047
+ "bengaluru": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bangalore"},
1048
+ "rr": {"rajasthan royals", "rajasthan"},
1049
+ "rajasthan royals": {"rr", "rajasthan"},
1050
+ "rajasthan": {"rr", "rajasthan royals"},
1051
+ "srh": {"sunrisers hyderabad", "hyderabad"},
1052
+ "sunrisers hyderabad": {"srh", "hyderabad"},
1053
+ "hyderabad": {"srh", "sunrisers hyderabad"},
1054
+ }
1055
+
578
1056
  func (s *MatchService) deliveryEventsFromRoute(ctx context.Context, ref string, baseWarnings []string) (NormalizedResult, error) {
579
1057
  resolved, err := s.resolveRefChainResilient(ctx, ref)
580
1058
  if err != nil {
@@ -628,7 +1106,9 @@ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]De
628
1106
  return
629
1107
  }
630
1108
 
631
- itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
1109
+ itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
1110
+ itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
1111
+ cancel()
632
1112
  if itemErr != nil {
633
1113
  results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, itemErr)}
634
1114
  return
@@ -962,26 +1442,55 @@ func (s *MatchService) fetchDetailedRefCollection(
962
1442
  return nil, nil, nil, err
963
1443
  }
964
1444
 
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
- }
1445
+ type normalizedItemResult struct {
1446
+ index int
1447
+ item any
1448
+ warning string
1449
+ }
972
1450
 
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
- }
1451
+ results := make([]normalizedItemResult, len(pageItems))
1452
+ sem := make(chan struct{}, detailSubresourceFetchConcurrency)
1453
+ var wg sync.WaitGroup
1454
+ for i, item := range pageItems {
1455
+ wg.Add(1)
1456
+ go func(index int, item Ref) {
1457
+ defer wg.Done()
1458
+ sem <- struct{}{}
1459
+ defer func() { <-sem }()
1460
+
1461
+ itemRef := strings.TrimSpace(item.URL)
1462
+ if itemRef == "" {
1463
+ results[index] = normalizedItemResult{index: index, warning: "skip item with empty ref"}
1464
+ return
1465
+ }
1466
+
1467
+ itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
1468
+ itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
1469
+ cancel()
1470
+ if itemErr != nil {
1471
+ results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, itemErr)}
1472
+ return
1473
+ }
1474
+
1475
+ normalized, normalizeErr := normalize(itemResolved.Body)
1476
+ if normalizeErr != nil {
1477
+ results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, normalizeErr)}
1478
+ return
1479
+ }
1480
+ results[index] = normalizedItemResult{index: index, item: normalized}
1481
+ }(i, item)
1482
+ }
1483
+ wg.Wait()
978
1484
 
979
- normalized, normalizeErr := normalize(itemResolved.Body)
980
- if normalizeErr != nil {
981
- warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, normalizeErr))
1485
+ items := make([]any, 0, len(results))
1486
+ for _, result := range results {
1487
+ if strings.TrimSpace(result.warning) != "" {
1488
+ warnings = append(warnings, result.warning)
982
1489
  continue
983
1490
  }
984
- items = append(items, normalized)
1491
+ if result.item != nil {
1492
+ items = append(items, result.item)
1493
+ }
985
1494
  }
986
1495
 
987
1496
  return resolved, items, compactWarnings(warnings), nil
@@ -1001,25 +1510,55 @@ func (s *MatchService) resolvePageRefs(ctx context.Context, first *ResolvedDocum
1001
1510
  return items, nil, nil
1002
1511
  }
1003
1512
 
1004
- warnings := make([]string, 0)
1513
+ type pageLoadResult struct {
1514
+ page int
1515
+ items []Ref
1516
+ warning string
1517
+ }
1518
+
1519
+ results := make([]pageLoadResult, page.PageCount+1)
1520
+ sem := make(chan struct{}, detailSubresourceFetchConcurrency)
1521
+ var wg sync.WaitGroup
1005
1522
  baseRef := firstNonEmptyString(first.CanonicalRef, first.RequestedRef)
1006
1523
  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))
1524
+ wg.Add(1)
1525
+ go func(index int) {
1526
+ defer wg.Done()
1527
+ sem <- struct{}{}
1528
+ defer func() { <-sem }()
1529
+
1530
+ pageRef := pagedRef(baseRef, index)
1531
+ if pageRef == "" {
1532
+ results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d unavailable for %s", index, baseRef)}
1533
+ return
1534
+ }
1535
+
1536
+ pageDoc, pageErr := s.resolveRefChainResilient(ctx, pageRef)
1537
+ if pageErr != nil {
1538
+ results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageRef, pageErr)}
1539
+ return
1540
+ }
1541
+
1542
+ nextPage, decodeErr := DecodePage[Ref](pageDoc.Body)
1543
+ if decodeErr != nil {
1544
+ results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageDoc.CanonicalRef, decodeErr)}
1545
+ return
1546
+ }
1547
+ results[index] = pageLoadResult{page: index, items: nextPage.Items}
1548
+ }(pageIndex)
1549
+ }
1550
+ wg.Wait()
1551
+
1552
+ warnings := make([]string, 0)
1553
+ for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
1554
+ result := results[pageIndex]
1555
+ if strings.TrimSpace(result.warning) != "" {
1556
+ warnings = append(warnings, result.warning)
1015
1557
  continue
1016
1558
  }
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
1559
+ if len(result.items) > 0 {
1560
+ items = append(items, result.items...)
1021
1561
  }
1022
- items = append(items, nextPage.Items...)
1023
1562
  }
1024
1563
 
1025
1564
  return items, compactWarnings(warnings), nil