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>
340 lines
12 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|