// Package espn is a thin client over ESPN's public (unofficial) scoreboard // API. It fetches one league's scoreboard and maps the JSON into the // league-agnostic model types the UI consumes. package espn import ( "context" "encoding/json" "fmt" "net/http" "sort" "strconv" "strings" "time" "github.com/humdrum-tiv/sportsball/internal/model" ) const baseURL = "https://site.api.espn.com/apis/site/v2/sports" // Client fetches scoreboards. Safe for concurrent use. type Client struct { HTTP *http.Client } // New returns a Client with a sane default timeout. func New() *Client { return &Client{HTTP: &http.Client{Timeout: 10 * time.Second}} } // Scoreboard fetches and normalizes the current scoreboard for one league. // On a given calendar day ESPN returns that day's slate; games carry their // own state (pre/live/final) so the dashboard can bucket them. func (c *Client) Scoreboard(ctx context.Context, l model.League) ([]model.Game, error) { return c.scoreboard(ctx, l, "") } // ScoreboardRange fetches games for the inclusive [start, end] day window so // the UI can show recent finals and upcoming schedules, not just today. ESPN // accepts a "YYYYMMDD-YYYYMMDD" dates param. func (c *Client) ScoreboardRange(ctx context.Context, l model.League, start, end time.Time) ([]model.Game, error) { // limit is required: without it ESPN caps results (~50) and silently // truncates dense, wide-window slates (e.g. MLB across 10+ days). q := fmt.Sprintf("?dates=%s-%s&limit=300", start.Format("20060102"), end.Format("20060102")) return c.scoreboard(ctx, l, q) } func (c *Client) scoreboard(ctx context.Context, l model.League, query string) ([]model.Game, error) { url := fmt.Sprintf("%s/%s/%s/scoreboard%s", baseURL, l.Sport, l.Path, query) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("fetch %s: %w", l.ID, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fetch %s: status %d", l.ID, resp.StatusCode) } var sb scoreboard if err := json.NewDecoder(resp.Body).Decode(&sb); err != nil { return nil, fmt.Errorf("decode %s: %w", l.ID, err) } games := make([]model.Game, 0, len(sb.Events)) for _, ev := range sb.Events { if g, ok := mapEvent(l.ID, ev); ok { games = append(games, g) } } sortGames(games) return games, nil } func mapEvent(id model.LeagueID, ev event) (model.Game, bool) { if len(ev.Competitions) == 0 { return model.Game{}, false } comp := ev.Competitions[0] st := comp.Status if st.Type.State == "" { st = ev.Status } g := model.Game{ ID: ev.ID, League: id, Start: parseTime(ev.Date), State: mapState(st.Type.State), Detail: strings.TrimSpace(st.Type.Detail), Clock: st.DisplayClock, Period: st.Period, Venue: comp.Venue.FullName, } if len(comp.Headlines) > 0 { g.Headline = comp.Headlines[0].Description } for _, cmp := range comp.Competitors { t := mapTeam(cmp) if cmp.HomeAway == "home" { g.Home = t } else { g.Away = t } } if g.State == model.StateLive { g.Situation = mapSituation(comp.Situation) } return g, true } // mapSituation converts ESPN's live competition situation into model.Situation, // returning nil when there's nothing useful (no pitcher/batter resolved — the // case for sports that don't ship a situation, or a between-innings gap). func mapSituation(s situationJSON) *model.Situation { pitcher := firstNonEmpty(s.Pitcher.Athlete.DisplayName, s.Pitcher.Athlete.ShortName) batter := firstNonEmpty(s.Batter.Athlete.DisplayName, s.Batter.Athlete.ShortName) if pitcher == "" && batter == "" { return nil } return &model.Situation{ Balls: s.Balls, Strikes: s.Strikes, Outs: s.Outs, OnFirst: s.OnFirst, OnSecond: s.OnSecond, OnThird: s.OnThird, Pitcher: pitcher, Batter: batter, PitcherLine: strings.TrimSpace(s.Pitcher.Summary), BatterLine: strings.TrimSpace(s.Batter.Summary), LastPlay: strings.TrimSpace(s.LastPlay.Text), } } func mapTeam(c competitor) model.Team { t := teamFromJSON(c.Team) t.Score = atoi(c.Score) t.Record = overallRecord(c.Records) t.Winner = c.Winner return t } // teamFromJSON maps the shared ESPN team object into a model.Team's identity // and display fields (no per-game score/record/winner). Used by the scoreboard, // standings, and schedule mappers. func teamFromJSON(t teamJSON) model.Team { name := t.ShortDisplayName if name == "" { name = t.Name } return model.Team{ ID: t.ID, Abbr: t.Abbreviation, Name: name, FullName: t.DisplayName, Color: t.Color, AltColor: t.AlternateColor, } } // overallRecord returns the "total" record summary if present, else the first. func overallRecord(rs []record) string { for _, r := range rs { if r.Type == "total" { return r.Summary } } if len(rs) > 0 { return rs[0].Summary } return "" } func mapState(s string) model.State { switch s { case "in": return model.StateLive case "post": return model.StateFinal default: return model.StatePre } } // sortGames orders live first, then upcoming (by start), then finals. func sortGames(gs []model.Game) { rank := func(s model.State) int { switch s { case model.StateLive: return 0 case model.StatePre: return 1 default: return 2 } } sort.SliceStable(gs, func(i, j int) bool { if rank(gs[i].State) != rank(gs[j].State) { return rank(gs[i].State) < rank(gs[j].State) } return gs[i].Start.Before(gs[j].Start) }) } func parseTime(s string) time.Time { t, err := time.Parse("2006-01-02T15:04Z", s) if err != nil { // ESPN sometimes uses full RFC3339 with seconds. t, _ = time.Parse(time.RFC3339, s) } return t.Local() } func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)) return n }