feat: support Miruro provider tracks, proxies, and native embeds
This commit is contained in:
@@ -97,7 +97,8 @@ private struct CastMediaCleanup: @unchecked Sendable { let handle: OpaquePointer
|
||||
var tracks: [CastTextTrack] = []
|
||||
for (index, subtitle) in input.subtitles.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let data = try await WebVTTDocument.loadData(url: subtitle.url, headers: subtitle.headers)
|
||||
let prepared = try await MiruroTransport.subtitle(subtitle)
|
||||
let data = try await WebVTTDocument.loadData(url: prepared.url, headers: prepared.headers)
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw CastDeliveryError() }
|
||||
_ = try WebVTTDocument.parse(text)
|
||||
let trackURL = try owner.subtitle(id: index + 1, data: data)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import CryptoKit
|
||||
|
||||
/// Native counterpart of MegaPlay's deployed e1-player and newclient codecs.
|
||||
struct MiruroEmbed: Sendable {
|
||||
let url: URL
|
||||
// Public embed-origin headers apply to its cross-origin media resources too:
|
||||
// deployed CDN segment endpoints reject requests without this Referer.
|
||||
let headers: String
|
||||
private let key: Data
|
||||
private let iv: Data
|
||||
|
||||
static func resolve(url: URL, now: @Sendable () -> Date = { Date() }) async throws -> MiruroEmbed {
|
||||
do {
|
||||
try validateOrigin(url)
|
||||
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: EmbedRedirectPolicy(), delegateQueue: nil)
|
||||
defer { session.invalidateAndCancel() }
|
||||
let html = try await text(url, session: session)
|
||||
guard let player = capture(#"<[^>]+\bid\s*=\s*["']megaplay-player["'][^>]*>"#, html),
|
||||
let id = capture(#"\bdata-id\s*=\s*["']([^"']+)["']"#, player),
|
||||
let base = capture(#"\bbase_url\s*:\s*["']([^"']+)["']"#, html),
|
||||
let baseURL = URL(string: base.replacingOccurrences(of: "\\/", with: "/"), relativeTo: url)?.absoluteURL else {
|
||||
throw EmbedError()
|
||||
}
|
||||
try validateOrigin(baseURL)
|
||||
// Only read script data; never evaluate provider JavaScript.
|
||||
let scripts = captures(#"<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']"#, html)
|
||||
guard let scriptURL = scripts.compactMap({ URL(string: $0, relativeTo: url)?.absoluteURL })
|
||||
.first(where: { $0.path.hasSuffix("/e1-player.min.js") }) else { throw EmbedError() }
|
||||
try validateOrigin(scriptURL)
|
||||
let script = try await text(scriptURL, session: session)
|
||||
guard let keyText = capture(#"\bSOURCE_ENC_KEY\s*=\s*"([^"\\]*)""#, script),
|
||||
let ivText = capture(#"\bSOURCE_ENC_IV\s*=\s*"([^"\\]*)""#, script),
|
||||
let secret = capture(#"\bCDN_TOKEN_SECRET\s*=\s*"([^"\\]*)""#, script) else { throw EmbedError() }
|
||||
let key = padded(keyText, count: 32)
|
||||
let iv = padded(ivText, count: 16)
|
||||
var endpoint = URLComponents(url: baseURL.appendingPathComponent("stream/getSources"), resolvingAgainstBaseURL: false)
|
||||
endpoint?.queryItems = [URLQueryItem(name: "id", value: id)]
|
||||
guard let endpointURL = endpoint?.url else { throw EmbedError() }
|
||||
let payload = try await request(endpointURL, session: session)
|
||||
guard let object = try JSONSerialization.jsonObject(with: payload) as? [String: Any],
|
||||
object["error"] == nil || (object["error"] as? Bool) == false else { throw EmbedError() }
|
||||
let source: Any
|
||||
if let encrypted = object["enc"] as? String {
|
||||
source = try JSONSerialization.jsonObject(with: decrypt(encrypted, key: key, iv: iv))
|
||||
guard (source as? [String: Any])?["file"] is String else { throw EmbedError() }
|
||||
} else if let plain = object["sources"] {
|
||||
source = plain
|
||||
} else { throw EmbedError() }
|
||||
guard let file = streamFile(source), let stream = URL(string: file) else { throw EmbedError() }
|
||||
try validateHTTP(stream)
|
||||
let tokenEnabled = capture(#"\bcdnTokenEnable\s*:\s*(false|0|"false"|'false'|"0"|'0')\s*[,}]"#, html) == nil
|
||||
let finalURL = try attachToken(stream, secret: secret, enabled: tokenEnabled, now: now())
|
||||
try Task.checkCancellation()
|
||||
return MiruroEmbed(url: finalURL, headers: "Referer: https://megaplay.buzz/\r\nOrigin: https://megaplay.buzz\r\n", key: key, iv: iv)
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
throw EmbedError()
|
||||
}
|
||||
}
|
||||
|
||||
func resourceURL(_ url: URL) throws -> URL {
|
||||
try Self.validateHTTP(url)
|
||||
guard let token = Self.capture(#"/segment/([A-Za-z0-9_-]+)"#, url.path) else { return url }
|
||||
let data = try Self.decrypt(token, key: key, iv: iv)
|
||||
guard let text = String(data: data, encoding: .utf8), let decoded = URL(string: text) else { throw EmbedError() }
|
||||
try Self.validateHTTP(decoded)
|
||||
return decoded
|
||||
}
|
||||
|
||||
func resourceData(_ data: Data, url: URL) throws -> Data {
|
||||
try Self.validateHTTP(url)
|
||||
// newclient v4.23 SegmentStrip: only these hosts, and only bodies longer than 252 bytes.
|
||||
guard let host = url.host,
|
||||
Self.capture(#"ibyteimg\.com|tiktokcdn\.com|ipstatp\.com|yoot\.akirax\.buzz"#, host) != nil,
|
||||
data.count > 252 else { return data }
|
||||
return Data(data.dropFirst(252))
|
||||
}
|
||||
|
||||
private static func request(_ url: URL, session: URLSession) async throws -> Data {
|
||||
try validateOrigin(url)
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 25
|
||||
request.setValue("https://megaplay.buzz/", forHTTPHeaderField: "Referer")
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
let limit = 2 * 1024 * 1024
|
||||
guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode),
|
||||
response.expectedContentLength <= Int64(limit) else { throw EmbedError() }
|
||||
var data = Data()
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < limit else { throw EmbedError() }
|
||||
data.append(byte)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private static func text(_ url: URL, session: URLSession) async throws -> String {
|
||||
let data = try await request(url, session: session)
|
||||
guard let text = String(data: data, encoding: .utf8) else { throw EmbedError() }
|
||||
return text
|
||||
}
|
||||
|
||||
private static func streamFile(_ source: Any) -> String? {
|
||||
if let string = source as? String { return string }
|
||||
if let array = source as? [Any], let first = array.first { return streamFile(first) }
|
||||
return (source as? [String: Any])?["file"] as? String
|
||||
}
|
||||
|
||||
private static func attachToken(_ url: URL, secret: String, enabled: Bool, now: Date) throws -> URL {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw EmbedError() }
|
||||
guard enabled, !(parts.queryItems ?? []).contains(where: { $0.name == "token" }),
|
||||
let pathKey = capture(#"/([a-f0-9]{32}/[a-f0-9]{32})/"#, url.path) else { return url }
|
||||
let seconds = now.timeIntervalSince1970.rounded(.down)
|
||||
guard seconds.isFinite, seconds >= 0, seconds < Double(Int64.max - 90) else { throw EmbedError() }
|
||||
let message = Data("\(Int64(seconds) + 90)|\(pathKey.lowercased())".utf8)
|
||||
let signature = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: Data(secret.utf8)))
|
||||
let token = base64URL(message) + "." + base64URL(Data(signature))
|
||||
var items = parts.queryItems ?? []
|
||||
items.append(URLQueryItem(name: "token", value: token))
|
||||
parts.queryItems = items
|
||||
guard let result = parts.url else { throw EmbedError() }
|
||||
return result
|
||||
}
|
||||
|
||||
private static func padded(_ string: String, count: Int) -> Data {
|
||||
var data = Data(string.utf8.prefix(count))
|
||||
data.append(Data(repeating: 0, count: count - data.count))
|
||||
return data
|
||||
}
|
||||
|
||||
private static func base64URL(_ data: Data) -> String {
|
||||
data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private static func decrypt(_ token: String, key: Data, iv: Data) throws -> Data {
|
||||
var encoded = token.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
|
||||
guard let encrypted = Data(base64Encoded: encoded), !encrypted.isEmpty,
|
||||
encrypted.count % kCCBlockSizeAES128 == 0 else { throw EmbedError() }
|
||||
var output = Data(count: encrypted.count + kCCBlockSizeAES128)
|
||||
let capacity = output.count
|
||||
var written = 0
|
||||
let status = output.withUnsafeMutableBytes { destination in
|
||||
encrypted.withUnsafeBytes { input in
|
||||
key.withUnsafeBytes { keyBytes in
|
||||
iv.withUnsafeBytes { ivBytes in
|
||||
CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding),
|
||||
keyBytes.baseAddress, key.count, ivBytes.baseAddress, input.baseAddress, encrypted.count,
|
||||
destination.baseAddress, capacity, &written)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess else { throw EmbedError() }
|
||||
output.count = written
|
||||
return output
|
||||
}
|
||||
|
||||
private static func validateHTTP(_ url: URL) throws {
|
||||
guard ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
|
||||
let host = url.host, !host.isEmpty, url.user == nil, url.password == nil else { throw EmbedError() }
|
||||
}
|
||||
|
||||
private static func validateOrigin(_ url: URL) throws {
|
||||
try validateHTTP(url)
|
||||
guard url.scheme?.lowercased() == "https", url.host?.lowercased() == "megaplay.buzz",
|
||||
url.port == nil || url.port == 443 else { throw EmbedError() }
|
||||
}
|
||||
|
||||
private static func capture(_ pattern: String, _ text: String) -> String? {
|
||||
captures(pattern, text).first
|
||||
}
|
||||
|
||||
private static func captures(_ pattern: String, _ text: String) -> [String] {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] }
|
||||
return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
|
||||
let index = match.numberOfRanges > 1 ? 1 : 0
|
||||
guard let range = Range(match.range(at: index), in: text) else { return nil }
|
||||
return String(text[range])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmbedError: LocalizedError {
|
||||
var errorDescription: String? { "The embedded provider could not prepare this stream." }
|
||||
}
|
||||
|
||||
private final class EmbedRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Darwin
|
||||
|
||||
private struct MiruroPlaybackError: LocalizedError {
|
||||
var errorDescription: String? { "Could not prepare this Miruro provider. Private source details are redacted." }
|
||||
}
|
||||
|
||||
private final class MiruroMediaRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
|
||||
}
|
||||
|
||||
// No cookies, redirects or credentials inherited from the app/browser session.
|
||||
enum MiruroMediaHTTP {
|
||||
private static let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.httpShouldSetCookies = false
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.urlCredentialStorage = nil
|
||||
configuration.urlCache = nil
|
||||
configuration.timeoutIntervalForRequest = 20
|
||||
configuration.timeoutIntervalForResource = 30
|
||||
return URLSession(configuration: configuration, delegate: MiruroMediaRedirectPolicy(), delegateQueue: nil)
|
||||
}()
|
||||
|
||||
static func read(_ url: URL, headers: String = "", limit: Int = 2 * 1024 * 1024,
|
||||
range: String? = nil) async throws -> (Data, HTTPURLResponse) {
|
||||
_ = try YouTubeResolver.validateURL(url.absoluteString)
|
||||
var request = URLRequest(url: url)
|
||||
for line in headers.components(separatedBy: "\r\n") where !line.isEmpty {
|
||||
guard let colon = line.firstIndex(of: ":") else { throw MiruroPlaybackError() }
|
||||
request.setValue(String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces),
|
||||
forHTTPHeaderField: String(line[..<colon]))
|
||||
}
|
||||
if let range { request.setValue(range, forHTTPHeaderField: "Range") }
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let response = response as? HTTPURLResponse,
|
||||
[200, 206].contains(response.statusCode), response.expectedContentLength <= limit else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
var data = Data()
|
||||
if response.expectedContentLength > 0 { data.reserveCapacity(Int(response.expectedContentLength)) }
|
||||
for try await byte in bytes {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < limit else { throw MiruroPlaybackError() }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
struct MiruroProxyDeployment: Sendable {
|
||||
let base: URL
|
||||
let key: [UInt8]
|
||||
var alternate: URL? = nil
|
||||
|
||||
static func decode(_ html: String) throws -> Self {
|
||||
let pattern = #"env:\s*(\{[^\n]*\})"#
|
||||
guard let match = html.range(of: pattern, options: .regularExpression),
|
||||
let start = html[match].firstIndex(of: "{"),
|
||||
let data = String(html[start..<match.upperBound]).data(using: .utf8),
|
||||
let env = try JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let text = env["PUBLIC_PROXY_A"], let base = URL(string: text),
|
||||
base.scheme == "https", base.user == nil, base.password == nil, base.host != nil else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
let hex = env["PUBLIC_PROXY_OBF_KEY"] ?? ""
|
||||
guard hex.count.isMultiple(of: 2), hex.count <= 512 else { throw MiruroPlaybackError() }
|
||||
var key: [UInt8] = []
|
||||
var index = hex.startIndex
|
||||
while index < hex.endIndex {
|
||||
let end = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(hex[index..<end], radix: 16) else { throw MiruroPlaybackError() }
|
||||
key.append(byte)
|
||||
index = end
|
||||
}
|
||||
return Self(base: base, key: key, alternate: env["PUBLIC_PROXY_B"].flatMap(URL.init(string:)))
|
||||
}
|
||||
|
||||
// The deployed proxy also rewrites nested references. Decode those before
|
||||
// applying provider rules; wrapping the proxy itself makes nested HLS fail.
|
||||
func originalURL(_ url: URL) throws -> URL {
|
||||
guard [base, alternate].compactMap({ $0 }).contains(where: {
|
||||
$0.scheme == url.scheme && $0.host == url.host && $0.port == url.port
|
||||
}) else { return url }
|
||||
guard let encoded = url.path.split(separator: "/").first?.split(separator: "~").first else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
var text = encoded.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
text += String(repeating: "=", count: (4 - text.count % 4) % 4)
|
||||
guard let data = Data(base64Encoded: text) else { throw MiruroPlaybackError() }
|
||||
var bytes = Array(data)
|
||||
if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } }
|
||||
guard let target = String(bytes: bytes, encoding: .utf8), let result = URL(string: target),
|
||||
["http", "https"].contains(result.scheme), result.user == nil, result.password == nil else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func url(_ target: URL, referer: String?, proxy: MiruroProxy, suffix: String) throws -> URL {
|
||||
func encode(_ text: String) -> String {
|
||||
var bytes = Array(text.utf8)
|
||||
if !key.isEmpty { for index in bytes.indices { bytes[index] ^= key[index % key.count] } }
|
||||
return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
var path = encode(target.absoluteString)
|
||||
if let referer, !referer.isEmpty { path += "~" + encode(referer) }
|
||||
else if proxy.rotate { path += "~" }
|
||||
if proxy.rotate { path += "~anon" }
|
||||
guard let url = URL(string: base.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/" + path + suffix) else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private actor MiruroDeploymentCache {
|
||||
static let shared = MiruroDeploymentCache()
|
||||
private var deployment: MiruroProxyDeployment?
|
||||
func get() async throws -> MiruroProxyDeployment {
|
||||
if let deployment { return deployment }
|
||||
let (data, _) = try await MiruroMediaHTTP.read(URL(string: "https://barelystarted.miruro.tv/")!)
|
||||
guard let html = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() }
|
||||
let result = try MiruroProxyDeployment.decode(html)
|
||||
deployment = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Only registered resources are reachable: no caller-supplied upstream URLs, and
|
||||
// no private headers ever cross the loopback native/Cast handoff.
|
||||
// ponytail: buffer up to eight 64-MiB resources; stream the relay if that ceiling becomes limiting.
|
||||
final class MiruroTransport: @unchecked Sendable {
|
||||
private enum Kind: String { case playlist, segment, resource }
|
||||
private struct Resource { let url: URL; let kind: Kind; let depth: Int }
|
||||
private let listener: NWListener
|
||||
private let queue = DispatchQueue(label: "terebi.miruro.transport")
|
||||
private let lock = NSLock()
|
||||
private var resources: [String: Resource] = [:]
|
||||
private var paths: [String: String] = [:]
|
||||
private var active = 0
|
||||
private var preparedMaster: (Data, String, String?)?
|
||||
private let token = UUID().uuidString
|
||||
private let sourceURL: URL
|
||||
private let headers: String
|
||||
private let requirements: MiruroPlaybackRequirements
|
||||
private let deployment: MiruroProxyDeployment?
|
||||
private let embed: MiruroEmbed?
|
||||
|
||||
private init(source: ResolvedSource, deployment: MiruroProxyDeployment?, embed: MiruroEmbed?) throws {
|
||||
guard let sourceURL = URL(string: source.url) else { throw MiruroPlaybackError() }
|
||||
self.sourceURL = embed?.url ?? sourceURL
|
||||
headers = embed?.headers ?? source.headers
|
||||
requirements = source.requirements
|
||||
self.deployment = deployment
|
||||
self.embed = embed
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: .any)
|
||||
listener = try NWListener(using: parameters)
|
||||
}
|
||||
|
||||
static func prepare(_ source: ResolvedSource, deployment override: MiruroProxyDeployment? = nil) async throws -> ResolvedSource {
|
||||
let rules = source.requirements
|
||||
guard rules.embed || rules.proxy != nil || rules.segmentOrigin != nil || !rules.playlistQuery.isEmpty else { return source }
|
||||
do {
|
||||
let deployment: MiruroProxyDeployment?
|
||||
if let override { deployment = override }
|
||||
else if rules.proxy != nil { deployment = try await MiruroDeploymentCache.shared.get() }
|
||||
else { deployment = nil }
|
||||
let embed = try await rules.embed ? MiruroEmbed.resolve(url: URL(string: source.url)!) : nil
|
||||
let owner = try MiruroTransport(source: source, deployment: deployment, embed: embed)
|
||||
try await owner.start()
|
||||
let url = try owner.register(owner.sourceURL, kind: .playlist, depth: 0)
|
||||
// Exercise the master before replacing acknowledged playback. A failed
|
||||
// provider switch must leave the current decoder and position intact.
|
||||
let master = try await owner.load(Resource(url: owner.sourceURL, kind: .playlist, depth: 0), range: nil)
|
||||
owner.lock.withLock { owner.preparedMaster = master }
|
||||
var prepared = ResolvedSource(url: url.absoluteString, headers: "", identity: source.identity,
|
||||
title: source.title, subtitles: source.subtitles)
|
||||
prepared.playbackLifetime = owner
|
||||
return prepared
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
}
|
||||
|
||||
static func subtitle(_ track: ResolvedSubtitle) async throws -> ResolvedSubtitle {
|
||||
guard let proxy = track.proxy, let url = URL(string: track.url) else { return track }
|
||||
do {
|
||||
let deployment = try await MiruroDeploymentCache.shared.get()
|
||||
let target = try deployment.url(url, referer: header("Referer", in: track.headers), proxy: proxy, suffix: "/sub.vtt")
|
||||
return ResolvedSubtitle(url: target.absoluteString, format: track.format, language: track.language,
|
||||
label: track.label, isDefault: track.isDefault, headers: [:])
|
||||
} catch {
|
||||
if Task.isCancelled || error is CancellationError { throw CancellationError() }
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
}
|
||||
|
||||
private static func header(_ name: String, in headers: String) -> String? {
|
||||
for line in headers.components(separatedBy: "\r\n") {
|
||||
guard let colon = line.firstIndex(of: ":"), line[..<colon].caseInsensitiveCompare(name) == .orderedSame else { continue }
|
||||
return String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func start() async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
self?.listener.stateUpdateHandler = nil
|
||||
continuation.resume()
|
||||
case .failed, .cancelled:
|
||||
self?.listener.stateUpdateHandler = nil
|
||||
continuation.resume(throwing: MiruroPlaybackError())
|
||||
default: break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in self?.accept(connection) }
|
||||
listener.start(queue: queue)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
}
|
||||
|
||||
deinit { listener.cancel() }
|
||||
|
||||
private func register(_ url: URL, kind: Kind, depth: Int) throws -> URL {
|
||||
guard depth <= 12, ["http", "https"].contains(url.scheme), url.user == nil, url.password == nil,
|
||||
url.fragment == nil, url.absoluteString.utf8.count <= 32768, let port = listener.port else { throw MiruroPlaybackError() }
|
||||
return try lock.withLock {
|
||||
let key = kind.rawValue + ":" + url.absoluteString
|
||||
let path: String
|
||||
if let existing = paths[key] { path = existing }
|
||||
else {
|
||||
guard resources.count < 32768 else { throw MiruroPlaybackError() }
|
||||
path = "/" + token + "/" + String(resources.count) + (kind == .playlist ? ".m3u8" : ".bin")
|
||||
resources[path] = Resource(url: url, kind: kind, depth: depth)
|
||||
paths[key] = path
|
||||
}
|
||||
return URL(string: "http://127.0.0.1:\(port.rawValue)\(path)")!
|
||||
}
|
||||
}
|
||||
|
||||
private func accept(_ connection: NWConnection) {
|
||||
let admitted = lock.withLock { () -> Bool in
|
||||
guard active < 8 else { return false }
|
||||
active += 1
|
||||
return true
|
||||
}
|
||||
guard admitted else { connection.cancel(); return }
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + 35) { connection.cancel() }
|
||||
receive(connection, data: Data())
|
||||
}
|
||||
|
||||
private func receive(_ connection: NWConnection, data: Data) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [weak self] next, _, complete, error in
|
||||
guard let self else { connection.cancel(); return }
|
||||
var data = data
|
||||
if let next { data.append(next) }
|
||||
guard error == nil, data.count <= 16384 else { self.finish(connection); return }
|
||||
guard data.range(of: Data("\r\n\r\n".utf8)) != nil else {
|
||||
if complete { self.finish(connection) } else { self.receive(connection, data: data) }
|
||||
return
|
||||
}
|
||||
Task { [self] in
|
||||
defer { finish(connection) }
|
||||
do {
|
||||
guard let request = String(data: data, encoding: .utf8) else { throw MiruroPlaybackError() }
|
||||
let lines = request.components(separatedBy: "\r\n")
|
||||
let first = lines[0].split(separator: " ")
|
||||
guard first.count == 3, first[0] == "GET" || first[0] == "HEAD",
|
||||
let resource = lock.withLock({ resources[String(first[1])] }) else { throw MiruroPlaybackError() }
|
||||
var range: String?
|
||||
for line in lines.dropFirst() where line.lowercased().hasPrefix("range:") {
|
||||
let value = String(line.dropFirst(6)).trimmingCharacters(in: .whitespaces)
|
||||
guard value.range(of: #"^bytes=[0-9]+-[0-9]*$"#, options: .regularExpression) != nil, range == nil else { throw MiruroPlaybackError() }
|
||||
range = value
|
||||
}
|
||||
let (body, type, contentRange) = try await load(resource, range: range)
|
||||
let status = contentRange == nil ? "200 OK" : "206 Partial Content"
|
||||
var response = "HTTP/1.1 \(status)\r\nContent-Type: \(type)\r\nContent-Length: \(body.count)\r\nConnection: close\r\n"
|
||||
if let contentRange { response += "Content-Range: \(contentRange)\r\n" }
|
||||
try await send(Data((response + "\r\n").utf8), connection: connection)
|
||||
if first[0] == "GET" { try await send(body, connection: connection) }
|
||||
} catch {
|
||||
try? await send(Data("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".utf8), connection: connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ connection: NWConnection) {
|
||||
connection.cancel()
|
||||
lock.withLock { active -= 1 }
|
||||
}
|
||||
|
||||
private func send(_ data: Data, connection: NWConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func load(_ resource: Resource, range: String?) async throws -> (Data, String, String?) {
|
||||
if resource.depth == 0, resource.kind == .playlist,
|
||||
let master = lock.withLock({ () -> (Data, String, String?)? in
|
||||
defer { preparedMaster = nil }
|
||||
return preparedMaster
|
||||
}) { return master }
|
||||
let original = try deployment?.originalURL(resource.url) ?? resource.url
|
||||
var url = try embed?.resourceURL(original) ?? original
|
||||
if resource.kind == .playlist, !requirements.playlistQuery.isEmpty {
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||
var query = components.queryItems ?? []
|
||||
query.removeAll { requirements.playlistQuery[$0.name] != nil }
|
||||
query += requirements.playlistQuery.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
components.queryItems = query
|
||||
guard let result = components.url else { throw MiruroPlaybackError() }
|
||||
url = result
|
||||
}
|
||||
if resource.kind == .segment, let origin = requirements.segmentOrigin,
|
||||
let replacement = URLComponents(string: origin), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||
components.scheme = replacement.scheme
|
||||
components.host = replacement.host
|
||||
components.port = replacement.port
|
||||
guard let result = components.url else { throw MiruroPlaybackError() }
|
||||
url = result
|
||||
}
|
||||
func fetch(_ url: URL) async throws -> (Data, HTTPURLResponse) {
|
||||
// Same-origin fixture/source access remains allowed. A manifest or
|
||||
// decrypted token cannot turn the relay into a local-network proxy.
|
||||
if !Self.sameOrigin(url, sourceURL) { try Self.requirePublicDestination(url) }
|
||||
let target: URL
|
||||
let requestHeaders: String
|
||||
if let proxy = requirements.proxy, let deployment,
|
||||
resource.kind == .playlist || proxy.segments {
|
||||
target = try deployment.url(url, referer: Self.header("Referer", in: headers), proxy: proxy,
|
||||
suffix: resource.kind == .playlist ? "/pl.m3u8" : "/seg.jpg")
|
||||
requestHeaders = ""
|
||||
} else {
|
||||
target = url
|
||||
// Only the embed's fixed public Referer has cross-origin scope.
|
||||
requestHeaders = embed?.headers ?? (Self.sameOrigin(url, sourceURL) ? headers : "")
|
||||
}
|
||||
return try await MiruroMediaHTTP.read(target, headers: requestHeaders,
|
||||
limit: resource.kind == .playlist ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
|
||||
range: resource.kind == .playlist || embed != nil ? nil : range)
|
||||
}
|
||||
let raw: Data
|
||||
let response: HTTPURLResponse
|
||||
if resource.kind == .playlist, let skip = requirements.playlistQuery["skip"], !skip.isEmpty,
|
||||
var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.queryItems?.contains(where: { $0.name == "token" }) == true {
|
||||
components.queryItems?.removeAll { $0.name == "token" }
|
||||
do { (raw, response) = try await fetch(components.url!) }
|
||||
catch {
|
||||
try Task.checkCancellation()
|
||||
(raw, response) = try await fetch(url)
|
||||
}
|
||||
} else { (raw, response) = try await fetch(url) }
|
||||
if resource.kind == .playlist {
|
||||
guard response.statusCode == 200, let text = String(data: raw, encoding: .utf8), text.hasPrefix("#EXTM3U") else { throw MiruroPlaybackError() }
|
||||
return (try rewrite(text, base: url, depth: resource.depth), "application/vnd.apple.mpegurl", nil)
|
||||
}
|
||||
guard embed == nil || response.statusCode == 200 else { throw MiruroPlaybackError() }
|
||||
let data = try embed?.resourceData(raw, url: url) ?? raw
|
||||
if embed != nil, let range {
|
||||
let offsets = range.dropFirst(6).split(separator: "-", omittingEmptySubsequences: false)
|
||||
guard let start = Int(offsets[0]), start < data.count else { throw MiruroPlaybackError() }
|
||||
let end = offsets[1].isEmpty ? data.count - 1 : min(Int(offsets[1]) ?? -1, data.count - 1)
|
||||
guard end >= start else { throw MiruroPlaybackError() }
|
||||
return (data.subdata(in: start..<(end + 1)), "application/octet-stream", "bytes \(start)-\(end)/\(data.count)")
|
||||
}
|
||||
let contentRange = response.statusCode == 206 ? response.value(forHTTPHeaderField: "Content-Range") : nil
|
||||
if response.statusCode == 206 {
|
||||
guard let contentRange,
|
||||
contentRange.range(of: #"^bytes [0-9]+-[0-9]+/([0-9]+|\*)$"#, options: .regularExpression) != nil else {
|
||||
throw MiruroPlaybackError()
|
||||
}
|
||||
}
|
||||
return (data, "application/octet-stream", contentRange)
|
||||
}
|
||||
|
||||
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && lhs.host?.lowercased() == rhs.host?.lowercased() &&
|
||||
(lhs.port ?? (lhs.scheme == "https" ? 443 : 80)) == (rhs.port ?? (rhs.scheme == "https" ? 443 : 80))
|
||||
}
|
||||
|
||||
private static func requirePublicDestination(_ url: URL) throws {
|
||||
guard let host = url.host else { throw MiruroPlaybackError() }
|
||||
var hints = addrinfo()
|
||||
hints.ai_family = AF_UNSPEC
|
||||
hints.ai_socktype = SOCK_STREAM
|
||||
var addresses: UnsafeMutablePointer<addrinfo>?
|
||||
guard getaddrinfo(host, nil, &hints, &addresses) == 0, let first = addresses else { throw MiruroPlaybackError() }
|
||||
defer { freeaddrinfo(first) }
|
||||
var current: UnsafeMutablePointer<addrinfo>? = first
|
||||
while let entry = current {
|
||||
let value = entry.pointee
|
||||
if value.ai_family == AF_INET {
|
||||
let address = value.ai_addr.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { UInt32(bigEndian: $0.pointee.sin_addr.s_addr) }
|
||||
let a = address >> 24, b = (address >> 16) & 255
|
||||
guard a != 0, a != 10, a != 127, a < 224,
|
||||
!(a == 169 && b == 254), !(a == 172 && (16...31).contains(b)),
|
||||
!(a == 192 && b == 168), !(a == 100 && (64...127).contains(b)),
|
||||
!(a == 198 && (18...19).contains(b)) else { throw MiruroPlaybackError() }
|
||||
} else if value.ai_family == AF_INET6 {
|
||||
let bytes = value.ai_addr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) {
|
||||
withUnsafeBytes(of: $0.pointee.sin6_addr) { Array($0) }
|
||||
}
|
||||
// Only globally routed unicast; excludes loopback, link-local,
|
||||
// unique-local, multicast and IPv4-mapped local destinations.
|
||||
guard bytes[0] & 0xe0 == 0x20 else { throw MiruroPlaybackError() }
|
||||
} else { throw MiruroPlaybackError() }
|
||||
current = value.ai_next
|
||||
}
|
||||
}
|
||||
|
||||
private func rewrite(_ text: String, base: URL, depth: Int) throws -> Data {
|
||||
let regex = try NSRegularExpression(pattern: #"URI=\"([^\"]+)\""#)
|
||||
var nextPlaylist = false
|
||||
var lines: [String] = []
|
||||
for raw in text.components(separatedBy: .newlines) {
|
||||
var line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.hasPrefix("#") {
|
||||
let kind: Kind = line.hasPrefix("#EXT-X-MAP:") ? .segment :
|
||||
(line.hasPrefix("#EXT-X-MEDIA:") || line.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") || line.hasPrefix("#EXT-X-RENDITION-REPORT:")) ? .playlist : .resource
|
||||
for match in regex.matches(in: line, range: NSRange(line.startIndex..., in: line)).reversed() {
|
||||
guard let range = Range(match.range(at: 1), in: line),
|
||||
let url = URL(string: String(line[range]), relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||
let replacement = try register(url, kind: kind, depth: depth + 1)
|
||||
line.replaceSubrange(range, with: replacement.absoluteString)
|
||||
}
|
||||
if line.hasPrefix("#EXT-X-STREAM-INF:") { nextPlaylist = true }
|
||||
} else if !line.isEmpty {
|
||||
guard let url = URL(string: line, relativeTo: base)?.absoluteURL else { throw MiruroPlaybackError() }
|
||||
let kind: Kind = nextPlaylist || url.path.lowercased().hasSuffix(".m3u8") ? .playlist : .segment
|
||||
line = try register(url, kind: kind, depth: depth + 1).absoluteString
|
||||
nextPlaylist = false
|
||||
}
|
||||
lines.append(line)
|
||||
}
|
||||
return Data(lines.joined(separator: "\n").utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,18 +8,21 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
let label: String
|
||||
let isDefault: Bool
|
||||
private let headerFields: [String: String]
|
||||
var proxy: MiruroProxy? = nil
|
||||
var headers: String {
|
||||
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
}
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
|
||||
init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String]) {
|
||||
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
||||
headers: [String: String], proxy: MiruroProxy? = nil) {
|
||||
self.url = url
|
||||
self.format = format
|
||||
self.language = language
|
||||
self.label = label
|
||||
self.isDefault = isDefault
|
||||
self.headerFields = headers
|
||||
self.proxy = proxy
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
@@ -34,6 +37,13 @@ struct ResolvedSource: Sendable {
|
||||
let identity: String
|
||||
let title: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
var requirements: MiruroPlaybackRequirements = .init()
|
||||
var playbackLifetime: (any Sendable)? = nil
|
||||
|
||||
func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource {
|
||||
ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles,
|
||||
requirements: requirements, playbackLifetime: playbackLifetime)
|
||||
}
|
||||
|
||||
struct Document: Decodable {
|
||||
struct Identity: Decodable {
|
||||
@@ -87,14 +97,7 @@ struct ResolvedSource: Sendable {
|
||||
validIdentity(mediaID: id.media_id, episode: id.episode),
|
||||
validURL(document.url), validHeaders(document.headers),
|
||||
validText(document.title ?? "", limit: 4096),
|
||||
document.subtitles.count <= 32,
|
||||
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
|
||||
for subtitle in document.subtitles {
|
||||
guard validURL(subtitle.url), subtitle.format == "vtt",
|
||||
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
|
||||
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
|
||||
validHeaders(subtitle.validatedHeaders) else { throw InvalidSource() }
|
||||
}
|
||||
validSubtitles(document.subtitles) else { throw InvalidSource() }
|
||||
let headers = document.headers.sorted { $0.key < $1.key }
|
||||
.map { "\($0.key): \($0.value)\r\n" }.joined()
|
||||
return ResolvedSource(url: document.url, headers: headers,
|
||||
@@ -102,16 +105,26 @@ struct ResolvedSource: Sendable {
|
||||
subtitles: document.subtitles)
|
||||
}
|
||||
|
||||
static func validSubtitles(_ subtitles: [ResolvedSubtitle]) -> Bool {
|
||||
subtitles.count <= 32 && subtitles.filter({ $0.isDefault }).count <= 1 &&
|
||||
subtitles.allSatisfy {
|
||||
validURL($0.url) && $0.format == "vtt" &&
|
||||
validText($0.language, limit: 128) && !$0.language.isEmpty &&
|
||||
validText($0.label, limit: 4096) && !$0.label.isEmpty &&
|
||||
validHeaders($0.validatedHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isIDByte(_ byte: UInt8) -> Bool {
|
||||
(65...90).contains(byte) || (97...122).contains(byte) ||
|
||||
(48...57).contains(byte) || byte == 45 || byte == 95
|
||||
}
|
||||
|
||||
private static func validText(_ text: String, limit: Int) -> Bool {
|
||||
static func validText(_ text: String, limit: Int) -> Bool {
|
||||
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
|
||||
}
|
||||
|
||||
private static func validURL(_ text: String) -> Bool {
|
||||
static func validURL(_ text: String) -> Bool {
|
||||
guard !text.isEmpty, validText(text, limit: 32768),
|
||||
!text.contains(where: { $0.isWhitespace }),
|
||||
let components = URLComponents(string: text),
|
||||
@@ -122,7 +135,7 @@ struct ResolvedSource: Sendable {
|
||||
return components.port == nil || (1...65535).contains(components.port!)
|
||||
}
|
||||
|
||||
private static func validHeaders(_ headers: [String: String]) -> Bool {
|
||||
static func validHeaders(_ headers: [String: String]) -> Bool {
|
||||
guard headers.count <= 64 else { return false }
|
||||
var names = Set<String>()
|
||||
var size = 0
|
||||
|
||||
@@ -41,6 +41,29 @@ enum StreamURL: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct MiruroProxy: Decodable, Sendable {
|
||||
let rotate: Bool
|
||||
let segments: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey { case rotate, segments }
|
||||
init(rotate: Bool, segments: Bool) {
|
||||
self.rotate = rotate
|
||||
self.segments = segments
|
||||
}
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
rotate = try values.decodeIfPresent(Bool.self, forKey: .rotate) ?? false
|
||||
segments = try values.decodeIfPresent(Bool.self, forKey: .segments) ?? true
|
||||
}
|
||||
}
|
||||
|
||||
struct MiruroPlaybackRequirements: Sendable {
|
||||
var proxy: MiruroProxy? = nil
|
||||
var segmentOrigin: String? = nil
|
||||
var playlistQuery: [String: String] = [:]
|
||||
var embed = false
|
||||
}
|
||||
|
||||
struct MiruroSelection: Sendable {
|
||||
let source: ResolvedSource
|
||||
let videoProvider: String
|
||||
@@ -50,32 +73,46 @@ struct MiruroSelection: Sendable {
|
||||
struct MiruroSources: Sendable {
|
||||
fileprivate struct Candidate: Sendable {
|
||||
let provider: String
|
||||
let track: String
|
||||
let source: ResolvedSource
|
||||
var id: String { "\(provider):\(track)" }
|
||||
}
|
||||
fileprivate struct Captions: Sendable {
|
||||
let provider: String
|
||||
let track: String
|
||||
let subtitles: [ResolvedSubtitle]
|
||||
var id: String { "\(provider):\(track)" }
|
||||
}
|
||||
fileprivate let videos: [Candidate]
|
||||
fileprivate let captions: [Candidate]
|
||||
fileprivate let captions: [Captions]
|
||||
|
||||
let videoProviders: [String]
|
||||
let captionProviders: [String]
|
||||
|
||||
fileprivate init(videos: [Candidate], captions: [Candidate]) {
|
||||
fileprivate init(videos: [Candidate], captions: [Captions]) {
|
||||
self.videos = videos
|
||||
self.captions = captions
|
||||
videoProviders = videos.map(\.provider)
|
||||
captionProviders = captions.map(\.provider)
|
||||
videoProviders = videos.map(\.id)
|
||||
captionProviders = captions.map(\.id)
|
||||
}
|
||||
|
||||
func select(videoProvider: String? = nil, captionProvider: String? = nil) throws -> MiruroSelection {
|
||||
guard let video = videos.first(where: { $0.provider == videoProvider }) ??
|
||||
videos.first(where: { $0.provider == "sun" }) ?? videos.first else {
|
||||
throw StreamResolutionError(message: "Direct Miruro Japanese source is unavailable.")
|
||||
func requested(_ preference: String?, provider: String, track: String) -> Bool {
|
||||
preference == "\(provider):\(track)" ||
|
||||
(preference == provider && track != "dub")
|
||||
}
|
||||
let caption = captions.first(where: { $0.provider == captionProvider }) ??
|
||||
captions.first(where: { $0.provider == video.provider }) ??
|
||||
captions.first(where: { $0.provider == "sun" }) ?? captions.first
|
||||
let source = ResolvedSource(url: video.source.url, headers: video.source.headers,
|
||||
identity: video.source.identity, title: video.source.title, subtitles: caption?.source.subtitles ?? [])
|
||||
return MiruroSelection(source: source, videoProvider: video.provider, captionProvider: caption?.provider)
|
||||
let japanese = videos.lazy.filter { $0.track != "dub" }
|
||||
guard let video = videos.first(where: { requested(videoProvider, provider: $0.provider, track: $0.track) }) ??
|
||||
japanese.first(where: { $0.provider == "sun" }) ?? japanese.first else {
|
||||
throw StreamResolutionError(message: "Miruro Japanese source is unavailable. Select an available provider and track explicitly.")
|
||||
}
|
||||
let compatibleCaptions = captions.lazy.filter { $0.track == video.track || (video.track != "dub" && $0.track != "dub") }
|
||||
let caption = captions.first(where: { requested(captionProvider, provider: $0.provider, track: $0.track) }) ??
|
||||
captions.first(where: { $0.id == video.id }) ??
|
||||
compatibleCaptions.first(where: { $0.provider == video.provider }) ??
|
||||
compatibleCaptions.first(where: { $0.provider == "sun" }) ?? compatibleCaptions.first
|
||||
let source = video.source.replacingSubtitles(caption?.subtitles ?? [])
|
||||
return MiruroSelection(source: source, videoProvider: video.id, captionProvider: caption?.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,66 +182,93 @@ enum MiruroResolver {
|
||||
let response = try JSONDecoder().decode(Sources.self, from: data)
|
||||
guard response.episodeNumber == episode else { throw InvalidProjection() }
|
||||
var videos: [MiruroSources.Candidate] = []
|
||||
var captions: [MiruroSources.Candidate] = []
|
||||
var videoNames = Set<String>()
|
||||
var captionNames = Set<String>()
|
||||
// Japanese variants only, preferring soft subtitles independently for each provider.
|
||||
for variant in ["ssub", "sub"] {
|
||||
for track in response.tracks.lazy.compactMap(\.value) where track.track == variant {
|
||||
for provider in track.providers.lazy.compactMap(\.value) {
|
||||
let name = provider.provider
|
||||
guard validProviderName(name), let config = response.config[name]?.value,
|
||||
config.player == "native", !config.proxy,
|
||||
config.hls?.segments?.enabled != true, config.hls?.query?.enabled != true else { continue }
|
||||
let needsVideo = !videoNames.contains(name)
|
||||
let needsCaptions = !captionNames.contains(name)
|
||||
guard needsVideo || needsCaptions else { continue }
|
||||
for server in provider.servers.lazy.compactMap(\.value) where !server.hasEmbed {
|
||||
let headers = server.headers ?? [:]
|
||||
var found = false
|
||||
for stream in server.streams.lazy.compactMap(\.value) {
|
||||
guard !stream.hasEmbed,
|
||||
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format),
|
||||
let video = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
|
||||
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: [])) else { continue }
|
||||
if videoNames.insert(name).inserted {
|
||||
videos.append(.init(provider: name, source: video))
|
||||
}
|
||||
// Caption requests retain this provider/server's headers, never the selected video's.
|
||||
let subtitles = provider.subtitles.lazy.compactMap(\.value).filter { $0.format == "vtt" }.map {
|
||||
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
|
||||
label: $0.label, isDefault: $0.default, headers: headers)
|
||||
}
|
||||
if needsCaptions, !subtitles.isEmpty,
|
||||
let caption = try? ResolvedSource.validated(.init(version: 1, url: stream.url,
|
||||
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: Array(subtitles))) {
|
||||
captions.append(.init(provider: name, source: caption))
|
||||
captionNames.insert(name)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
var captions: [MiruroSources.Captions] = []
|
||||
var videoIDs = Set<String>()
|
||||
var captionIDs = Set<String>()
|
||||
for track in response.tracks.lazy.compactMap(\.value) where ["ssub", "sub", "dub"].contains(track.track) {
|
||||
for provider in track.providers.lazy.compactMap(\.value) {
|
||||
let name = provider.provider
|
||||
guard validProviderName(name) else { continue }
|
||||
let id = "\(name):\(track.track)"
|
||||
let config = response.config[name]?.value
|
||||
let servers = provider.servers.compactMap(\.value)
|
||||
// Prefer supplied server headers, but do not require a playable video to use them.
|
||||
let captionServer = servers.first { !($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:]) } ??
|
||||
servers.first { ResolvedSource.validHeaders($0.headers ?? [:]) }
|
||||
if !captionIDs.contains(id), captionServer != nil || servers.isEmpty {
|
||||
var headers = captionServer?.headers ?? [:]
|
||||
var proxy = config?.proxy
|
||||
let returned = provider.subtitles.compactMap(\.value)
|
||||
// Iframe captions may be exact copies of a parent's resources.
|
||||
// Reuse only that verified caption transport, never its video rules.
|
||||
if headers.isEmpty, config?.player == "iframe", let parentName = config?.parent,
|
||||
let parent = track.providers.compactMap(\.value).first(where: { $0.provider == parentName }),
|
||||
parent.subtitles.compactMap(\.value) == returned,
|
||||
let parentServer = parent.servers.compactMap(\.value).first(where: {
|
||||
!($0.headers ?? [:]).isEmpty && ResolvedSource.validHeaders($0.headers ?? [:])
|
||||
}) {
|
||||
headers = parentServer.headers ?? [:]
|
||||
proxy = response.config[parentName]?.value?.proxy
|
||||
}
|
||||
let subtitles = returned.lazy
|
||||
.filter { $0.format == "vtt" }.map {
|
||||
ResolvedSubtitle(url: $0.file, format: $0.format, language: $0.language,
|
||||
label: $0.label, isDefault: $0.default, headers: headers, proxy: proxy)
|
||||
}
|
||||
if found { break }
|
||||
let values = Array(subtitles)
|
||||
if !values.isEmpty, ResolvedSource.validSubtitles(values) {
|
||||
captions.append(.init(provider: name, track: track.track, subtitles: values))
|
||||
captionIDs.insert(id)
|
||||
}
|
||||
}
|
||||
for server in servers {
|
||||
let headers = server.headers ?? [:]
|
||||
guard ResolvedSource.validHeaders(headers) else { continue }
|
||||
guard !videoIDs.contains(id), let config,
|
||||
var requirements = try? config.requirements() else { continue }
|
||||
var locations: [(String, Bool)] = []
|
||||
if config.player == "native", server.embed == nil {
|
||||
locations = server.streams.compactMap(\.value).compactMap { stream in
|
||||
guard stream.embed == nil,
|
||||
["hls", "m3u8", "application/vnd.apple.mpegurl", "application/x-mpegurl"].contains(stream.format) else { return nil }
|
||||
return (stream.url, false)
|
||||
}
|
||||
} else if config.player == "iframe" {
|
||||
if let embed = server.embed { locations.append((embed.url, true)) }
|
||||
locations.append(contentsOf: server.streams.lazy.compactMap(\.value).compactMap { stream in
|
||||
stream.embed.map { ($0.url, true) }
|
||||
})
|
||||
}
|
||||
for (url, embed) in locations {
|
||||
guard var source = try? ResolvedSource.validated(.init(version: 1, url: url,
|
||||
headers: headers, identity: .init(provider: "miruro", media_id: mediaID, episode: episode),
|
||||
title: nil, subtitles: [])) else { continue }
|
||||
requirements.embed = embed
|
||||
source.requirements = requirements
|
||||
videos.append(.init(provider: name, track: track.track, source: source))
|
||||
videoIDs.insert(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// API order is advisory; retain response order for names omitted from it.
|
||||
let order = response.providerOrder ?? []
|
||||
func ordered(_ candidates: [MiruroSources.Candidate]) -> [MiruroSources.Candidate] {
|
||||
var remaining = Dictionary(uniqueKeysWithValues: candidates.map { ($0.provider, $0) })
|
||||
var result: [MiruroSources.Candidate] = []
|
||||
for name in order {
|
||||
if let candidate = remaining.removeValue(forKey: name) { result.append(candidate) }
|
||||
}
|
||||
result.append(contentsOf: candidates.filter { remaining[$0.provider] != nil })
|
||||
return result
|
||||
// Provider order is advisory and deduplicated; each provider retains all usable tracks.
|
||||
var seen = Set<String>()
|
||||
let providerOrder = ((response.providerOrder ?? []) + videos.map(\.provider) + captions.map(\.provider))
|
||||
.filter { seen.insert($0).inserted }
|
||||
func variants(_ name: String) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return ((response.config[name]?.value?.variantOrder ?? []) + ["ssub", "sub", "dub"])
|
||||
.filter { seen.insert($0).inserted }
|
||||
}
|
||||
let sources = MiruroSources(videos: ordered(videos), captions: ordered(captions))
|
||||
_ = try sources.select()
|
||||
return sources
|
||||
let orderedVideos = providerOrder.flatMap { name in
|
||||
variants(name).compactMap { variant in videos.first { $0.provider == name && $0.track == variant } }
|
||||
}
|
||||
let orderedCaptions = providerOrder.flatMap { name in
|
||||
variants(name).compactMap { variant in captions.first { $0.provider == name && $0.track == variant } }
|
||||
}
|
||||
guard !orderedVideos.isEmpty || !orderedCaptions.isEmpty else { throw InvalidProjection() }
|
||||
return MiruroSources(videos: orderedVideos, captions: orderedCaptions)
|
||||
} catch {
|
||||
if let error = error as? StreamResolutionError { throw error }
|
||||
throw StreamResolutionError(message: "Miruro returned invalid or unsupported source metadata.")
|
||||
@@ -228,17 +292,57 @@ enum MiruroResolver {
|
||||
let tracks: [Entry<Track>]
|
||||
let config: [String: Entry<ProviderConfig>]
|
||||
let providerOrder: [String]?
|
||||
enum CodingKeys: String, CodingKey { case episodeNumber, tracks, config, providerOrder }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
episodeNumber = try values.decode(Int.self, forKey: .episodeNumber)
|
||||
tracks = try values.decode([Entry<Track>].self, forKey: .tracks)
|
||||
config = try values.decodeIfPresent([String: Entry<ProviderConfig>].self, forKey: .config) ?? [:]
|
||||
providerOrder = try values.decodeIfPresent([String].self, forKey: .providerOrder)
|
||||
}
|
||||
}
|
||||
private struct ProviderConfig: Decodable {
|
||||
let player: String
|
||||
// Objects describe proxy work this native player cannot perform.
|
||||
let proxy: Bool
|
||||
let proxy: MiruroProxy?
|
||||
let hls: HLS?
|
||||
let variantOrder: [String]?
|
||||
let parent: String?
|
||||
enum CodingKeys: String, CodingKey { case player, proxy, hls, variantOrder, parent }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
player = try values.decode(String.self, forKey: .player)
|
||||
if let flag = try? values.decode(Bool.self, forKey: .proxy) {
|
||||
guard !flag else { throw InvalidProjection() }
|
||||
proxy = nil
|
||||
} else {
|
||||
proxy = try values.decode(MiruroProxy.self, forKey: .proxy)
|
||||
}
|
||||
hls = try values.decodeIfPresent(HLS.self, forKey: .hls)
|
||||
variantOrder = try values.decodeIfPresent([String].self, forKey: .variantOrder)
|
||||
parent = try values.decodeIfPresent(String.self, forKey: .parent)
|
||||
}
|
||||
|
||||
func requirements() throws -> MiruroPlaybackRequirements {
|
||||
guard player == "native" || player == "iframe" else { throw InvalidProjection() }
|
||||
var result = MiruroPlaybackRequirements(proxy: proxy)
|
||||
if let segments = hls?.segments, segments.enabled == true {
|
||||
guard let origin = segments.origin, ResolvedSource.validURL(origin) else { throw InvalidProjection() }
|
||||
result.segmentOrigin = origin
|
||||
}
|
||||
if let query = hls?.query, query.enabled == true {
|
||||
guard let params = query.params, params.count <= 64,
|
||||
params.allSatisfy({ !$0.key.isEmpty && ResolvedSource.validText($0.key, limit: 256) &&
|
||||
ResolvedSource.validText($0.value, limit: 16384) }) else { throw InvalidProjection() }
|
||||
result.playlistQuery = params
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
private struct HLS: Decodable {
|
||||
struct Rule: Decodable { let enabled: Bool? }
|
||||
let segments: Rule?
|
||||
let query: Rule?
|
||||
struct Segments: Decodable { let enabled: Bool?; let origin: String? }
|
||||
struct Query: Decodable { let enabled: Bool?; let params: [String: String]? }
|
||||
let segments: Segments?
|
||||
let query: Query?
|
||||
}
|
||||
private struct Track: Decodable {
|
||||
let track: String
|
||||
@@ -248,40 +352,33 @@ enum MiruroResolver {
|
||||
let provider: String
|
||||
let servers: [Entry<Server>]
|
||||
let subtitles: [Entry<Subtitle>]
|
||||
enum CodingKeys: String, CodingKey { case provider, servers, subtitles }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
provider = try values.decode(String.self, forKey: .provider)
|
||||
servers = try values.decodeIfPresent([Entry<Server>].self, forKey: .servers) ?? []
|
||||
subtitles = try values.decodeIfPresent([Entry<Subtitle>].self, forKey: .subtitles) ?? []
|
||||
}
|
||||
}
|
||||
private struct Embed: Decodable { let url: String }
|
||||
private struct Server: Decodable {
|
||||
let headers: [String: String]?
|
||||
let streams: [Entry<Stream>]
|
||||
let hasEmbed: Bool
|
||||
let embed: Embed?
|
||||
enum CodingKeys: String, CodingKey { case headers, streams, embed }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
headers = try values.decodeIfPresent([String: String].self, forKey: .headers)
|
||||
streams = try values.decode([Entry<Stream>].self, forKey: .streams)
|
||||
if values.contains(.embed) {
|
||||
hasEmbed = try !values.decodeNil(forKey: .embed)
|
||||
} else {
|
||||
hasEmbed = false
|
||||
}
|
||||
streams = try values.decodeIfPresent([Entry<Stream>].self, forKey: .streams) ?? []
|
||||
embed = try values.decodeIfPresent(Embed.self, forKey: .embed)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
let embed: Embed?
|
||||
}
|
||||
private struct Subtitle: Decodable {
|
||||
private struct Subtitle: Decodable, Equatable {
|
||||
let file: String
|
||||
let format: String
|
||||
let language: String
|
||||
|
||||
@@ -125,14 +125,17 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var miruroCaptionProvider: String
|
||||
@Published private(set) var selectedMiruroVideoProvider: String?
|
||||
@Published private(set) var selectedMiruroCaptionProvider: String?
|
||||
private var pendingMiruro: (mediaID: String, episode: Int, sources: MiruroSources)?
|
||||
private var miruroSources: MiruroSources? {
|
||||
if let pendingMiruro { return pendingMiruro.sources }
|
||||
if case .resolved(_, let providers) = currentMedia { return providers }
|
||||
return nil
|
||||
}
|
||||
var miruroVideoProviders: [String] { miruroSources?.videoProviders ?? [] }
|
||||
var miruroCaptionProviders: [String] { miruroSources?.captionProviders ?? [] }
|
||||
var canChangeMiruroProvider: Bool {
|
||||
castDevice == nil && miruroSources != nil && canTogglePlayback &&
|
||||
castDevice == nil && miruroSources != nil &&
|
||||
(pendingMiruro != nil ? canOpen && !resolvingStream : canTogglePlayback) &&
|
||||
!isSeeking && !transition.seekPending && !state.seek_pending
|
||||
}
|
||||
@Published private(set) var subtitleTracks: [ResolvedSubtitle] = []
|
||||
@@ -421,8 +424,9 @@ final class PlayerModel: ObservableObject {
|
||||
source = .youtube(video, height: nil)
|
||||
case .miruro(let mediaID, let episode):
|
||||
let providers = try await miruroResolver(mediaID, episode)
|
||||
let resolved = try providers.select(videoProvider: miruroVideoProvider,
|
||||
let selected = try providers.select(videoProvider: miruroVideoProvider,
|
||||
captionProvider: miruroCaptionProvider).source
|
||||
let resolved = try await MiruroTransport.prepare(selected)
|
||||
guard resolved.identity == "miruro:\(mediaID):\(episode)" else { throw CocoaError(.fileReadCorruptFile) }
|
||||
url = URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines))!
|
||||
title = resolved.title
|
||||
@@ -551,7 +555,8 @@ final class PlayerModel: ObservableObject {
|
||||
let track = subtitleTracks[index]
|
||||
subtitleTask = Task { [weak self] in
|
||||
do {
|
||||
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
||||
let prepared = try await MiruroTransport.subtitle(track)
|
||||
let document = try await WebVTTDocument.load(url: prepared.url, headers: prepared.headers)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.subtitleDocument = document
|
||||
self.updateSubtitle()
|
||||
@@ -598,8 +603,13 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func setMiruroVideoProvider(_ provider: String) {
|
||||
guard canChangeMiruroProvider, let handle,
|
||||
provider.isEmpty || miruroVideoProviders.contains(provider),
|
||||
provider != miruroVideoProvider,
|
||||
provider.isEmpty || miruroVideoProviders.contains(provider) else { return }
|
||||
if let pendingMiruro {
|
||||
miruroVideoProvider = provider
|
||||
resolveMiruro(mediaID: pendingMiruro.mediaID, episode: pendingMiruro.episode)
|
||||
return
|
||||
}
|
||||
guard provider != miruroVideoProvider,
|
||||
case .resolved(let source, _) = currentMedia else { return }
|
||||
let identity = source.identity.split(separator: ":")
|
||||
guard identity.count == 3, let episode = Int(identity[2]) else { return }
|
||||
@@ -611,13 +621,18 @@ final class PlayerModel: ObservableObject {
|
||||
func setMiruroCaptionProvider(_ provider: String) {
|
||||
guard canChangeMiruroProvider, let providers = miruroSources,
|
||||
provider.isEmpty || miruroCaptionProviders.contains(provider) else { return }
|
||||
if pendingMiruro != nil {
|
||||
miruroCaptionProvider = provider
|
||||
return
|
||||
}
|
||||
guard case .resolved(let current, _) = currentMedia else { return }
|
||||
do {
|
||||
let selection = try providers.select(videoProvider: selectedMiruroVideoProvider,
|
||||
captionProvider: provider)
|
||||
let language = selectedSubtitle.flatMap { subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil }
|
||||
miruroCaptionProvider = provider
|
||||
selectedMiruroCaptionProvider = selection.captionProvider
|
||||
currentMedia = .resolved(selection.source, providers: providers)
|
||||
currentMedia = .resolved(current.replacingSubtitles(selection.source.subtitles), providers: providers)
|
||||
subtitleTracks = selection.source.subtitles
|
||||
let index = language.flatMap { language in subtitleTracks.firstIndex { $0.language == language } ??
|
||||
subtitleTracks.firstIndex(where: \.isDefault) }
|
||||
@@ -647,10 +662,12 @@ final class PlayerModel: ObservableObject {
|
||||
let videoProvider = miruroVideoProvider
|
||||
let captionProvider = miruroCaptionProvider
|
||||
let task = Task { [weak self] in
|
||||
var catalog: MiruroSources?
|
||||
do {
|
||||
let providers = try await resolver(mediaID, episode)
|
||||
catalog = providers
|
||||
let selection = try providers.select(videoProvider: videoProvider, captionProvider: captionProvider)
|
||||
let source = selection.source
|
||||
let source = try await MiruroTransport.prepare(selection.source)
|
||||
guard source.identity == "miruro:\(mediaID):\(episode)" else {
|
||||
throw CocoaError(.fileReadCorruptFile)
|
||||
}
|
||||
@@ -662,6 +679,9 @@ final class PlayerModel: ObservableObject {
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
self.objectWillChange.send()
|
||||
guard self.transition.finishResolution(request) != nil else { return }
|
||||
if let catalog, self.currentMedia?.url.absoluteString != "miruro:\(mediaID):\(episode)" {
|
||||
self.pendingMiruro = (mediaID, episode, catalog)
|
||||
}
|
||||
self.failStreamChange(change, error: error)
|
||||
}
|
||||
}
|
||||
@@ -712,6 +732,10 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func cancelStreamResolution() {
|
||||
if pendingMiruro != nil {
|
||||
objectWillChange.send()
|
||||
pendingMiruro = nil
|
||||
}
|
||||
guard transition.isResolving else { return }
|
||||
objectWillChange.send()
|
||||
transition.cancelResolution()
|
||||
@@ -872,6 +896,7 @@ final class PlayerModel: ObservableObject {
|
||||
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
||||
queueCompletionHandled = false
|
||||
currentMedia = source
|
||||
pendingMiruro = nil
|
||||
if case .resolved(_, let providers) = source,
|
||||
let selection = try? providers?.select(videoProvider: miruroVideoProvider,
|
||||
captionProvider: miruroCaptionProvider) {
|
||||
|
||||
Reference in New Issue
Block a user