cricinfo-cli-go 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +63 -0
- package/CONTRIBUTORS.md +75 -0
- package/LICENSE +21 -0
- package/Makefile +131 -0
- package/README.md +130 -0
- package/bin/cricinfo.js +44 -0
- package/cmd/cricinfo/main.go +15 -0
- package/go.mod +10 -0
- package/go.sum +10 -0
- package/internal/app/app.go +11 -0
- package/internal/app/app_test.go +122 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/internal/cli/analysis.go +262 -0
- package/internal/cli/analysis_test.go +175 -0
- package/internal/cli/competitions.go +154 -0
- package/internal/cli/competitions_test.go +165 -0
- package/internal/cli/leagues.go +297 -0
- package/internal/cli/leagues_test.go +194 -0
- package/internal/cli/matches.go +403 -0
- package/internal/cli/matches_test.go +413 -0
- package/internal/cli/players.go +263 -0
- package/internal/cli/players_test.go +384 -0
- package/internal/cli/root.go +141 -0
- package/internal/cli/search.go +119 -0
- package/internal/cli/teams.go +214 -0
- package/internal/cli/teams_test.go +192 -0
- package/internal/cricinfo/analysis.go +1401 -0
- package/internal/cricinfo/analysis_phase15_test.go +267 -0
- package/internal/cricinfo/client.go +471 -0
- package/internal/cricinfo/client_test.go +280 -0
- package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
- package/internal/cricinfo/competitions.go +405 -0
- package/internal/cricinfo/competitions_phase13_test.go +234 -0
- package/internal/cricinfo/coverage_ledger.go +122 -0
- package/internal/cricinfo/coverage_ledger_test.go +253 -0
- package/internal/cricinfo/decode.go +115 -0
- package/internal/cricinfo/decode_test.go +100 -0
- package/internal/cricinfo/entity_index.go +618 -0
- package/internal/cricinfo/entity_index_test.go +175 -0
- package/internal/cricinfo/fixture_matrix.go +243 -0
- package/internal/cricinfo/fixture_matrix_test.go +49 -0
- package/internal/cricinfo/fixtures_test.go +264 -0
- package/internal/cricinfo/historical_hydration.go +1641 -0
- package/internal/cricinfo/historical_phase14_test.go +542 -0
- package/internal/cricinfo/leagues.go +1210 -0
- package/internal/cricinfo/leagues_phase12_test.go +324 -0
- package/internal/cricinfo/live_leagues_test.go +169 -0
- package/internal/cricinfo/live_matches_test.go +203 -0
- package/internal/cricinfo/live_matrix_test.go +118 -0
- package/internal/cricinfo/live_players_test.go +122 -0
- package/internal/cricinfo/live_search_test.go +86 -0
- package/internal/cricinfo/live_smoke_test.go +213 -0
- package/internal/cricinfo/live_teams_test.go +104 -0
- package/internal/cricinfo/matches.go +1508 -0
- package/internal/cricinfo/matches_phase7_test.go +207 -0
- package/internal/cricinfo/matches_phase9_test.go +253 -0
- package/internal/cricinfo/normalize_entities.go +1727 -0
- package/internal/cricinfo/normalize_leagues.go +346 -0
- package/internal/cricinfo/players.go +1332 -0
- package/internal/cricinfo/players_phase10_test.go +174 -0
- package/internal/cricinfo/players_phase11_test.go +373 -0
- package/internal/cricinfo/render_contract.go +1088 -0
- package/internal/cricinfo/render_phase4_test.go +633 -0
- package/internal/cricinfo/renderer.go +1689 -0
- package/internal/cricinfo/resolver.go +813 -0
- package/internal/cricinfo/resolver_test.go +244 -0
- package/internal/cricinfo/teams.go +603 -0
- package/internal/cricinfo/teams_phase8_test.go +231 -0
- package/internal/cricinfo/testdata/fixtures/README.md +43 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
- package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
- package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
- package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
- package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
- package/internal/cricinfo/types.go +54 -0
- package/package.json +51 -0
- package/scripts/postinstall.js +153 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
package cricinfo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/url"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
"sync"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const defaultMatchListLimit = 20
|
|
14
|
+
const deliveryFetchConcurrency = 12
|
|
15
|
+
|
|
16
|
+
// MatchServiceConfig configures match discovery and lookup behavior.
|
|
17
|
+
type MatchServiceConfig struct {
|
|
18
|
+
Client *Client
|
|
19
|
+
Resolver *Resolver
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// MatchListOptions controls list/live traversal behavior.
|
|
23
|
+
type MatchListOptions struct {
|
|
24
|
+
Limit int
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MatchLookupOptions controls resolver-backed single match lookup.
|
|
28
|
+
type MatchLookupOptions struct {
|
|
29
|
+
LeagueID string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// MatchInningsOptions controls innings-depth lookup behavior.
|
|
33
|
+
type MatchInningsOptions struct {
|
|
34
|
+
LeagueID string
|
|
35
|
+
TeamQuery string
|
|
36
|
+
Innings int
|
|
37
|
+
Period int
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MatchService implements domain-level match discovery and lookup commands.
|
|
41
|
+
type MatchService struct {
|
|
42
|
+
client *Client
|
|
43
|
+
resolver *Resolver
|
|
44
|
+
ownsResolver bool
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// NewMatchService builds a match service using default client/resolver when omitted.
|
|
48
|
+
func NewMatchService(cfg MatchServiceConfig) (*MatchService, error) {
|
|
49
|
+
client := cfg.Client
|
|
50
|
+
if client == nil {
|
|
51
|
+
var err error
|
|
52
|
+
client, err = NewClient(Config{})
|
|
53
|
+
if err != nil {
|
|
54
|
+
return nil, err
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
resolver := cfg.Resolver
|
|
59
|
+
ownsResolver := false
|
|
60
|
+
if resolver == nil {
|
|
61
|
+
var err error
|
|
62
|
+
resolver, err = NewResolver(ResolverConfig{Client: client})
|
|
63
|
+
if err != nil {
|
|
64
|
+
return nil, err
|
|
65
|
+
}
|
|
66
|
+
ownsResolver = true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return &MatchService{
|
|
70
|
+
client: client,
|
|
71
|
+
resolver: resolver,
|
|
72
|
+
ownsResolver: ownsResolver,
|
|
73
|
+
}, nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Close persists resolver cache when owned by this service.
|
|
77
|
+
func (s *MatchService) Close() error {
|
|
78
|
+
if !s.ownsResolver || s.resolver == nil {
|
|
79
|
+
return nil
|
|
80
|
+
}
|
|
81
|
+
return s.resolver.Close()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// List discovers current matches from /events.
|
|
85
|
+
func (s *MatchService) List(ctx context.Context, opts MatchListOptions) (NormalizedResult, error) {
|
|
86
|
+
return s.listFromEvents(ctx, opts, false)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Live discovers current in-progress matches from /events.
|
|
90
|
+
func (s *MatchService) Live(ctx context.Context, opts MatchListOptions) (NormalizedResult, error) {
|
|
91
|
+
return s.listFromEvents(ctx, opts, true)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Show resolves and returns one match with normalized summary fields.
|
|
95
|
+
func (s *MatchService) Show(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
96
|
+
return s.lookupMatch(ctx, query, opts, false)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Status resolves and returns one match with status-focused summary fields.
|
|
100
|
+
func (s *MatchService) Status(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
101
|
+
return s.lookupMatch(ctx, query, opts, true)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Scorecard resolves and returns matchcards rendered as batting/bowling/partnership views.
|
|
105
|
+
func (s *MatchService) Scorecard(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
106
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
107
|
+
if passthrough != nil {
|
|
108
|
+
passthrough.Kind = EntityMatchScorecard
|
|
109
|
+
return *passthrough, nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
scorecardRef := matchSubresourceRef(*lookup.match, "matchcards", "matchcards")
|
|
113
|
+
if scorecardRef == "" {
|
|
114
|
+
return NormalizedResult{
|
|
115
|
+
Kind: EntityMatchScorecard,
|
|
116
|
+
Status: ResultStatusEmpty,
|
|
117
|
+
Message: fmt.Sprintf("scorecard route unavailable for match %q", lookup.match.ID),
|
|
118
|
+
}, nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return NewTransportErrorResult(EntityMatchScorecard, scorecardRef, err), nil
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
scorecard, err := NormalizeMatchScorecard(resolved.Body, *lookup.match)
|
|
127
|
+
if err != nil {
|
|
128
|
+
return NormalizedResult{}, fmt.Errorf("normalize matchcards %q: %w", resolved.CanonicalRef, err)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
132
|
+
result := NewDataResult(EntityMatchScorecard, scorecard)
|
|
133
|
+
if len(warnings) > 0 {
|
|
134
|
+
result = NewPartialResult(EntityMatchScorecard, scorecard, warnings...)
|
|
135
|
+
}
|
|
136
|
+
result.RequestedRef = resolved.RequestedRef
|
|
137
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
138
|
+
return result, nil
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Details resolves and returns normalized delivery events from the details route.
|
|
142
|
+
func (s *MatchService) Details(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
143
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
144
|
+
if passthrough != nil {
|
|
145
|
+
passthrough.Kind = EntityDeliveryEvent
|
|
146
|
+
return *passthrough, nil
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
detailsRef := nonEmpty(strings.TrimSpace(lookup.match.DetailsRef), matchSubresourceRef(*lookup.match, "details", "details"))
|
|
150
|
+
if detailsRef == "" {
|
|
151
|
+
return NormalizedResult{
|
|
152
|
+
Kind: EntityDeliveryEvent,
|
|
153
|
+
Status: ResultStatusEmpty,
|
|
154
|
+
Message: fmt.Sprintf("details route unavailable for match %q", lookup.match.ID),
|
|
155
|
+
}, nil
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return s.deliveryEventsFromRoute(ctx, detailsRef, lookup.warnings)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Plays resolves and returns normalized delivery events from the plays route.
|
|
162
|
+
func (s *MatchService) Plays(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
163
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
164
|
+
if passthrough != nil {
|
|
165
|
+
passthrough.Kind = EntityDeliveryEvent
|
|
166
|
+
return *passthrough, nil
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
playsRef := matchSubresourceRef(*lookup.match, "plays", "plays")
|
|
170
|
+
if playsRef == "" {
|
|
171
|
+
return NormalizedResult{
|
|
172
|
+
Kind: EntityDeliveryEvent,
|
|
173
|
+
Status: ResultStatusEmpty,
|
|
174
|
+
Message: fmt.Sprintf("plays route unavailable for match %q", lookup.match.ID),
|
|
175
|
+
}, nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return s.deliveryEventsFromRoute(ctx, playsRef, lookup.warnings)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Situation resolves and returns normalized match situation data.
|
|
182
|
+
func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
183
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
184
|
+
if passthrough != nil {
|
|
185
|
+
passthrough.Kind = EntityMatchSituation
|
|
186
|
+
return *passthrough, nil
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
situationRef := matchSubresourceRef(*lookup.match, "situation", "situation")
|
|
190
|
+
if situationRef == "" {
|
|
191
|
+
return NormalizedResult{
|
|
192
|
+
Kind: EntityMatchSituation,
|
|
193
|
+
Status: ResultStatusEmpty,
|
|
194
|
+
Message: fmt.Sprintf("situation route unavailable for match %q", lookup.match.ID),
|
|
195
|
+
}, nil
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
resolved, err := s.client.ResolveRefChain(ctx, situationRef)
|
|
199
|
+
if err != nil {
|
|
200
|
+
return NewTransportErrorResult(EntityMatchSituation, situationRef, err), nil
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
situation, err := NormalizeMatchSituation(resolved.Body, *lookup.match)
|
|
204
|
+
if err != nil {
|
|
205
|
+
return NormalizedResult{}, fmt.Errorf("normalize situation %q: %w", resolved.CanonicalRef, err)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if isSparseSituation(situation) {
|
|
209
|
+
result := NormalizedResult{
|
|
210
|
+
Kind: EntityMatchSituation,
|
|
211
|
+
Status: ResultStatusEmpty,
|
|
212
|
+
RequestedRef: resolved.RequestedRef,
|
|
213
|
+
CanonicalRef: resolved.CanonicalRef,
|
|
214
|
+
Message: "no situation data available for this match",
|
|
215
|
+
}
|
|
216
|
+
return result, nil
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
result := NewDataResult(EntityMatchSituation, situation)
|
|
220
|
+
if len(lookup.warnings) > 0 {
|
|
221
|
+
result = NewPartialResult(EntityMatchSituation, situation, lookup.warnings...)
|
|
222
|
+
}
|
|
223
|
+
result.RequestedRef = resolved.RequestedRef
|
|
224
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
225
|
+
return result, nil
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Innings resolves and returns innings summaries with over and wicket timelines when period statistics are available.
|
|
229
|
+
func (s *MatchService) Innings(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
230
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
|
|
231
|
+
if passthrough != nil {
|
|
232
|
+
passthrough.Kind = EntityInnings
|
|
233
|
+
return *passthrough, nil
|
|
234
|
+
}
|
|
235
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
236
|
+
teamCache := map[string]teamIdentity{}
|
|
237
|
+
scoreCache := map[string]string{}
|
|
238
|
+
lookup.warnings = append(lookup.warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
|
|
239
|
+
|
|
240
|
+
teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, opts.TeamQuery, opts.LeagueID)
|
|
241
|
+
if teamResult != nil {
|
|
242
|
+
teamResult.Kind = EntityInnings
|
|
243
|
+
return *teamResult, nil
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
247
|
+
warnings = append(warnings, teamWarnings...)
|
|
248
|
+
|
|
249
|
+
items := make([]any, 0)
|
|
250
|
+
for _, team := range teams {
|
|
251
|
+
innings, resolvedRef, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
|
|
252
|
+
warnings = append(warnings, inningsWarnings...)
|
|
253
|
+
for i := range innings {
|
|
254
|
+
if strings.TrimSpace(team.ID) != "" {
|
|
255
|
+
innings[i].TeamID = strings.TrimSpace(team.ID)
|
|
256
|
+
}
|
|
257
|
+
innings[i].TeamName = nonEmpty(team.ShortName, team.Name, team.ID, innings[i].TeamName)
|
|
258
|
+
innings[i].MatchID = nonEmpty(innings[i].MatchID, lookup.match.ID)
|
|
259
|
+
innings[i].CompetitionID = nonEmpty(innings[i].CompetitionID, lookup.match.CompetitionID, lookup.match.ID)
|
|
260
|
+
innings[i].EventID = nonEmpty(innings[i].EventID, lookup.match.EventID)
|
|
261
|
+
innings[i].LeagueID = nonEmpty(innings[i].LeagueID, lookup.match.LeagueID)
|
|
262
|
+
|
|
263
|
+
statsWarnings := s.hydrateInningsTimelines(ctx, &innings[i])
|
|
264
|
+
warnings = append(warnings, statsWarnings...)
|
|
265
|
+
items = append(items, innings[i])
|
|
266
|
+
}
|
|
267
|
+
if strings.TrimSpace(resolvedRef) != "" && len(items) == 0 {
|
|
268
|
+
warnings = append(warnings, fmt.Sprintf("no innings found at %s", resolvedRef))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
result := NewListResult(EntityInnings, items)
|
|
273
|
+
if len(warnings) > 0 {
|
|
274
|
+
result = NewPartialListResult(EntityInnings, items, warnings...)
|
|
275
|
+
}
|
|
276
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
277
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
278
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
279
|
+
result.Message = "no innings available for selected scope"
|
|
280
|
+
}
|
|
281
|
+
return result, nil
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Partnerships resolves detailed partnership objects for a selected team/innings/period.
|
|
285
|
+
func (s *MatchService) Partnerships(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
286
|
+
selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
|
|
287
|
+
if passthrough != nil {
|
|
288
|
+
passthrough.Kind = EntityPartnership
|
|
289
|
+
return *passthrough, nil
|
|
290
|
+
}
|
|
291
|
+
if strings.TrimSpace(selected.innings.PartnershipsRef) == "" {
|
|
292
|
+
return NormalizedResult{
|
|
293
|
+
Kind: EntityPartnership,
|
|
294
|
+
Status: ResultStatusEmpty,
|
|
295
|
+
Message: fmt.Sprintf("partnership route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
|
|
296
|
+
}, nil
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
resolved, items, warnings, err := s.fetchDetailedRefCollection(
|
|
300
|
+
ctx,
|
|
301
|
+
selected.innings.PartnershipsRef,
|
|
302
|
+
func(itemBody []byte) (any, error) {
|
|
303
|
+
partnership, normalizeErr := NormalizePartnership(itemBody)
|
|
304
|
+
if normalizeErr != nil {
|
|
305
|
+
return nil, normalizeErr
|
|
306
|
+
}
|
|
307
|
+
if strings.TrimSpace(selected.team.ID) != "" {
|
|
308
|
+
partnership.TeamID = strings.TrimSpace(selected.team.ID)
|
|
309
|
+
}
|
|
310
|
+
partnership.TeamName = nonEmpty(selected.team.ShortName, selected.team.Name, selected.team.ID, partnership.TeamName)
|
|
311
|
+
partnership.MatchID = nonEmpty(partnership.MatchID, selected.match.ID)
|
|
312
|
+
partnership.InningsID = nonEmpty(partnership.InningsID, fmt.Sprintf("%d", selected.innings.InningsNumber))
|
|
313
|
+
partnership.Period = nonEmpty(partnership.Period, fmt.Sprintf("%d", selected.innings.Period))
|
|
314
|
+
if partnership.Order == 0 {
|
|
315
|
+
partnership.Order = partnership.WicketNumber
|
|
316
|
+
}
|
|
317
|
+
return *partnership, nil
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
if err != nil {
|
|
321
|
+
return NewTransportErrorResult(EntityPartnership, selected.innings.PartnershipsRef, err), nil
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
warnings = append(selected.warnings, warnings...)
|
|
325
|
+
result := NewListResult(EntityPartnership, items)
|
|
326
|
+
if len(warnings) > 0 {
|
|
327
|
+
result = NewPartialListResult(EntityPartnership, items, warnings...)
|
|
328
|
+
}
|
|
329
|
+
result.RequestedRef = resolved.RequestedRef
|
|
330
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
331
|
+
return result, nil
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// FallOfWicket resolves detailed fall-of-wicket objects for a selected team/innings/period.
|
|
335
|
+
func (s *MatchService) FallOfWicket(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
336
|
+
selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
|
|
337
|
+
if passthrough != nil {
|
|
338
|
+
passthrough.Kind = EntityFallOfWicket
|
|
339
|
+
return *passthrough, nil
|
|
340
|
+
}
|
|
341
|
+
if strings.TrimSpace(selected.innings.FallOfWicketRef) == "" {
|
|
342
|
+
return NormalizedResult{
|
|
343
|
+
Kind: EntityFallOfWicket,
|
|
344
|
+
Status: ResultStatusEmpty,
|
|
345
|
+
Message: fmt.Sprintf("fall-of-wicket route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
|
|
346
|
+
}, nil
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
resolved, items, warnings, err := s.fetchDetailedRefCollection(
|
|
350
|
+
ctx,
|
|
351
|
+
selected.innings.FallOfWicketRef,
|
|
352
|
+
func(itemBody []byte) (any, error) {
|
|
353
|
+
fow, normalizeErr := NormalizeFallOfWicket(itemBody)
|
|
354
|
+
if normalizeErr != nil {
|
|
355
|
+
return nil, normalizeErr
|
|
356
|
+
}
|
|
357
|
+
if strings.TrimSpace(selected.team.ID) != "" {
|
|
358
|
+
fow.TeamID = strings.TrimSpace(selected.team.ID)
|
|
359
|
+
}
|
|
360
|
+
fow.TeamName = nonEmpty(selected.team.ShortName, selected.team.Name, selected.team.ID, fow.TeamName)
|
|
361
|
+
fow.MatchID = nonEmpty(fow.MatchID, selected.match.ID)
|
|
362
|
+
fow.InningsID = nonEmpty(fow.InningsID, fmt.Sprintf("%d", selected.innings.InningsNumber))
|
|
363
|
+
fow.Period = nonEmpty(fow.Period, fmt.Sprintf("%d", selected.innings.Period))
|
|
364
|
+
return *fow, nil
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
if err != nil {
|
|
368
|
+
return NewTransportErrorResult(EntityFallOfWicket, selected.innings.FallOfWicketRef, err), nil
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
warnings = append(selected.warnings, warnings...)
|
|
372
|
+
result := NewListResult(EntityFallOfWicket, items)
|
|
373
|
+
if len(warnings) > 0 {
|
|
374
|
+
result = NewPartialListResult(EntityFallOfWicket, items, warnings...)
|
|
375
|
+
}
|
|
376
|
+
result.RequestedRef = resolved.RequestedRef
|
|
377
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
378
|
+
return result, nil
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Deliveries resolves period statistics into over and wicket timelines for a selected team/innings/period.
|
|
382
|
+
func (s *MatchService) Deliveries(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
383
|
+
selected, passthrough := s.resolveSelectedInnings(ctx, query, opts, true)
|
|
384
|
+
if passthrough != nil {
|
|
385
|
+
passthrough.Kind = EntityInnings
|
|
386
|
+
return *passthrough, nil
|
|
387
|
+
}
|
|
388
|
+
if strings.TrimSpace(selected.innings.StatisticsRef) == "" {
|
|
389
|
+
return NormalizedResult{
|
|
390
|
+
Kind: EntityInnings,
|
|
391
|
+
Status: ResultStatusEmpty,
|
|
392
|
+
Message: fmt.Sprintf("period statistics route unavailable for team %q innings %d/%d", selected.team.ID, selected.innings.InningsNumber, selected.innings.Period),
|
|
393
|
+
}, nil
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
resolved, err := s.client.ResolveRefChain(ctx, selected.innings.StatisticsRef)
|
|
397
|
+
if err != nil {
|
|
398
|
+
return NewTransportErrorResult(EntityInnings, selected.innings.StatisticsRef, err), nil
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
overs, wickets, err := NormalizeInningsPeriodStatistics(resolved.Body)
|
|
402
|
+
if err != nil {
|
|
403
|
+
return NormalizedResult{}, fmt.Errorf("normalize period statistics %q: %w", resolved.CanonicalRef, err)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
innings := selected.innings
|
|
407
|
+
innings.OverTimeline = overs
|
|
408
|
+
innings.WicketTimeline = wickets
|
|
409
|
+
|
|
410
|
+
result := NewDataResult(EntityInnings, innings)
|
|
411
|
+
if len(selected.warnings) > 0 {
|
|
412
|
+
result = NewPartialResult(EntityInnings, innings, selected.warnings...)
|
|
413
|
+
}
|
|
414
|
+
result.RequestedRef = resolved.RequestedRef
|
|
415
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
416
|
+
return result, nil
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions, liveOnly bool) (NormalizedResult, error) {
|
|
420
|
+
resolved, err := s.client.ResolveRefChain(ctx, "/events")
|
|
421
|
+
if err != nil {
|
|
422
|
+
return NewTransportErrorResult(EntityMatch, "/events", err), nil
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
page, err := DecodePage[Ref](resolved.Body)
|
|
426
|
+
if err != nil {
|
|
427
|
+
return NormalizedResult{}, fmt.Errorf("decode /events page: %w", err)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
limit := opts.Limit
|
|
431
|
+
if limit <= 0 {
|
|
432
|
+
limit = defaultMatchListLimit
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
436
|
+
|
|
437
|
+
matches := make([]Match, 0, limit)
|
|
438
|
+
warnings := make([]string, 0)
|
|
439
|
+
for _, eventRef := range page.Items {
|
|
440
|
+
if len(matches) >= limit {
|
|
441
|
+
break
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
eventMatches, eventWarnings, eventErr := s.matchesFromEventRef(ctx, eventRef.URL)
|
|
445
|
+
if eventErr != nil {
|
|
446
|
+
warnings = append(warnings, fmt.Sprintf("event %s: %v", strings.TrimSpace(eventRef.URL), eventErr))
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
warnings = append(warnings, eventWarnings...)
|
|
450
|
+
|
|
451
|
+
for _, eventMatch := range eventMatches {
|
|
452
|
+
match := eventMatch
|
|
453
|
+
s.enrichMatchTeamsFromIndex(&match)
|
|
454
|
+
if liveOnly && !isLiveMatch(match) {
|
|
455
|
+
warnings = append(warnings, s.hydrateMatchStatusOnly(ctx, &match, statusCache)...)
|
|
456
|
+
}
|
|
457
|
+
if liveOnly && !isLiveMatch(match) {
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
match.ScoreSummary = matchScoreSummary(match.Teams)
|
|
461
|
+
matches = append(matches, match)
|
|
462
|
+
if len(matches) >= limit {
|
|
463
|
+
break
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
items := make([]any, 0, len(matches))
|
|
469
|
+
for i := range matches {
|
|
470
|
+
items = append(items, matches[i])
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
result := NewListResult(EntityMatch, items)
|
|
474
|
+
if len(warnings) > 0 {
|
|
475
|
+
result = NewPartialListResult(EntityMatch, items, warnings...)
|
|
476
|
+
}
|
|
477
|
+
result.RequestedRef = resolved.RequestedRef
|
|
478
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
479
|
+
return result, nil
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
func (s *MatchService) lookupMatch(ctx context.Context, query string, opts MatchLookupOptions, statusOnly bool) (NormalizedResult, error) {
|
|
483
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
484
|
+
if passthrough != nil {
|
|
485
|
+
return *passthrough, nil
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
489
|
+
teamCache := map[string]teamIdentity{}
|
|
490
|
+
scoreCache := map[string]string{}
|
|
491
|
+
warnings := make([]string, 0, len(lookup.warnings)+2)
|
|
492
|
+
warnings = append(warnings, lookup.warnings...)
|
|
493
|
+
|
|
494
|
+
hydrationWarnings := s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)
|
|
495
|
+
warnings = append(warnings, hydrationWarnings...)
|
|
496
|
+
|
|
497
|
+
if statusOnly {
|
|
498
|
+
lookup.match.Extensions = nil
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
result := NewDataResult(EntityMatch, lookup.match)
|
|
502
|
+
if len(warnings) > 0 {
|
|
503
|
+
result = NewPartialResult(EntityMatch, lookup.match, warnings...)
|
|
504
|
+
}
|
|
505
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
506
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
507
|
+
return result, nil
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
type matchLookup struct {
|
|
511
|
+
match *Match
|
|
512
|
+
resolved *ResolvedDocument
|
|
513
|
+
warnings []string
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opts MatchLookupOptions) (*matchLookup, *NormalizedResult) {
|
|
517
|
+
query = strings.TrimSpace(query)
|
|
518
|
+
if query == "" {
|
|
519
|
+
result := NormalizedResult{
|
|
520
|
+
Kind: EntityMatch,
|
|
521
|
+
Status: ResultStatusEmpty,
|
|
522
|
+
Message: "match query is required",
|
|
523
|
+
}
|
|
524
|
+
return nil, &result
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
searchResult, err := s.resolver.Search(ctx, EntityMatch, query, ResolveOptions{
|
|
528
|
+
Limit: 5,
|
|
529
|
+
LeagueID: strings.TrimSpace(opts.LeagueID),
|
|
530
|
+
})
|
|
531
|
+
if err != nil {
|
|
532
|
+
result := NewTransportErrorResult(EntityMatch, query, err)
|
|
533
|
+
return nil, &result
|
|
534
|
+
}
|
|
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),
|
|
540
|
+
}
|
|
541
|
+
return nil, &result
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
entity := searchResult.Entities[0]
|
|
545
|
+
ref := buildMatchRef(entity)
|
|
546
|
+
if ref == "" {
|
|
547
|
+
result := NormalizedResult{
|
|
548
|
+
Kind: EntityMatch,
|
|
549
|
+
Status: ResultStatusEmpty,
|
|
550
|
+
Message: fmt.Sprintf("unable to resolve match ref for %q", query),
|
|
551
|
+
}
|
|
552
|
+
return nil, &result
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
556
|
+
if err != nil {
|
|
557
|
+
result := NewTransportErrorResult(EntityMatch, ref, err)
|
|
558
|
+
return nil, &result
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
match, err := NormalizeMatch(resolved.Body)
|
|
562
|
+
if err != nil {
|
|
563
|
+
result := NormalizedResult{
|
|
564
|
+
Kind: EntityMatch,
|
|
565
|
+
Status: ResultStatusError,
|
|
566
|
+
Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err),
|
|
567
|
+
}
|
|
568
|
+
return nil, &result
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return &matchLookup{
|
|
572
|
+
match: match,
|
|
573
|
+
resolved: resolved,
|
|
574
|
+
warnings: searchResult.Warnings,
|
|
575
|
+
}, nil
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
func (s *MatchService) deliveryEventsFromRoute(ctx context.Context, ref string, baseWarnings []string) (NormalizedResult, error) {
|
|
579
|
+
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
580
|
+
if err != nil {
|
|
581
|
+
return NewTransportErrorResult(EntityDeliveryEvent, ref, err), nil
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
pageItems, pageWarnings, err := s.resolvePageRefs(ctx, resolved)
|
|
585
|
+
if err != nil {
|
|
586
|
+
return NormalizedResult{}, err
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
events := make([]any, 0, len(pageItems))
|
|
590
|
+
warnings := make([]string, 0, len(baseWarnings))
|
|
591
|
+
warnings = append(warnings, baseWarnings...)
|
|
592
|
+
warnings = append(warnings, pageWarnings...)
|
|
593
|
+
loaded, loadWarnings := s.loadDeliveryEvents(ctx, pageItems)
|
|
594
|
+
warnings = append(warnings, loadWarnings...)
|
|
595
|
+
for _, delivery := range loaded {
|
|
596
|
+
events = append(events, delivery)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
result := NewListResult(EntityDeliveryEvent, events)
|
|
600
|
+
if len(warnings) > 0 {
|
|
601
|
+
result = NewPartialListResult(EntityDeliveryEvent, events, warnings...)
|
|
602
|
+
}
|
|
603
|
+
result.RequestedRef = resolved.RequestedRef
|
|
604
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
605
|
+
return result, nil
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]DeliveryEvent, []string) {
|
|
609
|
+
type deliveryLoadResult struct {
|
|
610
|
+
index int
|
|
611
|
+
delivery *DeliveryEvent
|
|
612
|
+
warning string
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
results := make([]deliveryLoadResult, len(refs))
|
|
616
|
+
sem := make(chan struct{}, deliveryFetchConcurrency)
|
|
617
|
+
var wg sync.WaitGroup
|
|
618
|
+
for i, item := range refs {
|
|
619
|
+
wg.Add(1)
|
|
620
|
+
go func(index int, item Ref) {
|
|
621
|
+
defer wg.Done()
|
|
622
|
+
sem <- struct{}{}
|
|
623
|
+
defer func() { <-sem }()
|
|
624
|
+
|
|
625
|
+
itemRef := strings.TrimSpace(item.URL)
|
|
626
|
+
if itemRef == "" {
|
|
627
|
+
results[index] = deliveryLoadResult{index: index, warning: "skip detail item with empty ref"}
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
itemResolved, itemErr := s.resolveRefChainResilient(ctx, itemRef)
|
|
632
|
+
if itemErr != nil {
|
|
633
|
+
results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, itemErr)}
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
delivery, normalizeErr := NormalizeDeliveryEvent(itemResolved.Body)
|
|
638
|
+
if normalizeErr != nil {
|
|
639
|
+
results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, normalizeErr)}
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
results[index] = deliveryLoadResult{index: index, delivery: delivery}
|
|
643
|
+
}(i, item)
|
|
644
|
+
}
|
|
645
|
+
wg.Wait()
|
|
646
|
+
|
|
647
|
+
deliveries := make([]DeliveryEvent, 0, len(results))
|
|
648
|
+
warnings := make([]string, 0)
|
|
649
|
+
for _, result := range results {
|
|
650
|
+
if result.warning != "" {
|
|
651
|
+
warnings = append(warnings, result.warning)
|
|
652
|
+
continue
|
|
653
|
+
}
|
|
654
|
+
if result.delivery != nil {
|
|
655
|
+
deliveries = append(deliveries, *result.delivery)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
return deliveries, compactWarnings(warnings)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
type selectedInningsContext struct {
|
|
662
|
+
match Match
|
|
663
|
+
team Team
|
|
664
|
+
innings Innings
|
|
665
|
+
warnings []string
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
func (s *MatchService) resolveSelectedInnings(
|
|
669
|
+
ctx context.Context,
|
|
670
|
+
query string,
|
|
671
|
+
opts MatchInningsOptions,
|
|
672
|
+
requireTeam bool,
|
|
673
|
+
) (*selectedInningsContext, *NormalizedResult) {
|
|
674
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
|
|
675
|
+
if passthrough != nil {
|
|
676
|
+
return nil, passthrough
|
|
677
|
+
}
|
|
678
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
679
|
+
teamCache := map[string]teamIdentity{}
|
|
680
|
+
scoreCache := map[string]string{}
|
|
681
|
+
lookup.warnings = append(lookup.warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
|
|
682
|
+
|
|
683
|
+
teamQuery := strings.TrimSpace(opts.TeamQuery)
|
|
684
|
+
if requireTeam && teamQuery == "" {
|
|
685
|
+
result := NormalizedResult{
|
|
686
|
+
Kind: EntityInnings,
|
|
687
|
+
Status: ResultStatusEmpty,
|
|
688
|
+
Message: "--team is required",
|
|
689
|
+
}
|
|
690
|
+
return nil, &result
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, teamQuery, opts.LeagueID)
|
|
694
|
+
if teamResult != nil {
|
|
695
|
+
return nil, teamResult
|
|
696
|
+
}
|
|
697
|
+
if len(teams) == 0 {
|
|
698
|
+
result := NormalizedResult{
|
|
699
|
+
Kind: EntityInnings,
|
|
700
|
+
Status: ResultStatusEmpty,
|
|
701
|
+
Message: "no teams available for match selection",
|
|
702
|
+
}
|
|
703
|
+
return nil, &result
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
team := teams[0]
|
|
707
|
+
inningsList, _, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
|
|
708
|
+
if len(inningsWarnings) > 0 {
|
|
709
|
+
teamWarnings = append(teamWarnings, inningsWarnings...)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if len(inningsList) == 0 {
|
|
713
|
+
result := NormalizedResult{
|
|
714
|
+
Kind: EntityInnings,
|
|
715
|
+
Status: ResultStatusEmpty,
|
|
716
|
+
Message: fmt.Sprintf("no innings found for team %q", team.ID),
|
|
717
|
+
}
|
|
718
|
+
return nil, &result
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
requestedInnings := opts.Innings
|
|
722
|
+
requestedPeriod := opts.Period
|
|
723
|
+
if requestedInnings <= 0 || requestedPeriod <= 0 {
|
|
724
|
+
result := NormalizedResult{
|
|
725
|
+
Kind: EntityInnings,
|
|
726
|
+
Status: ResultStatusEmpty,
|
|
727
|
+
Message: "--innings and --period are required",
|
|
728
|
+
}
|
|
729
|
+
return nil, &result
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
var selected *Innings
|
|
733
|
+
for i := range inningsList {
|
|
734
|
+
if inningsList[i].InningsNumber == requestedInnings && inningsList[i].Period == requestedPeriod {
|
|
735
|
+
candidate := inningsList[i]
|
|
736
|
+
if strings.TrimSpace(team.ID) != "" {
|
|
737
|
+
candidate.TeamID = strings.TrimSpace(team.ID)
|
|
738
|
+
}
|
|
739
|
+
candidate.TeamName = nonEmpty(team.ShortName, team.Name, team.ID, candidate.TeamName)
|
|
740
|
+
candidate.MatchID = nonEmpty(candidate.MatchID, lookup.match.ID)
|
|
741
|
+
candidate.CompetitionID = nonEmpty(candidate.CompetitionID, lookup.match.CompetitionID, lookup.match.ID)
|
|
742
|
+
candidate.EventID = nonEmpty(candidate.EventID, lookup.match.EventID)
|
|
743
|
+
candidate.LeagueID = nonEmpty(candidate.LeagueID, lookup.match.LeagueID)
|
|
744
|
+
selected = &candidate
|
|
745
|
+
break
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if selected == nil {
|
|
750
|
+
result := NormalizedResult{
|
|
751
|
+
Kind: EntityInnings,
|
|
752
|
+
Status: ResultStatusEmpty,
|
|
753
|
+
Message: fmt.Sprintf(
|
|
754
|
+
"requested innings/period %d/%d was not found; available: %s",
|
|
755
|
+
requestedInnings,
|
|
756
|
+
requestedPeriod,
|
|
757
|
+
availableInningsPeriods(inningsList),
|
|
758
|
+
),
|
|
759
|
+
}
|
|
760
|
+
return nil, &result
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
764
|
+
warnings = append(warnings, teamWarnings...)
|
|
765
|
+
return &selectedInningsContext{
|
|
766
|
+
match: *lookup.match,
|
|
767
|
+
team: team,
|
|
768
|
+
innings: *selected,
|
|
769
|
+
warnings: compactWarnings(warnings),
|
|
770
|
+
}, nil
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
func (s *MatchService) selectTeamsFromMatch(
|
|
774
|
+
ctx context.Context,
|
|
775
|
+
match Match,
|
|
776
|
+
teamQuery string,
|
|
777
|
+
leagueID string,
|
|
778
|
+
) ([]Team, []string, *NormalizedResult) {
|
|
779
|
+
teamQuery = strings.TrimSpace(teamQuery)
|
|
780
|
+
if teamQuery == "" {
|
|
781
|
+
teams := make([]Team, 0, len(match.Teams))
|
|
782
|
+
for _, team := range match.Teams {
|
|
783
|
+
if strings.TrimSpace(team.ID) == "" {
|
|
784
|
+
continue
|
|
785
|
+
}
|
|
786
|
+
teams = append(teams, team)
|
|
787
|
+
}
|
|
788
|
+
if len(teams) == 0 {
|
|
789
|
+
result := NormalizedResult{
|
|
790
|
+
Kind: EntityInnings,
|
|
791
|
+
Status: ResultStatusEmpty,
|
|
792
|
+
Message: "no teams available in match competitors",
|
|
793
|
+
}
|
|
794
|
+
return nil, nil, &result
|
|
795
|
+
}
|
|
796
|
+
return teams, nil, nil
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if direct := findTeamInMatch(match, teamQuery); direct != nil {
|
|
800
|
+
return []Team{*direct}, nil, nil
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
searchResult, err := s.resolver.Search(ctx, EntityTeam, teamQuery, ResolveOptions{
|
|
804
|
+
Limit: 5,
|
|
805
|
+
LeagueID: strings.TrimSpace(leagueID),
|
|
806
|
+
MatchID: strings.TrimSpace(match.ID),
|
|
807
|
+
})
|
|
808
|
+
if err != nil {
|
|
809
|
+
result := NewTransportErrorResult(EntityTeam, teamQuery, err)
|
|
810
|
+
return nil, nil, &result
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
for _, entity := range searchResult.Entities {
|
|
814
|
+
if found := matchTeamByID(match, entity.ID); found != nil {
|
|
815
|
+
return []Team{*found}, searchResult.Warnings, nil
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
result := NormalizedResult{
|
|
820
|
+
Kind: EntityTeam,
|
|
821
|
+
Status: ResultStatusEmpty,
|
|
822
|
+
Message: fmt.Sprintf("team %q not found in match; available: %s", teamQuery, availableMatchTeams(match)),
|
|
823
|
+
}
|
|
824
|
+
return nil, searchResult.Warnings, &result
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
func (s *MatchService) fetchTeamInnings(ctx context.Context, match Match, team Team) ([]Innings, string, []string) {
|
|
828
|
+
candidates := compactWarnings([]string{
|
|
829
|
+
strings.TrimSpace(team.LinescoresRef),
|
|
830
|
+
strings.TrimSpace(competitorSubresourceRef(match, team.ID, "linescores")),
|
|
831
|
+
})
|
|
832
|
+
if len(candidates) == 0 {
|
|
833
|
+
return []Innings{}, "", []string{fmt.Sprintf("linescores route unavailable for team %q", team.ID)}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
seen := map[string]struct{}{}
|
|
837
|
+
warnings := make([]string, 0)
|
|
838
|
+
for _, ref := range candidates {
|
|
839
|
+
if _, ok := seen[ref]; ok {
|
|
840
|
+
continue
|
|
841
|
+
}
|
|
842
|
+
seen[ref] = struct{}{}
|
|
843
|
+
|
|
844
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
845
|
+
if err != nil {
|
|
846
|
+
warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, err))
|
|
847
|
+
continue
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
innings, collectWarnings, collectErr := s.collectInningsFromPayload(ctx, resolved.Body)
|
|
851
|
+
warnings = append(warnings, collectWarnings...)
|
|
852
|
+
if collectErr != nil {
|
|
853
|
+
warnings = append(warnings, fmt.Sprintf("linescores %s: %v", ref, collectErr))
|
|
854
|
+
continue
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
for i := range innings {
|
|
858
|
+
if strings.TrimSpace(team.ID) != "" {
|
|
859
|
+
innings[i].TeamID = strings.TrimSpace(team.ID)
|
|
860
|
+
}
|
|
861
|
+
innings[i].TeamName = nonEmpty(team.ShortName, team.Name, team.ID, innings[i].TeamName)
|
|
862
|
+
innings[i].MatchID = nonEmpty(innings[i].MatchID, match.ID)
|
|
863
|
+
innings[i].CompetitionID = nonEmpty(innings[i].CompetitionID, match.CompetitionID, match.ID)
|
|
864
|
+
innings[i].EventID = nonEmpty(innings[i].EventID, match.EventID)
|
|
865
|
+
innings[i].LeagueID = nonEmpty(innings[i].LeagueID, match.LeagueID)
|
|
866
|
+
if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "statistics/0"); scopedRef != "" {
|
|
867
|
+
innings[i].StatisticsRef = scopedRef
|
|
868
|
+
}
|
|
869
|
+
if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "partnerships"); scopedRef != "" {
|
|
870
|
+
innings[i].PartnershipsRef = scopedRef
|
|
871
|
+
}
|
|
872
|
+
if scopedRef := inningsSubresourceRef(match, team.ID, innings[i].InningsNumber, innings[i].Period, "fow"); scopedRef != "" {
|
|
873
|
+
innings[i].FallOfWicketRef = scopedRef
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
if len(innings) > 0 {
|
|
878
|
+
return innings, resolved.CanonicalRef, compactWarnings(warnings)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return []Innings{}, "", compactWarnings(warnings)
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
func (s *MatchService) collectInningsFromPayload(ctx context.Context, body []byte) ([]Innings, []string, error) {
|
|
886
|
+
payload, err := decodePayloadMap(body)
|
|
887
|
+
if err != nil {
|
|
888
|
+
return nil, nil, err
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
warnings := make([]string, 0)
|
|
892
|
+
innings := make([]Innings, 0)
|
|
893
|
+
|
|
894
|
+
appendInningsMap := func(item map[string]any) {
|
|
895
|
+
if item == nil {
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
if stringField(item, "$ref") == "" && intField(item, "period") == 0 && intField(item, "runs") == 0 && intField(item, "wickets") == 0 && stringField(item, "score") == "" {
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
innings = append(innings, *normalizeInningsFromMap(item))
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
items := mapSliceField(payload, "items")
|
|
905
|
+
if len(items) > 0 {
|
|
906
|
+
for _, item := range items {
|
|
907
|
+
itemRef := strings.TrimSpace(stringField(item, "$ref"))
|
|
908
|
+
if itemRef != "" && intField(item, "period") == 0 && stringField(item, "score") == "" && intField(item, "runs") == 0 && intField(item, "wickets") == 0 {
|
|
909
|
+
resolved, itemErr := s.client.ResolveRefChain(ctx, itemRef)
|
|
910
|
+
if itemErr != nil {
|
|
911
|
+
warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemRef, itemErr))
|
|
912
|
+
continue
|
|
913
|
+
}
|
|
914
|
+
normalized, normalizeErr := NormalizeInnings(resolved.Body)
|
|
915
|
+
if normalizeErr != nil {
|
|
916
|
+
warnings = append(warnings, fmt.Sprintf("innings %s: %v", itemRef, normalizeErr))
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
innings = append(innings, *normalized)
|
|
920
|
+
continue
|
|
921
|
+
}
|
|
922
|
+
appendInningsMap(item)
|
|
923
|
+
}
|
|
924
|
+
return innings, compactWarnings(warnings), nil
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
appendInningsMap(payload)
|
|
928
|
+
return innings, compactWarnings(warnings), nil
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
func (s *MatchService) hydrateInningsTimelines(ctx context.Context, innings *Innings) []string {
|
|
932
|
+
if innings == nil || strings.TrimSpace(innings.StatisticsRef) == "" {
|
|
933
|
+
return nil
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
resolved, err := s.client.ResolveRefChain(ctx, innings.StatisticsRef)
|
|
937
|
+
if err != nil {
|
|
938
|
+
return []string{fmt.Sprintf("period statistics %s: %v", innings.StatisticsRef, err)}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
overs, wickets, err := NormalizeInningsPeriodStatistics(resolved.Body)
|
|
942
|
+
if err != nil {
|
|
943
|
+
return []string{fmt.Sprintf("period statistics %s: %v", resolved.CanonicalRef, err)}
|
|
944
|
+
}
|
|
945
|
+
innings.OverTimeline = overs
|
|
946
|
+
innings.WicketTimeline = wickets
|
|
947
|
+
return nil
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
func (s *MatchService) fetchDetailedRefCollection(
|
|
951
|
+
ctx context.Context,
|
|
952
|
+
ref string,
|
|
953
|
+
normalize func(itemBody []byte) (any, error),
|
|
954
|
+
) (*ResolvedDocument, []any, []string, error) {
|
|
955
|
+
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
956
|
+
if err != nil {
|
|
957
|
+
return nil, nil, nil, err
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
pageItems, warnings, err := s.resolvePageRefs(ctx, resolved)
|
|
961
|
+
if err != nil {
|
|
962
|
+
return nil, nil, nil, err
|
|
963
|
+
}
|
|
964
|
+
|
|
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
|
+
}
|
|
972
|
+
|
|
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
|
+
}
|
|
978
|
+
|
|
979
|
+
normalized, normalizeErr := normalize(itemResolved.Body)
|
|
980
|
+
if normalizeErr != nil {
|
|
981
|
+
warnings = append(warnings, fmt.Sprintf("item %s: %v", itemRef, normalizeErr))
|
|
982
|
+
continue
|
|
983
|
+
}
|
|
984
|
+
items = append(items, normalized)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return resolved, items, compactWarnings(warnings), nil
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
func (s *MatchService) resolvePageRefs(ctx context.Context, first *ResolvedDocument) ([]Ref, []string, error) {
|
|
991
|
+
if first == nil {
|
|
992
|
+
return nil, nil, fmt.Errorf("resolved page is nil")
|
|
993
|
+
}
|
|
994
|
+
page, err := DecodePage[Ref](first.Body)
|
|
995
|
+
if err != nil {
|
|
996
|
+
return nil, nil, fmt.Errorf("decode page %q: %w", first.CanonicalRef, err)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
items := append([]Ref(nil), page.Items...)
|
|
1000
|
+
if page.PageCount <= 1 {
|
|
1001
|
+
return items, nil, nil
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
warnings := make([]string, 0)
|
|
1005
|
+
baseRef := firstNonEmptyString(first.CanonicalRef, first.RequestedRef)
|
|
1006
|
+
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))
|
|
1015
|
+
continue
|
|
1016
|
+
}
|
|
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
|
|
1021
|
+
}
|
|
1022
|
+
items = append(items, nextPage.Items...)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return items, compactWarnings(warnings), nil
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
func pagedRef(ref string, page int) string {
|
|
1029
|
+
ref = strings.TrimSpace(ref)
|
|
1030
|
+
if ref == "" || page <= 1 {
|
|
1031
|
+
return ref
|
|
1032
|
+
}
|
|
1033
|
+
parsed, err := url.Parse(ref)
|
|
1034
|
+
if err != nil {
|
|
1035
|
+
separator := "?"
|
|
1036
|
+
if strings.Contains(ref, "?") {
|
|
1037
|
+
separator = "&"
|
|
1038
|
+
}
|
|
1039
|
+
return ref + separator + "page=" + strconv.Itoa(page)
|
|
1040
|
+
}
|
|
1041
|
+
query := parsed.Query()
|
|
1042
|
+
query.Set("page", strconv.Itoa(page))
|
|
1043
|
+
parsed.RawQuery = query.Encode()
|
|
1044
|
+
return parsed.String()
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
func (s *MatchService) resolveRefChainResilient(ctx context.Context, ref string) (*ResolvedDocument, error) {
|
|
1048
|
+
var lastErr error
|
|
1049
|
+
for attempt := 0; attempt < 3; attempt++ {
|
|
1050
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1051
|
+
if err == nil {
|
|
1052
|
+
return resolved, nil
|
|
1053
|
+
}
|
|
1054
|
+
lastErr = err
|
|
1055
|
+
var statusErr *HTTPStatusError
|
|
1056
|
+
if !errors.As(err, &statusErr) && !strings.Contains(strings.ToLower(err.Error()), "context deadline exceeded") {
|
|
1057
|
+
break
|
|
1058
|
+
}
|
|
1059
|
+
if statusErr != nil && statusErr.StatusCode != 503 {
|
|
1060
|
+
break
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return nil, lastErr
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
func availableInningsPeriods(innings []Innings) string {
|
|
1067
|
+
if len(innings) == 0 {
|
|
1068
|
+
return "none"
|
|
1069
|
+
}
|
|
1070
|
+
parts := make([]string, 0, len(innings))
|
|
1071
|
+
seen := map[string]struct{}{}
|
|
1072
|
+
for _, item := range innings {
|
|
1073
|
+
if item.InningsNumber == 0 || item.Period == 0 {
|
|
1074
|
+
continue
|
|
1075
|
+
}
|
|
1076
|
+
label := fmt.Sprintf("%d/%d", item.InningsNumber, item.Period)
|
|
1077
|
+
if _, ok := seen[label]; ok {
|
|
1078
|
+
continue
|
|
1079
|
+
}
|
|
1080
|
+
seen[label] = struct{}{}
|
|
1081
|
+
parts = append(parts, label)
|
|
1082
|
+
}
|
|
1083
|
+
if len(parts) == 0 {
|
|
1084
|
+
return "none"
|
|
1085
|
+
}
|
|
1086
|
+
return strings.Join(parts, ", ")
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
func findTeamInMatch(match Match, query string) *Team {
|
|
1090
|
+
query = normalizeAlias(query)
|
|
1091
|
+
queryTokens := strings.Fields(query)
|
|
1092
|
+
if query == "" {
|
|
1093
|
+
return nil
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
bestScore := 0
|
|
1097
|
+
var best *Team
|
|
1098
|
+
for i := range match.Teams {
|
|
1099
|
+
candidate := &match.Teams[i]
|
|
1100
|
+
values := []string{
|
|
1101
|
+
strings.TrimSpace(candidate.ID),
|
|
1102
|
+
strings.TrimSpace(candidate.Name),
|
|
1103
|
+
strings.TrimSpace(candidate.ShortName),
|
|
1104
|
+
strings.TrimSpace(candidate.Abbreviation),
|
|
1105
|
+
strings.TrimSpace(refIDs(candidate.Ref)["teamId"]),
|
|
1106
|
+
strings.TrimSpace(refIDs(candidate.Ref)["competitorId"]),
|
|
1107
|
+
}
|
|
1108
|
+
for _, value := range values {
|
|
1109
|
+
normalized := normalizeAlias(value)
|
|
1110
|
+
if normalized == "" {
|
|
1111
|
+
continue
|
|
1112
|
+
}
|
|
1113
|
+
score := aliasMatchScore(normalized, query, queryTokens)
|
|
1114
|
+
if score > bestScore {
|
|
1115
|
+
bestScore = score
|
|
1116
|
+
best = candidate
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
if bestScore >= 300 {
|
|
1122
|
+
return best
|
|
1123
|
+
}
|
|
1124
|
+
return nil
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
func availableMatchTeams(match Match) string {
|
|
1128
|
+
parts := make([]string, 0, len(match.Teams))
|
|
1129
|
+
for _, team := range match.Teams {
|
|
1130
|
+
name := nonEmpty(team.ShortName, team.Name, team.ID)
|
|
1131
|
+
if name == "" {
|
|
1132
|
+
continue
|
|
1133
|
+
}
|
|
1134
|
+
parts = append(parts, name)
|
|
1135
|
+
}
|
|
1136
|
+
if len(parts) == 0 {
|
|
1137
|
+
return "none"
|
|
1138
|
+
}
|
|
1139
|
+
return strings.Join(parts, ", ")
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
func inningsSubresourceRef(match Match, teamID string, innings, period int, suffix string) string {
|
|
1143
|
+
base := competitorSubresourceRef(match, teamID, "")
|
|
1144
|
+
if base == "" || innings <= 0 || period <= 0 {
|
|
1145
|
+
return ""
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
suffix = strings.Trim(strings.TrimSpace(suffix), "/")
|
|
1149
|
+
ref := fmt.Sprintf("%s/linescores/%d/%d", strings.TrimRight(base, "/"), innings, period)
|
|
1150
|
+
if suffix != "" {
|
|
1151
|
+
ref += "/" + suffix
|
|
1152
|
+
}
|
|
1153
|
+
return ref
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
func (s *MatchService) matchesFromEventRef(ctx context.Context, ref string) ([]Match, []string, error) {
|
|
1157
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1158
|
+
if err != nil {
|
|
1159
|
+
return nil, nil, err
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
matches, err := NormalizeMatchesFromEvent(resolved.Body)
|
|
1163
|
+
if err != nil {
|
|
1164
|
+
return nil, nil, err
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
return matches, nil, nil
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
func (s *MatchService) hydrateMatch(
|
|
1171
|
+
ctx context.Context,
|
|
1172
|
+
match *Match,
|
|
1173
|
+
statusCache map[string]matchStatusSnapshot,
|
|
1174
|
+
teamCache map[string]teamIdentity,
|
|
1175
|
+
scoreCache map[string]string,
|
|
1176
|
+
) []string {
|
|
1177
|
+
if match == nil {
|
|
1178
|
+
return nil
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
warnings := make([]string, 0)
|
|
1182
|
+
|
|
1183
|
+
if statusRef := strings.TrimSpace(match.StatusRef); statusRef != "" {
|
|
1184
|
+
snapshot, err := s.fetchStatus(ctx, statusRef, statusCache)
|
|
1185
|
+
if err != nil {
|
|
1186
|
+
warnings = append(warnings, fmt.Sprintf("status %s: %v", statusRef, err))
|
|
1187
|
+
} else {
|
|
1188
|
+
match.MatchState = nonEmpty(match.MatchState, snapshot.stateSummary())
|
|
1189
|
+
if strings.TrimSpace(match.Note) == "" {
|
|
1190
|
+
match.Note = snapshot.longSummary
|
|
1191
|
+
}
|
|
1192
|
+
if match.Extensions == nil {
|
|
1193
|
+
match.Extensions = map[string]any{}
|
|
1194
|
+
}
|
|
1195
|
+
match.Extensions["statusState"] = snapshot.state
|
|
1196
|
+
match.Extensions["statusDetail"] = snapshot.detail
|
|
1197
|
+
match.Extensions["statusShortDetail"] = snapshot.shortDetail
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
for i := range match.Teams {
|
|
1202
|
+
team := &match.Teams[i]
|
|
1203
|
+
|
|
1204
|
+
if strings.TrimSpace(team.Name) == "" || strings.TrimSpace(team.ShortName) == "" {
|
|
1205
|
+
identity, err := s.fetchTeamIdentity(ctx, team, teamCache)
|
|
1206
|
+
if err != nil {
|
|
1207
|
+
warnings = append(warnings, fmt.Sprintf("team %s: %v", nonEmpty(team.Ref, team.ID), err))
|
|
1208
|
+
} else {
|
|
1209
|
+
if team.Name == "" {
|
|
1210
|
+
team.Name = identity.name
|
|
1211
|
+
}
|
|
1212
|
+
if team.ShortName == "" {
|
|
1213
|
+
team.ShortName = identity.shortName
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if strings.TrimSpace(team.ScoreSummary) == "" && strings.TrimSpace(team.ScoreRef) != "" {
|
|
1219
|
+
score, err := s.fetchTeamScore(ctx, team.ScoreRef, scoreCache)
|
|
1220
|
+
if err != nil {
|
|
1221
|
+
warnings = append(warnings, fmt.Sprintf("score %s: %v", team.ScoreRef, err))
|
|
1222
|
+
} else {
|
|
1223
|
+
team.ScoreSummary = score
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
match.ScoreSummary = matchScoreSummary(match.Teams)
|
|
1229
|
+
return compactWarnings(warnings)
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
func (s *MatchService) hydrateMatchStatusOnly(
|
|
1233
|
+
ctx context.Context,
|
|
1234
|
+
match *Match,
|
|
1235
|
+
statusCache map[string]matchStatusSnapshot,
|
|
1236
|
+
) []string {
|
|
1237
|
+
if match == nil {
|
|
1238
|
+
return nil
|
|
1239
|
+
}
|
|
1240
|
+
statusRef := strings.TrimSpace(match.StatusRef)
|
|
1241
|
+
if statusRef == "" {
|
|
1242
|
+
return nil
|
|
1243
|
+
}
|
|
1244
|
+
snapshot, err := s.fetchStatus(ctx, statusRef, statusCache)
|
|
1245
|
+
if err != nil {
|
|
1246
|
+
return []string{fmt.Sprintf("status %s: %v", statusRef, err)}
|
|
1247
|
+
}
|
|
1248
|
+
match.MatchState = nonEmpty(match.MatchState, snapshot.stateSummary())
|
|
1249
|
+
if strings.TrimSpace(match.Note) == "" {
|
|
1250
|
+
match.Note = snapshot.longSummary
|
|
1251
|
+
}
|
|
1252
|
+
if match.Extensions == nil {
|
|
1253
|
+
match.Extensions = map[string]any{}
|
|
1254
|
+
}
|
|
1255
|
+
match.Extensions["statusState"] = snapshot.state
|
|
1256
|
+
match.Extensions["statusDetail"] = snapshot.detail
|
|
1257
|
+
match.Extensions["statusShortDetail"] = snapshot.shortDetail
|
|
1258
|
+
return nil
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
func (s *MatchService) enrichMatchTeamsFromIndex(match *Match) {
|
|
1262
|
+
if match == nil || s == nil || s.resolver == nil || s.resolver.index == nil {
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
for i := range match.Teams {
|
|
1266
|
+
team := &match.Teams[i]
|
|
1267
|
+
teamID := strings.TrimSpace(team.ID)
|
|
1268
|
+
if teamID == "" {
|
|
1269
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
|
|
1270
|
+
}
|
|
1271
|
+
if teamID == "" {
|
|
1272
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
|
|
1273
|
+
}
|
|
1274
|
+
if teamID == "" {
|
|
1275
|
+
continue
|
|
1276
|
+
}
|
|
1277
|
+
cached, ok := s.resolver.index.FindByID(EntityTeam, teamID)
|
|
1278
|
+
if !ok {
|
|
1279
|
+
continue
|
|
1280
|
+
}
|
|
1281
|
+
if strings.TrimSpace(team.Name) == "" {
|
|
1282
|
+
team.Name = strings.TrimSpace(cached.Name)
|
|
1283
|
+
}
|
|
1284
|
+
if strings.TrimSpace(team.ShortName) == "" {
|
|
1285
|
+
team.ShortName = strings.TrimSpace(cached.ShortName)
|
|
1286
|
+
}
|
|
1287
|
+
if strings.TrimSpace(team.Ref) == "" {
|
|
1288
|
+
team.Ref = strings.TrimSpace(cached.Ref)
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
func (s *MatchService) fetchStatus(ctx context.Context, ref string, cache map[string]matchStatusSnapshot) (matchStatusSnapshot, error) {
|
|
1294
|
+
ref = strings.TrimSpace(ref)
|
|
1295
|
+
if ref == "" {
|
|
1296
|
+
return matchStatusSnapshot{}, fmt.Errorf("status ref is empty")
|
|
1297
|
+
}
|
|
1298
|
+
if cached, ok := cache[ref]; ok {
|
|
1299
|
+
return cached, nil
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1303
|
+
if err != nil {
|
|
1304
|
+
return matchStatusSnapshot{}, err
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
1308
|
+
if err != nil {
|
|
1309
|
+
return matchStatusSnapshot{}, err
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
typed := mapField(payload, "type")
|
|
1313
|
+
status := matchStatusSnapshot{
|
|
1314
|
+
summary: stringField(payload, "summary"),
|
|
1315
|
+
longSummary: stringField(payload, "longSummary"),
|
|
1316
|
+
state: stringField(typed, "state"),
|
|
1317
|
+
detail: stringField(typed, "detail"),
|
|
1318
|
+
shortDetail: stringField(typed, "shortDetail"),
|
|
1319
|
+
description: stringField(typed, "description"),
|
|
1320
|
+
}
|
|
1321
|
+
cache[ref] = status
|
|
1322
|
+
return status, nil
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
func (s *MatchService) fetchTeamIdentity(ctx context.Context, team *Team, cache map[string]teamIdentity) (teamIdentity, error) {
|
|
1326
|
+
if team == nil {
|
|
1327
|
+
return teamIdentity{}, fmt.Errorf("team is nil")
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
ref := strings.TrimSpace(team.Ref)
|
|
1331
|
+
if ref == "" && strings.TrimSpace(team.ID) != "" {
|
|
1332
|
+
ref = "/teams/" + strings.TrimSpace(team.ID)
|
|
1333
|
+
}
|
|
1334
|
+
if ref == "" {
|
|
1335
|
+
return teamIdentity{}, fmt.Errorf("team ref is empty")
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
if cached, ok := cache[ref]; ok {
|
|
1339
|
+
return cached, nil
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1343
|
+
if err != nil {
|
|
1344
|
+
return teamIdentity{}, err
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
1348
|
+
if err != nil {
|
|
1349
|
+
return teamIdentity{}, err
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
identity := teamIdentity{
|
|
1353
|
+
name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name")),
|
|
1354
|
+
shortName: nonEmpty(stringField(payload, "shortDisplayName"), stringField(payload, "shortName"), stringField(payload, "abbreviation")),
|
|
1355
|
+
}
|
|
1356
|
+
if identity.name == "" && strings.TrimSpace(team.ID) != "" {
|
|
1357
|
+
identity.name = team.ID
|
|
1358
|
+
}
|
|
1359
|
+
cache[ref] = identity
|
|
1360
|
+
return identity, nil
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
func (s *MatchService) fetchTeamScore(ctx context.Context, ref string, cache map[string]string) (string, error) {
|
|
1364
|
+
ref = strings.TrimSpace(ref)
|
|
1365
|
+
if ref == "" {
|
|
1366
|
+
return "", fmt.Errorf("score ref is empty")
|
|
1367
|
+
}
|
|
1368
|
+
if cached, ok := cache[ref]; ok {
|
|
1369
|
+
return cached, nil
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
1373
|
+
if err != nil {
|
|
1374
|
+
return "", err
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
1378
|
+
if err != nil {
|
|
1379
|
+
return "", err
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
score := nonEmpty(stringField(payload, "displayValue"), stringField(payload, "value"), stringField(payload, "summary"))
|
|
1383
|
+
cache[ref] = score
|
|
1384
|
+
return score, nil
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
func buildMatchRef(entity IndexedEntity) string {
|
|
1388
|
+
if strings.TrimSpace(entity.Ref) != "" {
|
|
1389
|
+
return entity.Ref
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
leagueID := strings.TrimSpace(entity.LeagueID)
|
|
1393
|
+
eventID := strings.TrimSpace(entity.EventID)
|
|
1394
|
+
matchID := strings.TrimSpace(entity.ID)
|
|
1395
|
+
if leagueID == "" || eventID == "" || matchID == "" {
|
|
1396
|
+
return ""
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
return fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, eventID, matchID)
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
func matchSubresourceRef(match Match, extensionKey, suffix string) string {
|
|
1403
|
+
extensionKey = strings.TrimSpace(extensionKey)
|
|
1404
|
+
suffix = strings.Trim(strings.TrimSpace(suffix), "/")
|
|
1405
|
+
|
|
1406
|
+
if extensionKey != "" {
|
|
1407
|
+
if ref := extensionRef(match.Extensions, extensionKey); ref != "" {
|
|
1408
|
+
return ref
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
base := strings.TrimSpace(match.Ref)
|
|
1413
|
+
if base != "" {
|
|
1414
|
+
if suffix == "" {
|
|
1415
|
+
return base
|
|
1416
|
+
}
|
|
1417
|
+
return strings.TrimRight(base, "/") + "/" + suffix
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
leagueID := strings.TrimSpace(match.LeagueID)
|
|
1421
|
+
eventID := strings.TrimSpace(match.EventID)
|
|
1422
|
+
competitionID := strings.TrimSpace(match.CompetitionID)
|
|
1423
|
+
if competitionID == "" {
|
|
1424
|
+
competitionID = strings.TrimSpace(match.ID)
|
|
1425
|
+
}
|
|
1426
|
+
if leagueID == "" || eventID == "" || competitionID == "" {
|
|
1427
|
+
return ""
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
base = fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueID, eventID, competitionID)
|
|
1431
|
+
if suffix == "" {
|
|
1432
|
+
return base
|
|
1433
|
+
}
|
|
1434
|
+
return base + "/" + suffix
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
func extensionRef(extensions map[string]any, key string) string {
|
|
1438
|
+
if extensions == nil {
|
|
1439
|
+
return ""
|
|
1440
|
+
}
|
|
1441
|
+
raw, ok := extensions[key]
|
|
1442
|
+
if !ok || raw == nil {
|
|
1443
|
+
return ""
|
|
1444
|
+
}
|
|
1445
|
+
refMap, ok := raw.(map[string]any)
|
|
1446
|
+
if !ok {
|
|
1447
|
+
return ""
|
|
1448
|
+
}
|
|
1449
|
+
return strings.TrimSpace(stringField(refMap, "$ref"))
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
func isSparseSituation(situation *MatchSituation) bool {
|
|
1453
|
+
if situation == nil {
|
|
1454
|
+
return true
|
|
1455
|
+
}
|
|
1456
|
+
return len(situation.Data) == 0
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
func isLiveMatch(match Match) bool {
|
|
1460
|
+
if state := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusState"))); state == "in" {
|
|
1461
|
+
return true
|
|
1462
|
+
}
|
|
1463
|
+
if detail := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusDetail"))); detail == "live" {
|
|
1464
|
+
return true
|
|
1465
|
+
}
|
|
1466
|
+
if detail := strings.ToLower(strings.TrimSpace(statusString(match.Extensions, "statusShortDetail"))); detail == "live" {
|
|
1467
|
+
return true
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
state := strings.ToLower(strings.TrimSpace(match.MatchState))
|
|
1471
|
+
if strings.Contains(state, "live") {
|
|
1472
|
+
return true
|
|
1473
|
+
}
|
|
1474
|
+
return strings.Contains(state, " in progress") || strings.Contains(state, "stumps")
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
func statusString(extensions map[string]any, key string) string {
|
|
1478
|
+
if extensions == nil {
|
|
1479
|
+
return ""
|
|
1480
|
+
}
|
|
1481
|
+
raw, ok := extensions[key]
|
|
1482
|
+
if !ok || raw == nil {
|
|
1483
|
+
return ""
|
|
1484
|
+
}
|
|
1485
|
+
value, ok := raw.(string)
|
|
1486
|
+
if !ok {
|
|
1487
|
+
return ""
|
|
1488
|
+
}
|
|
1489
|
+
return value
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
type matchStatusSnapshot struct {
|
|
1493
|
+
summary string
|
|
1494
|
+
longSummary string
|
|
1495
|
+
state string
|
|
1496
|
+
detail string
|
|
1497
|
+
shortDetail string
|
|
1498
|
+
description string
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
func (s matchStatusSnapshot) stateSummary() string {
|
|
1502
|
+
return nonEmpty(s.summary, s.longSummary, s.shortDetail, s.detail, s.description, s.state)
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
type teamIdentity struct {
|
|
1506
|
+
name string
|
|
1507
|
+
shortName string
|
|
1508
|
+
}
|