feat: open streaming URLs without changing resolved handoffs
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import Foundation
|
||||
|
||||
private struct StreamResolutionError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
enum StreamURL: Sendable {
|
||||
case youtube(URL)
|
||||
case miruro(mediaID: String, episode: Int)
|
||||
|
||||
static func parse(_ text: String) throws -> StreamURL {
|
||||
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = try? YouTubeResolver.validateURL(text) { return .youtube(url) }
|
||||
let invalid = StreamResolutionError(message: "Enter a YouTube video URL or a Miruro episode URL from barelystarted.miruro.tv.")
|
||||
guard !text.unicodeScalars.contains(where: {
|
||||
CharacterSet.whitespacesAndNewlines.contains($0) || CharacterSet.controlCharacters.contains($0)
|
||||
}), let components = URLComponents(string: text),
|
||||
components.scheme?.lowercased() == "https",
|
||||
components.host?.lowercased() == "barelystarted.miruro.tv",
|
||||
components.user == nil, components.password == nil,
|
||||
components.port == nil || components.port == 443,
|
||||
components.fragment == nil,
|
||||
components.percentEncodedPath == components.path,
|
||||
components.url != nil else { throw invalid }
|
||||
let path = components.path.split(separator: "/", omittingEmptySubsequences: false)
|
||||
guard path.count == 4, path[0].isEmpty, path[1] == "watch",
|
||||
!path[3].isEmpty, path[3].utf8.count <= 512,
|
||||
path[3].utf8.allSatisfy({ isSegmentByte($0) }),
|
||||
let query = components.queryItems, query.count == 1, query[0].name == "ep",
|
||||
let number = query[0].value, let episode = Int(number),
|
||||
components.percentEncodedQuery == "ep=\(episode)",
|
||||
ResolvedSource.validIdentity(mediaID: String(path[2]), episode: episode) else { throw invalid }
|
||||
return .miruro(mediaID: String(path[2]), episode: episode)
|
||||
}
|
||||
|
||||
private static func isSegmentByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
}
|
||||
|
||||
enum MiruroResolver {
|
||||
static let bodyLimit = 2 * 1024 * 1024
|
||||
|
||||
static func resolve(mediaID: String, episode: Int) async throws -> ResolvedSource {
|
||||
try Task.checkCancellation()
|
||||
guard ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else {
|
||||
throw StreamResolutionError(message: "Invalid Miruro episode identity.")
|
||||
}
|
||||
var components = URLComponents(string: "https://barelystarted.miruro.tv/api/sources")!
|
||||
components.queryItems = [URLQueryItem(name: "id", value: mediaID), URLQueryItem(name: "n", value: String(episode))]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 25
|
||||
configuration.timeoutIntervalForResource = 25
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCache = nil
|
||||
let session = URLSession(configuration: configuration, delegate: MiruroRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
try validateResponse(response)
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < bodyLimit else {
|
||||
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return try decode(data, mediaID: mediaID, episode: episode)
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro request failed or timed out. No retry was attempted.")
|
||||
}
|
||||
}
|
||||
|
||||
static func validateResponse(_ response: URLResponse) throws {
|
||||
guard let response = response as? HTTPURLResponse else {
|
||||
throw StreamResolutionError(message: "Miruro returned an invalid HTTP response.")
|
||||
}
|
||||
guard response.statusCode == 200 else {
|
||||
throw StreamResolutionError(message: "Miruro HTTP \(response.statusCode). No retry was attempted.")
|
||||
}
|
||||
guard response.mimeType?.lowercased() == "application/json" else {
|
||||
throw StreamResolutionError(message: "Miruro returned an unexpected content type; protection pages are not supported.")
|
||||
}
|
||||
guard response.expectedContentLength <= Int64(bodyLimit) else {
|
||||
throw StreamResolutionError(message: "Miruro source response exceeded 2 MiB.")
|
||||
}
|
||||
}
|
||||
|
||||
// Match Tubular's direct ssub/Sun projection, without provider or server retry.
|
||||
static func decode(_ data: Data, mediaID: String, episode: Int) throws -> ResolvedSource {
|
||||
do {
|
||||
guard data.count <= bodyLimit,
|
||||
ResolvedSource.validIdentity(mediaID: mediaID, episode: episode) else { throw InvalidProjection() }
|
||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||
let config = response.config.sun
|
||||
guard config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else {
|
||||
throw StreamResolutionError(message: "Miruro Sun requires unsupported proxy or HLS rewriting.")
|
||||
}
|
||||
guard let provider = response.tracks.first(where: { $0.track == "ssub" })?
|
||||
.providers.first(where: { $0.provider == "sun" }),
|
||||
let server = provider.servers.first(where: { !$0.streams.isEmpty }),
|
||||
let stream = server.streams.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Sun soft-subtitle source is unavailable.")
|
||||
}
|
||||
guard ["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
!stream.hasEmbed else { throw InvalidProjection() }
|
||||
let headers = server.headers ?? [:]
|
||||
var subtitles: [ResolvedSubtitle] = []
|
||||
for subtitle in provider.subtitles where subtitle.format == "vtt" {
|
||||
guard subtitles.count < 32 else { throw InvalidProjection() }
|
||||
subtitles.append(ResolvedSubtitle(url: subtitle.file, format: subtitle.format, language: subtitle.language,
|
||||
label: subtitle.label, isDefault: subtitle.default, headers: headers))
|
||||
}
|
||||
return try ResolvedSource.validated(.init(version: 1, url: stream.url, headers: headers,
|
||||
identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: subtitles))
|
||||
} catch {
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
||||
}
|
||||
}
|
||||
|
||||
private struct InvalidProjection: Error {}
|
||||
private struct Sources: Decodable {
|
||||
let episodeNumber: Int
|
||||
let tracks: [Track]
|
||||
let config: Config
|
||||
}
|
||||
private struct Config: Decodable { let sun: ProviderConfig }
|
||||
private struct ProviderConfig: Decodable {
|
||||
let player: String
|
||||
let proxy: Bool
|
||||
let hls: HLS?
|
||||
}
|
||||
private struct HLS: Decodable {
|
||||
struct Rule: Decodable { let enabled: Bool? }
|
||||
let segments: Rule?
|
||||
let query: Rule?
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
let providers: [Provider]
|
||||
}
|
||||
private struct Provider: Decodable {
|
||||
let provider: String
|
||||
let servers: [Server]
|
||||
let subtitles: [Subtitle]
|
||||
}
|
||||
private struct Server: Decodable {
|
||||
let headers: [String: String]?
|
||||
let streams: [Stream]
|
||||
}
|
||||
private struct Stream: Decodable {
|
||||
let url: String
|
||||
let format: String
|
||||
let hasEmbed: Bool
|
||||
enum CodingKeys: String, CodingKey { case url, format, embed }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
url = try values.decode(String.self, forKey: .url)
|
||||
format = try values.decode(String.self, forKey: .format)
|
||||
if values.contains(.embed) {
|
||||
hasEmbed = try !values.decodeNil(forKey: .embed)
|
||||
} else {
|
||||
hasEmbed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
private struct Subtitle: Decodable {
|
||||
let file: String
|
||||
let format: String
|
||||
let language: String
|
||||
let label: String
|
||||
let `default`: Bool
|
||||
}
|
||||
}
|
||||
|
||||
private final class MiruroRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user