import Foundation actor MLBServerAPI { static let defaultBaseURL = "https://ballgame.treytartt.com" let baseURL: String init(baseURL: String = defaultBaseURL) { self.baseURL = baseURL } // MARK: - Stream URL Construction func streamURL(for config: StreamConfig) -> URL { var components = URLComponents(string: "\(baseURL)/stream.m3u8")! var items: [URLQueryItem] = [] if let mediaId = config.mediaId { items.append(URLQueryItem(name: "mediaId", value: mediaId)) } else if let team = config.team { items.append(URLQueryItem(name: "team", value: team)) } items.append(URLQueryItem(name: "resolution", value: config.resolution)) if let date = config.date { items.append(URLQueryItem(name: "date", value: date)) } if let level = config.level { items.append(URLQueryItem(name: "level", value: level)) } if let skip = config.skip { items.append(URLQueryItem(name: "skip", value: skip)) } if let audioTrack = config.audioTrack { items.append(URLQueryItem(name: "audio_track", value: audioTrack)) } if let mediaType = config.mediaType { items.append(URLQueryItem(name: "mediaType", value: mediaType)) } if let game = config.game { items.append(URLQueryItem(name: "game", value: String(game))) } components.queryItems = items return components.url! } func eventStreamURL(event: String, resolution: String = "best") -> URL { var components = URLComponents(string: "\(baseURL)/stream.m3u8")! components.queryItems = [ URLQueryItem(name: "event", value: event), URLQueryItem(name: "resolution", value: resolution), ] return components.url! } // MARK: - Fetch Games func fetchGames(date: String? = nil) async throws -> [Game] { var urlString = baseURL + "/?scores=Show" if let date { urlString += "&date=\(date)" } guard let url = URL(string: urlString) else { throw APIError.invalidURL } let (data, response) = try await URLSession.shared.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw APIError.serverError } guard let html = String(data: data, encoding: .utf8) else { throw APIError.invalidData } return parseGames(from: html, date: date) } // MARK: - Fetch Highlights func fetchHighlights(gamePk: String, gameDate: String) async throws -> [Highlight] { let urlString = "\(baseURL)/highlights?gamePk=\(gamePk)&gameDate=\(gameDate)" guard let url = URL(string: urlString) else { throw APIError.invalidURL } let (data, _) = try await URLSession.shared.data(from: url) let highlights = try JSONDecoder().decode([Highlight].self, from: data) return highlights } // MARK: - HTML Parsing private func parseGames(from html: String, date: String?) -> [Game] { var games: [Game] = [] let todayDate = date ?? { let f = DateFormatter() f.dateFormat = "yyyy-MM-dd" return f.string(from: Date()) }() // Split HTML into table rows // Game rows have the pattern: AWAY @ HOME......streams... let rowPattern = #"]*>\s*([^<]*(?:<(?!/?tr)[^>]*>[^<]*)*)\s*(.*?)"# guard let rowRegex = try? NSRegularExpression(pattern: rowPattern, options: .dotMatchesLineSeparators) else { return games } let matches = rowRegex.matches(in: html, range: NSRange(html.startIndex..., in: html)) for match in matches { guard let gameInfoRange = Range(match.range(at: 1), in: html), let streamInfoRange = Range(match.range(at: 2), in: html) else { continue } let gameInfo = String(html[gameInfoRange]) let streamInfo = String(html[streamInfoRange]) // Must contain " @ " to be a game row (not MLB Network, not docs) guard gameInfo.contains(" @ ") else { continue } if let game = parseGameRow( gameInfo: gameInfo, streamInfo: streamInfo, gameDate: todayDate ) { games.append(game) } } return games } private func parseGameRow(gameInfo: String, streamInfo: String, gameDate: String) -> Game? { // Strip HTML tags for text parsing let cleanInfo = gameInfo .replacingOccurrences(of: "
", with: "\n") .replacingOccurrences(of: "
", with: "\n") .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) let lines = cleanInfo.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } guard !lines.isEmpty else { return nil } // Line 1: "[Type: ]AWAY [score] @ HOME [score]" let matchupLine = lines[0] // Extract game type prefix (e.g. "Spring Training: ", "Exhibition Game: ") var gameType: String? var matchupText = matchupLine if let colonRange = matchupLine.range(of: ": ") { let prefix = String(matchupLine[matchupLine.startIndex.. 5 { gameType = prefix matchupText = String(matchupLine[colonRange.upperBound...]) } } // Parse "AWAY [score] @ HOME [score]" let atParts = matchupText.components(separatedBy: " @ ") guard atParts.count == 2 else { return nil } let (awayName, awayScore) = parseTeamAndScore(atParts[0].trimmingCharacters(in: .whitespaces)) let (homeName, homeScore) = parseTeamAndScore(atParts[1].trimmingCharacters(in: .whitespaces)) // Line 2: "Pitcher vs Pitcher" (optional) let pitchers: String? = lines.count > 1 ? lines[1] : nil // Line 3+: Status ("Final", "7:05 PM", "In Progress", "Top 5th", etc.) and venue var statusText = "" var venue: String? for i in 2.. 0 ? teamCodes[0] : awayName let homeCode = teamCodes.count > 1 ? teamCodes[1] : homeName let id = gamePk ?? "\(awayCode)-\(homeCode)-\(gameDate)" return Game( id: id, awayTeam: TeamInfo(code: awayCode, name: awayName, score: awayScore), homeTeam: TeamInfo(code: homeCode, name: homeName, score: homeScore), status: status, gameType: gameType, startTime: { if case .scheduled(let t) = status { return t } return nil }(), venue: venue, pitchers: pitchers, gamePk: gamePk, gameDate: gameDate, broadcasts: broadcasts, isBlackedOut: isBlackedOut ) } /// Parse "TeamName 5" or just "TeamName" → (name, score?) private func parseTeamAndScore(_ text: String) -> (String, Int?) { let parts = text.split(separator: " ") guard parts.count >= 2, let score = Int(parts.last!) else { return (text, nil) } let name = parts.dropLast().joined(separator: " ") return (name, score) } /// Extract broadcast info from the stream cell HTML private func parseBroadcasts(from html: String) -> [Broadcast] { var broadcasts: [Broadcast] = [] // Pattern: TEAM: NAME // Split by broadcast entries: look for "CODE: ]*href="/embed\.html\?mediaId=([^"&]+)"[^>]*>([^<]+)\s*]*value="([^"]*)"# guard let regex = try? NSRegularExpression(pattern: broadcastPattern) else { return broadcasts } let matches = regex.matches(in: html, range: NSRange(html.startIndex..., in: html)) for match in matches { guard let teamRange = Range(match.range(at: 1), in: html), let mediaIdRange = Range(match.range(at: 2), in: html), let nameRange = Range(match.range(at: 3), in: html), let urlRange = Range(match.range(at: 4), in: html) else { continue } let broadcast = Broadcast( id: String(html[mediaIdRange]), teamCode: String(html[teamRange]), name: String(html[nameRange]), mediaId: String(html[mediaIdRange]), streamURL: String(html[urlRange]) ) broadcasts.append(broadcast) } return broadcasts } /// Extract team codes from addmultiview onclick private func extractTeamCodes(from html: String) -> [String] { let pattern = #"addmultiview\(this,\s*\[([^\]]*)\]"# guard let regex = try? NSRegularExpression(pattern: pattern), let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), let range = Range(match.range(at: 1), in: html) else { return [] } return String(html[range]) .replacingOccurrences(of: "'", with: "") .replacingOccurrences(of: "\"", with: "") .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } } } // MARK: - Types struct Highlight: Codable, Sendable, Identifiable { let id: String? let headline: String? let playbacks: [Playback]? struct Playback: Codable, Sendable { let name: String? let url: String? } var hlsURL: String? { playbacks?.first(where: { $0.name == "HTTP_CLOUD_WIRED_60" })?.url } var mp4URL: String? { playbacks?.first(where: { $0.name == "mp4Avc" })?.url } } enum APIError: Error, LocalizedError { case invalidURL case serverError case invalidData case noStreamsAvailable var errorDescription: String? { switch self { case .invalidURL: "Invalid server URL" case .serverError: "Server returned an error" case .invalidData: "Could not parse server response" case .noStreamsAvailable: "No streams available" } } }