Files
MLBApp/mlbTVOS/Services/MLBServerAPI.swift
Trey t 39092e5f3d Restore Live shelf on Today, flatten Feed to time-ordered highlights
Today tab: Removed LiveSituationBar, restored the full Live game shelf
below the featured Astros card where it belongs.

Feed tab: Changed from two grouped shelves (condensed / highlights) to a
single horizontal scroll with ALL highlights ordered by timestamp (most
recent first). Added condensed game badge overlay on thumbnails. Added
date field to Highlight model for time-based ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 13:39:41 -05:00

340 lines
12 KiB
Swift

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: <tr...><td>AWAY @ HOME...</td><td>...streams...</td></tr>
let rowPattern = #"<tr[^>]*>\s*<td>([^<]*(?:<(?!/?tr)[^>]*>[^<]*)*)</td>\s*<td>(.*?)</td>"#
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: "<br/>", with: "\n")
.replacingOccurrences(of: "<br>", 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..<colonRange.lowerBound])
// Only treat as game type if it doesn't look like a team name
if prefix.contains(" ") || prefix.count > 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..<lines.count {
let line = lines[i]
if line.hasPrefix("at ") {
venue = line
} else if !line.isEmpty {
statusText = line
}
}
// Determine game status
let status: GameStatus
let lower = statusText.lowercased()
if lower.contains("final") || lower.contains("game over") || lower.contains("completed") {
status = .final_
} else if lower.contains("progress") || lower.contains("top") || lower.contains("bot")
|| lower.contains("mid") || lower.contains("end") || lower.contains("delay") {
status = .live(statusText)
} else if statusText.contains("PM") || statusText.contains("AM") || statusText.contains(":") {
status = .scheduled(statusText)
} else if statusText.isEmpty {
// If no status line, check if scores exist probably final or live
if awayScore != nil {
status = .final_
} else {
status = .unknown
}
} else {
status = .unknown
}
// Check for blackout
let isBlackedOut = streamInfo.contains("blackout")
// Extract broadcasts from stream info
let broadcasts = parseBroadcasts(from: streamInfo)
// Extract gamePk from highlights link
let gamePk: String? = {
let pattern = #"showhighlights\('(\d+)'"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: streamInfo, range: NSRange(streamInfo.startIndex..., in: streamInfo)),
let range = Range(match.range(at: 1), in: streamInfo) else { return nil }
return String(streamInfo[range])
}()
// Extract team codes from addmultiview
let teamCodes = extractTeamCodes(from: streamInfo)
let awayCode = teamCodes.count > 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: <a href="/embed.html?mediaId=UUID">NAME</a><input ... value="streamURL" onclick="addmultiview(this, ['X', 'Y'])">
// Split by broadcast entries: look for "CODE: <a" patterns
let broadcastPattern = #"(\w+):\s*<a[^>]*href="/embed\.html\?mediaId=([^"&]+)"[^>]*>([^<]+)</a>\s*<input[^>]*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 date: 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"
}
}
}