commit ff5f7f3a6d3d6a4560e93c660537405fab0767c9 Author: filifa Date: Mon Jul 1 23:16:44 2024 -0500 initial commit diff --git a/game.go b/game.go new file mode 100644 index 0000000..04408ca --- /dev/null +++ b/game.go @@ -0,0 +1,17 @@ +package main + +import "scm.dairydemon.net/filifa/mlblive/internal/statsapi" + +type Team struct { + City string + Name string +} + +type Game struct { + GUID string + HomeTeam Team + AwayTeam Team + HomeScore int + AwayScore int + Plays []statsapi.Play +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61fb99e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module scm.dairydemon.net/filifa/mlblive + +go 1.19 + +require github.com/gorilla/websocket v1.5.3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/internal/statsapi/client.go b/internal/statsapi/client.go new file mode 100644 index 0000000..adf4735 --- /dev/null +++ b/internal/statsapi/client.go @@ -0,0 +1,90 @@ +package statsapi + +import ( + "encoding/json" + "net/http" + "net/url" +) + +var DefaultClient = NewClient(http.DefaultClient) + +func Schedule(sportId, teamId string) (ScheduleResponse, error) { + return DefaultClient.Schedule(sportId, teamId) +} + +func Feed(gamePk string) (FeedResponse, error) { + return DefaultClient.Feed(gamePk) +} + +func DiffPatch(gamePk, startTimecode, pushUpdateId string) (DiffPatchResponse, error) { + return DefaultClient.DiffPatch(gamePk, startTimecode, pushUpdateId) +} + +type Client struct { + baseURL url.URL + httpClient *http.Client +} + +func NewClient(c *http.Client) *Client { + return &Client{ + baseURL: url.URL{ + Scheme: "https", + Host: "statsapi.mlb.com", + }, + httpClient: c, + } +} + +func (c *Client) Schedule(sportId, teamId string) (ScheduleResponse, error) { + endpoint := url.URL{Path: "api/v1/schedule"} + query := endpoint.Query() + query.Add("sportId", sportId) + query.Add("teamId", teamId) + endpoint.RawQuery = query.Encode() + + url := c.baseURL.ResolveReference(&endpoint) + + var schedule ScheduleResponse + err := c.get(url.String(), &schedule) + return schedule, err +} + +func (c *Client) Feed(gamePk string) (FeedResponse, error) { + endpoint := url.URL{Path: "api/v1.1/game/" + gamePk + "/feed/live"} + url := c.baseURL.ResolveReference(&endpoint) + + var feed FeedResponse + err := c.get(url.String(), &feed) + return feed, err +} + +func (c *Client) DiffPatch(gamePk, startTimecode, pushUpdateId string) (DiffPatchResponse, error) { + endpoint := url.URL{Path: "api/v1.1/game/" + gamePk + "/feed/live/diffPatch"} + query := endpoint.Query() + query.Add("language", "en") + query.Add("startTimecode", startTimecode) + query.Add("pushUpdateId", pushUpdateId) + endpoint.RawQuery = query.Encode() + + url := c.baseURL.ResolveReference(&endpoint) + + var diffPatch DiffPatchResponse + err := c.get(url.String(), &diffPatch) + return diffPatch, err +} + +func (c *Client) get(url string, v any) error { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + err = json.NewDecoder(resp.Body).Decode(v) + return err +} diff --git a/internal/statsapi/common.go b/internal/statsapi/common.go new file mode 100644 index 0000000..4a233b9 --- /dev/null +++ b/internal/statsapi/common.go @@ -0,0 +1,34 @@ +package statsapi + +var TeamIds = map[string]int{ + "laa": 108, + "az": 109, + "bal": 110, + "bos": 111, + "chc": 112, + "cin": 113, + "cle": 114, + "col": 115, + "det": 116, + "hou": 117, + "kc": 118, + "lad": 119, + "wsh": 120, + "nym": 121, + "oak": 133, + "pit": 134, + "sd": 135, + "sea": 136, + "sf": 137, + "stl": 138, + "tb": 139, + "tex": 140, + "tor": 141, + "min": 142, + "phi": 143, + "atl": 144, + "cws": 145, + "mia": 146, + "nyy": 147, + "mil": 158, +} diff --git a/internal/statsapi/feed.go b/internal/statsapi/feed.go new file mode 100644 index 0000000..861e0cf --- /dev/null +++ b/internal/statsapi/feed.go @@ -0,0 +1,70 @@ +package statsapi + +import ( + "encoding/json" +) + +type FeedParams struct { + GamePk string +} + +type FeedResponse struct { + MetaData metadata + LiveData livedata +} + +type metadata struct { + TimeStamp string +} + +type livedata struct { + Plays plays +} + +type plays struct { + AllPlays []Play + CurrentPlay Play + ScoringPlays []json.Number +} + +type Play struct { + Result result + About about + AtBatIndex int +} + +type result struct { + Event string + Description string + RBI int + AwayScore int + HomeScore int +} + +type about struct { + AtBatIndex json.Number + IsTopInning bool + Inning json.Number + IsScoringPlay bool + CaptivatingIndex json.Number +} + +func (p *Play) Patch(patch Patch) { + instructions := patch.Diff + stem := "/liveData/plays/currentPlay" + for _, i := range instructions { + if i.Op == "add" && i.Path == stem+"/result/description" { + p.Result.Description = i.Value.(string) + } else if i.Op == "replace" && i.Path == stem+"/about/isScoringPlay" { + p.About.IsScoringPlay = i.Value.(bool) + } else if i.Op == "replace" && i.Path == stem+"/result/homeScore" { + p.Result.HomeScore = int(i.Value.(float64)) + } else if i.Op == "replace" && i.Path == stem+"/result/awayScore" { + p.Result.AwayScore = int(i.Value.(float64)) + } else if i.Op == "add" && i.Path == stem+"/result/event" { + p.Result.Event = i.Value.(string) + } else if i.Op == "replace" && i.Path == stem+"/atBatIndex" { + p.AtBatIndex = int(i.Value.(float64)) + } + } +} diff --git a/internal/statsapi/patch.go b/internal/statsapi/patch.go new file mode 100644 index 0000000..fa0495c --- /dev/null +++ b/internal/statsapi/patch.go @@ -0,0 +1,34 @@ +package statsapi + +import ( + "errors" +) + +type DiffPatchParams struct { + GamePk string + StartTimecode string + PushUpdateId string +} + +type DiffPatchResponse []Patch + +type Patch struct { + Diff []instruction +} + +type instruction struct { + Op string + Path string + Value any + From string +} + +func (p *Patch) Timestamp() (string, error) { + for _, d := range p.Diff { + if d.Op == "replace" && d.Path == "/metaData/timeStamp" { + return d.Value.(string), nil + } + } + + return "", errors.New("could not find replacement timestamp") +} diff --git a/internal/statsapi/schedule.go b/internal/statsapi/schedule.go new file mode 100644 index 0000000..f628b75 --- /dev/null +++ b/internal/statsapi/schedule.go @@ -0,0 +1,31 @@ +package statsapi + +import ( + "encoding/json" +) + +type ScheduleParams struct { + SportId string + TeamId string +} + +type ScheduleResponse struct { + TotalGames json.Number + TotalGamesInProgress json.Number + Dates []date +} + +type date struct { + TotalGames json.Number + TotalGamesInProgress json.Number + Games []game +} + +type game struct { + GamePk json.Number + Content content +} + +type content struct { + Link string +} diff --git a/internal/statsapi/websocket.go b/internal/statsapi/websocket.go new file mode 100644 index 0000000..c64f460 --- /dev/null +++ b/internal/statsapi/websocket.go @@ -0,0 +1,59 @@ +package statsapi + +import ( + "net/url" + "time" + + "github.com/gorilla/websocket" +) + +type Push struct { + UpdateId string +} + +type GamedayWebsocket struct { + baseURL url.URL + *websocket.Conn +} + +func NewGamedayWebsocket(gamePk string) (GamedayWebsocket, error) { + ws := GamedayWebsocket{ + baseURL: url.URL{ + Scheme: "wss", + Host: "ws.statsapi.mlb.com", + }, + } + + err := ws.init(gamePk) + return ws, err +} + +func (g *GamedayWebsocket) init(gamePk string) error { + endpoint := url.URL{ + Path: "api/v1/game/push/subscribe/gameday/" + gamePk, + } + + url := g.baseURL.ResolveReference(&endpoint) + conn, _, err := websocket.DefaultDialer.Dial(url.String(), nil) + + g.Conn = conn + return err +} + +func (g *GamedayWebsocket) SendKeepAlive() error { + msg := []byte("Gameday5") + err := g.Conn.WriteMessage(websocket.TextMessage, msg) + return err +} + +func (g *GamedayWebsocket) KeepAlive(interval time.Duration, ch chan<- error) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + err := g.SendKeepAlive() + if err != nil { + ch <- err + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9549d8f --- /dev/null +++ b/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strconv" + "time" + + "scm.dairydemon.net/filifa/mlblive/internal/statsapi" +) + +func feedUpdate(gamePk string, ts *string) (statsapi.Play, error) { + feed, err := statsapi.Feed(gamePk) + if err != nil { + return statsapi.Play{}, err + } + + *ts = feed.MetaData.TimeStamp + return feed.LiveData.Plays.CurrentPlay, nil +} + +func patchUpdate(patches statsapi.DiffPatchResponse, ts *string) []statsapi.Play { + var plays []statsapi.Play + var err error + for _, patch := range patches { + var play statsapi.Play + play.Patch(patch) + plays = append(plays, play) + + *ts, err = patch.Timestamp() + if err != nil { + continue + } + } + + return plays +} + +func update(gamePk string, ts *string, updateId string) ([]statsapi.Play, error) { + var plays []statsapi.Play + + patches, err := statsapi.DiffPatch(gamePk, *ts, updateId) + if err != nil { + log.Println("feed update") + var p statsapi.Play + p, err = feedUpdate(gamePk, ts) + if err != nil { + return plays, err + } + + plays = []statsapi.Play{p} + } else { + log.Println("patch update") + plays = patchUpdate(patches, ts) + } + + return plays, err +} + +func writeTest(v any, ts string) { + b, err := json.Marshal(v) + if err != nil { + log.Println(err) + } + + os.WriteFile("examples/data/data_"+ts+".json", b, 0644) +} + +func getGamePk() string { + teamId := flag.String("t", "", "team to get updates for") + flag.Parse() + if *teamId == "" { + log.Fatal("need team ID") + } + + id, ok := statsapi.TeamIds[*teamId] + if !ok { + log.Fatal("invalid team ID") + } + + sched, err := statsapi.Schedule("1", strconv.Itoa(id)) + if err != nil { + log.Fatal(err) + } + + gamePk := sched.Dates[0].Games[0].GamePk.String() + return gamePk +} + +func main() { + gamePk := getGamePk() + + ws, err := statsapi.NewGamedayWebsocket(gamePk) + if err != nil { + log.Fatal(err) + } + defer ws.Close() + + ch := make(chan error) + go ws.KeepAlive(10*time.Second, ch) + + var ts string + _, err = feedUpdate(gamePk, &ts) + if err != nil { + log.Fatal(err) + } + + for { + var p statsapi.Push + err := ws.ReadJSON(&p) + if err != nil { + log.Fatal(err) + } + + plays, err := update(gamePk, &ts, p.UpdateId) + if err != nil { + log.Println(err) + continue + } + + for _, play := range plays { + result := play.Result + if result.Event != "" { + // TODO: figure out format + // maybe a string format with play/result type? + // FIXME: proper score on patch updates + // FIXME: repeated output on feed updates + fmt.Printf("%v (%v-%v)\n", result.Description, result.AwayScore, result.HomeScore) + } + } + } +}