Files
MLBApp/mlbTVOS/Services/MLBStatsAPI.swift
Trey t 88308b46f5 Add game center, per-model shuffle, audio focus fixes, README, tests
- README.md with build/architecture overview
- Game Center screen with at-bat timeline, pitch sequence, spray chart,
  and strike zone component views
- VideoShuffle service: per-model bucketed random selection with
  no-back-to-back guarantee; replaces flat shuffle-bag approach
- Refresh JWT token for authenticated NSFW feed; add josie-hamming-2
  and dani-speegle-2 to the user list
- MultiStreamView audio focus: remove redundant isMuted writes during
  startStream and playNextWerkoutClip so audio stops ducking during
  clip transitions; gate AVAudioSession.setCategory(.playback) behind
  a one-shot flag
- GamesViewModel.attachPlayer: skip mute recalculation when the same
  player is re-attached (prevents toggle flicker on item replace)
- mlbTVOSTests target wired through project.yml with
  GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel,
  pickRandomFromBuckets, real-distribution no-back-to-back invariant,
  and uniform model distribution over 6000 picks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:02:46 -05:00

1168 lines
33 KiB
Swift

import Foundation
/// Fetches supplementary game data from the public MLB Stats API.
actor MLBStatsAPI {
private let baseURL = "https://statsapi.mlb.com/api/v1"
func fetchStandings(season: String) async throws -> [Int: TeamStanding] {
let response: StandingsResponse = try await fetchJSON(
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team"
)
var result: [Int: TeamStanding] = [:]
for record in response.records {
let divisionName = record.division?.name
for tr in record.teamRecords {
result[tr.team.id] = TeamStanding(
divisionRank: tr.divisionRank,
gamesBack: tr.gamesBack == "-" ? nil : tr.gamesBack,
streak: tr.streak?.streakCode,
divisionName: divisionName
)
}
}
return result
}
func fetchSchedule(date: String) async throws -> [StatsGame] {
let response: MLBScheduleResponse = try await fetchJSON(
"\(baseURL)/schedule?sportId=1&date=\(date)&hydrate=team(record),linescore,probablePitcher,venue"
)
return response.dates.flatMap(\.games)
}
func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] {
let response: StandingsResponse = try await fetchJSON(
"\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team"
)
return response.records
}
func fetchGameFeed(gamePk: String) async throws -> LiveGameFeed {
try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live")
}
func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] {
try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability")
}
func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] {
let response: TeamsResponse = try await fetchJSON(
"\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record"
)
return response.teams
.filter(\.active)
.sorted { $0.name < $1.name }
.map(LeagueTeamSummary.init)
}
func fetchTeamProfile(teamID: Int, season: String) async throws -> TeamProfile {
let response: TeamsResponse = try await fetchJSON(
"\(baseURL)/teams/\(teamID)?sportId=1&season=\(season)&hydrate=league,division,venue,record"
)
guard let team = response.teams.first else {
throw StatsAPIError.notFound
}
return TeamProfile(team: team)
}
func fetchTeamRoster(teamID: Int, season: String) async throws -> [RosterPlayerSummary] {
let response: TeamRosterResponse = try await fetchJSON(
"\(baseURL)/teams/\(teamID)/roster?season=\(season)"
)
return response.roster
.sorted { lhs, rhs in
lhs.person.fullName < rhs.person.fullName
}
.map(RosterPlayerSummary.init)
}
func fetchPlayerProfile(personID: Int, season: String) async throws -> PlayerProfile {
async let personTask: PeopleResponse = fetchJSON("\(baseURL)/people/\(personID)")
async let statsTask: StatsQueryResponse = fetchJSON(
"\(baseURL)/people/\(personID)/stats?stats=yearByYear&group=hitting,pitching,fielding&sportIds=1"
)
let personResponse = try await personTask
let statsResponse = try await statsTask
guard let person = personResponse.people.first else {
throw StatsAPIError.notFound
}
let resolvedSeason = resolvePlayerStatsSeason(
requestedSeason: season,
from: statsResponse.stats
)
let filteredStatGroups = statsResponse.stats.compactMap { group in
group.filteredForPlayerProfile(season: resolvedSeason)
}
return PlayerProfile(
person: person,
season: resolvedSeason ?? season,
statGroups: filteredStatGroups
)
}
private func resolvePlayerStatsSeason(requestedSeason: String, from groups: [StatsQueryGroup]) -> String? {
if groups.contains(where: { $0.hasRegularSeasonData(for: requestedSeason) }) {
return requestedSeason
}
return groups
.flatMap(\.splits)
.filter(\.isRegularSeason)
.compactMap { split in
split.season.flatMap(Int.init)
}
.max()
.map(String.init)
}
private func fetchJSON<T: Decodable>(_ urlString: String) async throws -> T {
guard let url = URL(string: urlString) else {
throw StatsAPIError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw StatsAPIError.requestFailed
}
return try JSONDecoder().decode(T.self, from: data)
}
}
// MARK: - Codable Models
struct MLBScheduleResponse: Codable, Sendable {
let dates: [ScheduleDate]
}
struct ScheduleDate: Codable, Sendable {
let date: String
let games: [StatsGame]
}
struct StatsGame: Codable, Sendable, Identifiable {
let gamePk: Int
let gameDate: String?
let status: StatsGameStatus
let teams: StatsTeams
let linescore: StatsLinescore?
let venue: StatsVenue?
let seriesDescription: String?
let gameType: String?
var id: Int { gamePk }
var isLive: Bool {
status.abstractGameState == "Live"
}
var isFinal: Bool {
status.abstractGameState == "Final"
}
var isScheduled: Bool {
status.abstractGameState == "Preview"
}
/// Format start time from gameDate ISO string
var startTime: String? {
guard let dateStr = gameDate else { return nil }
let isoFormatter = ISO8601DateFormatter()
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = isoFormatter.date(from: dateStr)
?? ISO8601DateFormatter().date(from: dateStr) else { return nil }
let displayFormatter = DateFormatter()
displayFormatter.dateFormat = "h:mm a"
displayFormatter.timeZone = .current
return displayFormatter.string(from: date)
}
}
struct StatsGameStatus: Codable, Sendable {
let detailedState: String?
let abstractGameState: String?
let statusCode: String?
}
struct StatsTeams: Codable, Sendable {
let away: StatsTeamGameInfo
let home: StatsTeamGameInfo
}
struct StatsTeamGameInfo: Codable, Sendable {
let team: StatsTeamBasic
let score: Int?
let leagueRecord: StatsLeagueRecord?
let probablePitcher: StatsPitcher?
}
struct StatsTeamBasic: Codable, Sendable {
let id: Int
let name: String?
let abbreviation: String?
}
struct StatsLeagueRecord: Codable, Sendable {
let wins: Int
let losses: Int
let pct: String?
}
struct StatsPitcher: Codable, Sendable {
let id: Int
let fullName: String?
}
struct StatsLinescore: Codable, Sendable {
let innings: [StatsInningScore]?
let teams: StatsLinescoreTeams?
let currentInning: Int?
let currentInningOrdinal: String?
let inningState: String?
let inningHalf: String?
let isTopInning: Bool?
let scheduledInnings: Int?
let balls: Int?
let strikes: Int?
let outs: Int?
var currentInningDisplay: String? {
guard let ordinal = currentInningOrdinal,
let half = inningHalf else { return nil }
return "\(half) \(ordinal)"
}
var hasData: Bool {
guard let innings, !innings.isEmpty else { return false }
return innings.contains { ($0.away?.runs != nil) || ($0.home?.runs != nil) }
}
}
struct StatsInningScore: Codable, Sendable {
let num: Int
let away: StatsInningRuns?
let home: StatsInningRuns?
}
struct StatsInningRuns: Codable, Sendable {
let runs: Int?
let hits: Int?
let errors: Int?
}
struct StatsLinescoreTeams: Codable, Sendable {
let away: StatsLinescoreTotals?
let home: StatsLinescoreTotals?
}
struct StatsLinescoreTotals: Codable, Sendable {
let runs: Int?
let hits: Int?
let errors: Int?
}
struct StatsVenue: Codable, Sendable {
let name: String?
}
// MARK: - Standings Models
struct TeamStanding: Sendable {
let divisionRank: String?
let gamesBack: String?
let streak: String?
let divisionName: String?
}
struct StandingsResponse: Codable, Sendable {
let records: [StandingsDivisionRecord]
}
struct StandingsDivisionRecord: Codable, Sendable {
let division: StandingsDivision?
let teamRecords: [StandingsTeamRecord]
}
struct StandingsDivision: Codable, Sendable {
let id: Int?
let name: String?
}
struct StandingsTeamRecord: Codable, Sendable {
let team: StatsTeamBasic
let divisionRank: String?
let gamesBack: String?
let streak: StandingsStreak?
let wins: Int?
let losses: Int?
}
struct StandingsStreak: Codable, Sendable {
let streakCode: String?
}
enum StatsAPIError: Error {
case invalidURL
case requestFailed
case notFound
}
// MARK: - League Hub Models
struct TeamsResponse: Codable, Sendable {
let teams: [LeagueTeam]
}
struct LeagueTeam: Codable, Sendable, Identifiable {
let id: Int
let name: String
let abbreviation: String?
let teamName: String?
let locationName: String?
let clubName: String?
let franchiseName: String?
let firstYearOfPlay: String?
let league: LeagueReference?
let division: LeagueReference?
let venue: LeagueVenue?
let record: LeagueRecordSummary?
let active: Bool
}
struct LeagueReference: Codable, Sendable {
let id: Int?
let name: String?
let abbreviation: String?
}
struct LeagueVenue: Codable, Sendable {
let id: Int?
let name: String?
}
struct LeagueRecordSummary: Codable, Sendable {
let gamesPlayed: Int?
let divisionGamesBack: String?
let wildCardGamesBack: String?
let leagueRecord: LeagueRecordLine?
let wins: Int?
let losses: Int?
let winningPercentage: String?
}
struct LeagueRecordLine: Codable, Sendable {
let wins: Int
let losses: Int
let ties: Int?
let pct: String?
}
struct LeagueTeamSummary: Identifiable, Sendable {
let id: Int
let name: String
let abbreviation: String
let locationName: String
let clubName: String
let leagueName: String?
let divisionName: String?
let venueName: String?
let recordText: String?
init(team: LeagueTeam) {
id = team.id
name = team.name
abbreviation = team.abbreviation ?? team.teamName ?? "MLB"
locationName = team.locationName ?? team.name
clubName = team.clubName ?? team.teamName ?? team.name
leagueName = team.league?.name
divisionName = team.division?.name
venueName = team.venue?.name
if let wins = team.record?.wins, let losses = team.record?.losses {
recordText = "\(wins)-\(losses)"
} else {
recordText = nil
}
}
var teamInfo: TeamInfo {
TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText)
}
}
struct TeamProfile: Identifiable, Sendable {
let id: Int
let name: String
let abbreviation: String
let locationName: String
let clubName: String
let franchiseName: String?
let firstYearOfPlay: String?
let venueName: String?
let leagueName: String?
let divisionName: String?
let recordText: String?
let gamesBackText: String?
init(team: LeagueTeam) {
id = team.id
name = team.name
abbreviation = team.abbreviation ?? team.teamName ?? "MLB"
locationName = team.locationName ?? team.name
clubName = team.clubName ?? team.teamName ?? team.name
franchiseName = team.franchiseName
firstYearOfPlay = team.firstYearOfPlay
venueName = team.venue?.name
leagueName = team.league?.name
divisionName = team.division?.name
if let wins = team.record?.wins, let losses = team.record?.losses {
recordText = "\(wins)-\(losses)"
} else {
recordText = nil
}
if let divisionGamesBack = team.record?.divisionGamesBack, divisionGamesBack != "-" {
gamesBackText = "\(divisionGamesBack) GB"
} else {
gamesBackText = nil
}
}
var teamInfo: TeamInfo {
TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText)
}
}
struct TeamRosterResponse: Codable, Sendable {
let roster: [RosterEntry]
}
struct RosterEntry: Codable, Sendable, Identifiable {
let person: RosterPerson
let jerseyNumber: String?
let position: RosterPosition?
let status: RosterStatus?
var id: Int { person.id }
}
struct RosterPerson: Codable, Sendable {
let id: Int
let fullName: String
}
struct RosterPosition: Codable, Sendable {
let code: String?
let name: String?
let type: String?
let abbreviation: String?
}
struct RosterStatus: Codable, Sendable {
let code: String?
let description: String?
}
struct RosterPlayerSummary: Identifiable, Sendable {
let id: Int
let fullName: String
let jerseyNumber: String?
let positionAbbreviation: String?
let status: String?
init(entry: RosterEntry) {
id = entry.person.id
fullName = entry.person.fullName
jerseyNumber = entry.jerseyNumber
positionAbbreviation = entry.position?.abbreviation
status = entry.status?.description
}
var headshotURL: URL {
URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")!
}
}
struct PeopleResponse: Codable, Sendable {
let people: [PersonDetail]
}
struct PersonDetail: Codable, Sendable, Identifiable {
let id: Int
let fullName: String
let firstName: String?
let lastName: String?
let primaryNumber: String?
let birthDate: String?
let currentAge: Int?
let birthCity: String?
let birthStateProvince: String?
let birthCountry: String?
let height: String?
let weight: Int?
let active: Bool?
let primaryPosition: PersonPosition?
let batSide: Handedness?
let pitchHand: Handedness?
let mlbDebutDate: String?
}
struct PersonPosition: Codable, Sendable {
let code: String?
let name: String?
let type: String?
let abbreviation: String?
}
struct Handedness: Codable, Sendable {
let code: String?
let description: String?
}
struct StatsQueryResponse: Codable, Sendable {
let stats: [StatsQueryGroup]
}
struct StatsQueryGroup: Codable, Sendable, Identifiable {
let type: StatsQueryDescriptor?
let group: StatsQueryDescriptor?
let splits: [StatsQuerySplit]
var id: String {
"\(group?.displayName ?? "stats")-\(type?.displayName ?? "season")"
}
}
struct StatsQueryDescriptor: Codable, Sendable {
let displayName: String?
}
struct StatsQuerySplit: Codable, Sendable {
let season: String?
let gameType: String?
let stat: [String: JSONValue]
}
struct PlayerProfile: Identifiable, Sendable {
let id: Int
let fullName: String
let primaryNumber: String?
let primaryPosition: String?
let currentAge: Int?
let birthDate: String?
let birthPlace: String?
let height: String?
let weight: Int?
let bats: String?
let throwsHand: String?
let debutDate: String?
let seasonLabel: String
let statGroups: [PlayerStatSummary]
init(person: PersonDetail, season: String, statGroups: [StatsQueryGroup]) {
id = person.id
fullName = person.fullName
primaryNumber = person.primaryNumber
primaryPosition = person.primaryPosition?.abbreviation ?? person.primaryPosition?.name
currentAge = person.currentAge
birthDate = person.birthDate
let placeParts = [person.birthCity, person.birthStateProvince, person.birthCountry]
.compactMap { $0?.isEmpty == false ? $0 : nil }
birthPlace = placeParts.isEmpty ? nil : placeParts.joined(separator: ", ")
height = person.height
weight = person.weight
bats = person.batSide?.description
throwsHand = person.pitchHand?.description
debutDate = person.mlbDebutDate
seasonLabel = season
self.statGroups = statGroups.compactMap(PlayerStatSummary.init)
}
var headshotURL: URL {
URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")!
}
}
struct PlayerStatSummary: Identifiable, Sendable {
let id: String
let title: String
let items: [PlayerStatItem]
init?(group: StatsQueryGroup) {
guard let split = group.splits.first else { return nil }
let groupName = group.group?.displayName ?? "Stats"
let title = groupName.capitalized
let preferredKeys: [String]
switch groupName.lowercased() {
case "hitting":
preferredKeys = ["avg", "obp", "slg", "ops", "homeRuns", "rbi", "hits", "runs", "baseOnBalls", "stolenBases"]
case "pitching":
preferredKeys = ["era", "wins", "losses", "saves", "inningsPitched", "strikeOuts", "whip", "baseOnBalls", "hits", "runs"]
case "fielding":
preferredKeys = ["fielding", "assists", "putOuts", "errors", "chances", "gamesStarted"]
default:
preferredKeys = Array(split.stat.keys.sorted().prefix(8))
}
let items = preferredKeys.compactMap { key -> PlayerStatItem? in
guard let value = split.stat[key]?.displayString else { return nil }
return PlayerStatItem(label: key.statLabel, value: value)
}
guard !items.isEmpty else { return nil }
id = group.id
self.title = title
self.items = items
}
}
private extension StatsQueryGroup {
func hasRegularSeasonData(for season: String) -> Bool {
splits.contains { split in
split.isRegularSeason && split.season == season
}
}
func filteredForPlayerProfile(season: String?) -> StatsQueryGroup? {
guard let season else { return nil }
let filteredSplits = splits.filter { split in
split.isRegularSeason && split.season == season
}
guard !filteredSplits.isEmpty else { return nil }
return StatsQueryGroup(
type: type,
group: group,
splits: filteredSplits
)
}
}
private extension StatsQuerySplit {
var isRegularSeason: Bool {
guard let gameType else { return true }
return gameType == "R"
}
}
struct PlayerStatItem: Sendable {
let label: String
let value: String
}
enum JSONValue: Codable, Sendable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode(Int.self) {
self = .int(value)
} else if let value = try? container.decode(Double.self) {
self = .double(value)
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
} else if let value = try? container.decode([JSONValue].self) {
self = .array(value)
} else {
throw DecodingError.typeMismatch(
JSONValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON value")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let value):
try container.encode(value)
case .int(let value):
try container.encode(value)
case .double(let value):
try container.encode(value)
case .bool(let value):
try container.encode(value)
case .object(let value):
try container.encode(value)
case .array(let value):
try container.encode(value)
case .null:
try container.encodeNil()
}
}
var displayString: String? {
switch self {
case .string(let value):
return value
case .int(let value):
return String(value)
case .double(let value):
if value.rounded() == value {
return String(Int(value))
}
return String(format: "%.3f", value)
.replacingOccurrences(of: #"(\.\d*?[1-9])0+$"#, with: "$1", options: .regularExpression)
.replacingOccurrences(of: #"\.0+$"#, with: "", options: .regularExpression)
case .bool(let value):
return value ? "Yes" : "No"
case .object, .array, .null:
return nil
}
}
}
private extension String {
var statLabel: String {
switch self {
case "avg": return "AVG"
case "obp": return "OBP"
case "slg": return "SLG"
case "ops": return "OPS"
case "era": return "ERA"
case "whip": return "WHIP"
case "rbi": return "RBI"
default:
let pattern = "([a-z0-9])([A-Z])"
return replacingOccurrences(of: pattern, with: "$1 $2", options: .regularExpression)
.replacingOccurrences(of: "_", with: " ")
.capitalized
}
}
}
// MARK: - Live Game Feed Models
struct LiveGameFeed: Codable, Sendable {
let metaData: LiveFeedMetaData?
let gameData: LiveFeedGameData
let liveData: LiveFeedData
var recentPlays: [LiveFeedPlay] {
Array(liveData.plays.allPlays.suffix(12).reversed())
}
var scoringPlays: [LiveFeedPlay] {
liveData.plays.allPlays.filter { $0.about?.isScoringPlay == true }
}
var currentPlay: LiveFeedPlay? {
liveData.plays.currentPlay ?? liveData.plays.allPlays.last
}
var currentBatter: LiveFeedPlayerReference? {
liveData.linescore?.offense?.batter ?? currentPlay?.matchup?.batter
}
var currentPitcher: LiveFeedPlayerReference? {
liveData.linescore?.defense?.pitcher ?? currentPlay?.matchup?.pitcher
}
var onDeckBatter: LiveFeedPlayerReference? {
liveData.linescore?.offense?.onDeck
}
var inHoleBatter: LiveFeedPlayerReference? {
liveData.linescore?.offense?.inHole
}
var currentCountText: String? {
guard let count = currentPlay?.count else { return nil }
return "\(count.balls)-\(count.strikes), \(count.outs) out\(count.outs == 1 ? "" : "s")"
}
var occupiedBases: [String] {
var bases: [String] = []
if liveData.linescore?.offense?.first != nil { bases.append("1B") }
if liveData.linescore?.offense?.second != nil { bases.append("2B") }
if liveData.linescore?.offense?.third != nil { bases.append("3B") }
return bases
}
var officialSummary: [String] {
(liveData.boxscore?.officials ?? []).compactMap { official in
guard let type = official.officialType, let name = official.official?.fullName else { return nil }
return "\(type): \(name)"
}
}
var weatherSummary: String? {
guard let weather = gameData.weather else { return nil }
return [weather.condition, weather.temp.map { "\($0)°" }, weather.wind]
.compactMap { $0 }
.joined(separator: "")
}
var decisionsSummary: [String] {
var result: [String] = []
if let winner = liveData.decisions?.winner?.fullName {
result.append("W: \(winner)")
}
if let loser = liveData.decisions?.loser?.fullName {
result.append("L: \(loser)")
}
if let save = liveData.decisions?.save?.fullName {
result.append("SV: \(save)")
}
return result
}
var awayLineup: [LiveFeedBoxscorePlayer] {
liveData.boxscore?.teams?.away.lineupPlayers ?? []
}
var homeLineup: [LiveFeedBoxscorePlayer] {
liveData.boxscore?.teams?.home.lineupPlayers ?? []
}
var currentAtBatPitches: [LiveFeedPlayEvent] {
currentPlay?.pitches ?? []
}
var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] {
liveData.plays.allPlays.compactMap { play in
guard let lastEvent = play.playEvents?.last,
let hitData = lastEvent.hitData,
hitData.coordinates?.coordX != nil else { return nil }
return (play: play, hitData: hitData)
}
}
}
struct LiveFeedMetaData: Codable, Sendable {
let wait: Int?
let timeStamp: String?
let gameEvents: [String]?
let logicalEvents: [String]?
}
struct LiveFeedGameData: Codable, Sendable {
let game: LiveFeedGameInfo?
let datetime: LiveFeedDateTime?
let status: LiveFeedStatus?
let teams: LiveFeedGameTeams?
let probablePitchers: LiveFeedProbablePitchers?
let venue: LiveFeedVenue?
let weather: LiveFeedWeather?
}
struct LiveFeedGameInfo: Codable, Sendable {
let pk: Int?
let type: String?
let season: String?
}
struct LiveFeedDateTime: Codable, Sendable {
let dateTime: String?
let officialDate: String?
let time: String?
let ampm: String?
}
struct LiveFeedStatus: Codable, Sendable {
let abstractGameState: String?
let detailedState: String?
let codedGameState: String?
}
struct LiveFeedGameTeams: Codable, Sendable {
let away: LiveFeedGameTeam?
let home: LiveFeedGameTeam?
}
struct LiveFeedGameTeam: Codable, Sendable {
let id: Int?
let name: String?
let abbreviation: String?
let record: LeagueRecordSummary?
}
struct LiveFeedProbablePitchers: Codable, Sendable {
let away: LiveFeedPlayerReference?
let home: LiveFeedPlayerReference?
}
struct LiveFeedVenue: Codable, Sendable {
let id: Int?
let name: String?
}
struct LiveFeedWeather: Codable, Sendable {
let condition: String?
let temp: String?
let wind: String?
}
struct LiveFeedData: Codable, Sendable {
let plays: LiveFeedPlays
let linescore: LiveFeedLinescore?
let boxscore: LiveFeedBoxscore?
let decisions: LiveFeedDecisions?
}
struct LiveFeedPlays: Codable, Sendable {
let currentPlay: LiveFeedPlay?
let allPlays: [LiveFeedPlay]
}
struct LiveFeedPlay: Codable, Sendable, Identifiable {
let result: LiveFeedPlayResult?
let about: LiveFeedPlayAbout?
let count: LiveFeedPlayCount?
let matchup: LiveFeedPlayMatchup?
let playEvents: [LiveFeedPlayEvent]?
var pitches: [LiveFeedPlayEvent] {
playEvents?.filter { $0.isPitch == true } ?? []
}
var id: String {
let inning = about?.inning ?? -1
let half = about?.halfInning ?? "play"
let atBat = about?.atBatIndex ?? -1
let event = result?.eventType ?? result?.event ?? result?.description ?? "update"
return "\(inning)-\(half)-\(atBat)-\(event)"
}
var summaryText: String {
result?.description ?? result?.event ?? "Play update"
}
}
struct LiveFeedPlayResult: Codable, Sendable {
let type: String?
let event: String?
let eventType: String?
let description: String?
}
struct LiveFeedPlayAbout: Codable, Sendable {
let atBatIndex: Int?
let halfInning: String?
let inning: Int?
let isScoringPlay: Bool?
}
struct LiveFeedPlayCount: Codable, Sendable {
let balls: Int
let strikes: Int
let outs: Int
}
struct LiveFeedPlayMatchup: Codable, Sendable {
let batter: LiveFeedPlayerReference?
let pitcher: LiveFeedPlayerReference?
}
struct LiveFeedPlayerReference: Codable, Sendable, Identifiable {
let id: Int?
let fullName: String?
let link: String?
var displayName: String {
fullName ?? "TBD"
}
var headshotURL: URL? {
guard let id else { return nil }
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
}
}
struct LiveFeedLinescore: Codable, Sendable {
let currentInning: Int?
let currentInningOrdinal: String?
let inningHalf: String?
let inningState: String?
let scheduledInnings: Int?
let offense: LiveFeedOffenseState?
let defense: LiveFeedDefenseState?
let teams: StatsLinescoreTeams?
var inningDisplay: String? {
if let inningHalf, let currentInningOrdinal {
return "\(inningHalf) \(currentInningOrdinal)"
}
if let inningState, let currentInningOrdinal {
return "\(inningState) \(currentInningOrdinal)"
}
return nil
}
}
struct LiveFeedOffenseState: Codable, Sendable {
let batter: LiveFeedPlayerReference?
let onDeck: LiveFeedPlayerReference?
let inHole: LiveFeedPlayerReference?
let first: LiveFeedPlayerReference?
let second: LiveFeedPlayerReference?
let third: LiveFeedPlayerReference?
}
struct LiveFeedDefenseState: Codable, Sendable {
let pitcher: LiveFeedPlayerReference?
}
struct LiveFeedBoxscore: Codable, Sendable {
let teams: LiveFeedBoxscoreTeams?
let officials: [LiveFeedOfficial]?
}
struct LiveFeedBoxscoreTeams: Codable, Sendable {
let away: LiveFeedBoxscoreTeam
let home: LiveFeedBoxscoreTeam
}
struct LiveFeedBoxscoreTeam: Codable, Sendable {
let team: StatsTeamBasic?
let players: [String: LiveFeedBoxscorePlayer]
let battingOrder: [JSONValue]?
let batters: [Int]?
var lineupPlayers: [LiveFeedBoxscorePlayer] {
let orderedIDs = battingOrder?.compactMap(\.displayString) ?? batters?.map(String.init) ?? []
let players = orderedIDs.compactMap(player(forIdentifier:))
if !players.isEmpty {
return players
}
return self.players.values.sorted { lhs, rhs in
(lhs.battingOrder ?? "999") < (rhs.battingOrder ?? "999")
}
}
private func player(forIdentifier identifier: String) -> LiveFeedBoxscorePlayer? {
if let player = players[identifier] {
return player
}
if let player = players["ID\(identifier)"] {
return player
}
if identifier.hasPrefix("ID"), let player = players[String(identifier.dropFirst(2))] {
return player
}
return nil
}
}
struct LiveFeedBoxscorePlayer: Codable, Sendable, Identifiable {
let person: LiveFeedPlayerReference
let jerseyNumber: String?
let position: PersonPosition?
let battingOrder: String?
var id: Int { person.id ?? -1 }
}
struct LiveFeedOfficial: Codable, Sendable {
let official: LiveFeedPlayerReference?
let officialType: String?
}
struct LiveFeedDecisions: Codable, Sendable {
let winner: LiveFeedPlayerReference?
let loser: LiveFeedPlayerReference?
let save: LiveFeedPlayerReference?
}
// MARK: - Play Event / Pitch Data Models
struct LiveFeedPlayEvent: Codable, Sendable, Identifiable {
let details: LiveFeedPlayEventDetails?
let count: LiveFeedPlayCount?
let pitchData: LiveFeedPitchData?
let hitData: LiveFeedHitData?
let isPitch: Bool?
let pitchNumber: Int?
let index: Int?
var id: Int { index ?? pitchNumber ?? 0 }
var callCode: String {
details?.call?.code ?? ""
}
var callDescription: String {
details?.call?.description ?? details?.description ?? ""
}
var pitchTypeDescription: String {
details?.type?.description ?? "Unknown"
}
var pitchTypeCode: String {
details?.type?.code ?? ""
}
var speedMPH: Double? {
pitchData?.startSpeed
}
}
struct LiveFeedPlayEventDetails: Codable, Sendable {
let call: LiveFeedCallInfo?
let description: String?
let type: LiveFeedPitchType?
}
struct LiveFeedCallInfo: Codable, Sendable {
let code: String?
let description: String?
}
struct LiveFeedPitchType: Codable, Sendable {
let code: String?
let description: String?
}
struct LiveFeedPitchData: Codable, Sendable {
let startSpeed: Double?
let endSpeed: Double?
let strikeZoneTop: Double?
let strikeZoneBottom: Double?
let coordinates: LiveFeedPitchCoordinates?
let zone: Int?
}
struct LiveFeedPitchCoordinates: Codable, Sendable {
let pX: Double?
let pZ: Double?
}
struct LiveFeedHitData: Codable, Sendable {
let launchSpeed: Double?
let launchAngle: Double?
let totalDistance: Double?
let coordinates: LiveFeedHitCoordinates?
}
struct LiveFeedHitCoordinates: Codable, Sendable {
let coordX: Double?
let coordY: Double?
}
// MARK: - Win Probability
struct WinProbabilityEntry: Codable, Sendable {
let atBatIndex: Int?
let homeTeamWinProbability: Double?
let awayTeamWinProbability: Double?
}