462 lines
25 KiB
Swift
462 lines
25 KiB
Swift
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.zoko || 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() }
|
|
if rules.zoko { throw (error as? ZokoError) ?? ZokoError.transport }
|
|
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,
|
|
(!requirements.zoko || ZokoResolver.validResource(url, playlist: kind == .playlist)),
|
|
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 requirements.zoko {
|
|
guard ZokoResolver.validResource(url, playlist: resource.kind == .playlist) else { throw ZokoError.malformed }
|
|
}
|
|
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 = requirements.zoko ? ZokoResolver.referer :
|
|
(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)
|
|
}
|
|
}
|
|
|