1057 lines
30 KiB
Swift
1057 lines
30 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 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 ?? []
|
|
}
|
|
}
|
|
|
|
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: Int?
|
|
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?
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
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?
|
|
}
|