initial commit

This commit is contained in:
filifa 2024-07-01 23:16:44 -05:00
commit ff5f7f3a6d
10 changed files with 477 additions and 0 deletions

17
game.go Normal file
View File

@ -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
}

5
go.mod Normal file
View File

@ -0,0 +1,5 @@
module scm.dairydemon.net/filifa/mlblive
go 1.19
require github.com/gorilla/websocket v1.5.3 // indirect

2
go.sum Normal file
View File

@ -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=

View File

@ -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
}

View File

@ -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,
}

70
internal/statsapi/feed.go Normal file
View File

@ -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))
}
}
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}
}
}

135
main.go Normal file
View File

@ -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)
}
}
}
}