feat: support Miruro provider tracks, proxies, and native embeds
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user