Files
MLBApp/mlbTVOS/Services/MLBStatsAPI.swift
Trey t cd605d889d Replace Feed with highlights, remove duplicate live shelf, drop Intel schedule
Feed tab: Replaced news/transaction feed with league-wide highlights and
condensed game replays. FeedViewModel now fetches highlights from all
games concurrently, splits into condensed games vs individual highlights.
Cards show team color thumbnails with play button overlay.

Today tab: Removed duplicate Live games shelf — LiveSituationBar already
shows all live games at the top, so the shelf was redundant.

Intel tab: Removed schedule section (already on Today tab). Updated
header description and stat pills. Added division hydration to standings
API call so division names display correctly instead of "Division".

Focus: Added .platformFocusable() to standings cards and leaderboard
cards so tvOS remote can scroll horizontally through them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:25:36 -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,division"
)
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?
}