initial commit
This commit is contained in:
commit
ff5f7f3a6d
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
module scm.dairydemon.net/filifa/mlblive
|
||||||
|
|
||||||
|
go 1.19
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3 // indirect
|
|
@ -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=
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue