iOS only sent that UA when the hostname looked like viaplay; stored MPDs often do not. Event plays now pin the browser session for the whole fetch chain, still direct to the CDN.
2156 lines
94 KiB
Swift
2156 lines
94 KiB
Swift
import Foundation
|
||
import Network
|
||
import os.log
|
||
|
||
enum ViaplayPlaybackHeaders {
|
||
static let userAgent =
|
||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||
static let referer = "https://viaplay.com/"
|
||
static let origin = "https://viaplay.com"
|
||
|
||
static func matches(_ url: URL) -> Bool {
|
||
let h = (url.host ?? "").lowercased()
|
||
let s = url.absoluteString.lowercased()
|
||
if h.contains("viaplay") || h.contains("viasat") { return true }
|
||
if s.contains("viaplay") || s.contains("viasat") { return true }
|
||
if h.contains("live-dash") || h.contains("vod-dash") { return true }
|
||
if s.contains("mediapkg") || s.contains("tve_vsport") || s.contains("vp-xtra") { return true }
|
||
return false
|
||
}
|
||
|
||
static func apply(to req: inout URLRequest) {
|
||
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||
req.setValue(referer, forHTTPHeaderField: "Referer")
|
||
req.setValue(origin, forHTTPHeaderField: "Origin")
|
||
}
|
||
}
|
||
|
||
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
|
||
|
||
/// CFNetwork volgt redirects met de app-UA; wij volgen zelf en zetten Chrome/122 opnieuw.
|
||
private final class DenyRedirects: NSObject, URLSessionTaskDelegate {
|
||
func urlSession(
|
||
_ session: URLSession,
|
||
task: URLSessionTask,
|
||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||
newRequest request: URLRequest,
|
||
completionHandler: @escaping (URLRequest?) -> Void
|
||
) {
|
||
completionHandler(nil)
|
||
}
|
||
}
|
||
|
||
private let viaplayDenyRedirects = DenyRedirects()
|
||
|
||
private final class ResumeOnce: @unchecked Sendable {
|
||
private var done = false
|
||
private let lock = NSLock()
|
||
func run(_ body: () -> Void) {
|
||
lock.lock()
|
||
defer { lock.unlock() }
|
||
guard !done else { return }
|
||
done = true
|
||
body()
|
||
}
|
||
}
|
||
|
||
/// Lokale HTTP-proxy: haalt DASH MPD of HLS m3u8 + CENC-segmenten op, strip DRM, decrypt ClearKey.
|
||
/// VLC speelt daarna plain media vanaf 127.0.0.1.
|
||
final class ClearKeyDashProxy {
|
||
static let shared = ClearKeyDashProxy()
|
||
|
||
/// CMAF: video- én audio-init hebben vaak dezelfde track_ID (1).
|
||
/// Gescheiden state voorkomt dat audio-crypto video-decrypt overschrijft → geluid zonder beeld.
|
||
private enum CryptoLane: String {
|
||
case video = "v"
|
||
case audio = "a"
|
||
case shared = "s"
|
||
}
|
||
|
||
private let queue = DispatchQueue(label: "nl.vonas.vmc.clearkey-proxy")
|
||
/// Beschermt alle gedeelde session-state. Recursive: mediaPlaylist → resolveTemplateURL
|
||
/// → withManifestQuery mag genest onder withState draaien (anders deadlock = app hangt).
|
||
private let stateLock = NSRecursiveLock()
|
||
private let cryptoLock = NSLock()
|
||
private var listener: NWListener?
|
||
private var port: UInt16 = 0
|
||
private var remoteManifest: URL?
|
||
/// Eind-URL na redirects (Odido: lb → edge-sessiepad). Segmenten resolven hiertegen en
|
||
/// refreshes blijven op deze URL, anders wisselt het sessiepad per refresh (403 / seq-breuk).
|
||
private var resolvedManifest: URL?
|
||
/// Auth-query van het manifest (bijv. Akamai `hdnts=…`) — plakken op relatieve segment-URLs.
|
||
private var manifestQuery: String?
|
||
private var keyMap: ClearKeyKeyMap?
|
||
private var cryptoVideo = CencMp4Decryptor.SessionState()
|
||
private var cryptoAudio = CencMp4Decryptor.SessionState()
|
||
private var cryptoShared = CencMp4Decryptor.SessionState()
|
||
private var generation = 0
|
||
private var isHlsMode = false
|
||
/// AVPlayer kan geen DASH: serve clear HLS (native of vanuit MPD gegenereerd).
|
||
private var preferHlsOutput = false
|
||
/// Segment-CDN's zonder "viaplay" in de host krijgen dezelfde browser-UA als het manifest.
|
||
private var useViaplayBrowserHeaders = false
|
||
private var dashVideoRep: DashHlsRep?
|
||
private var dashAudioRep: DashHlsRep?
|
||
/// Beschikbare audio-reps (UI); master playlist heeft er maar één actief.
|
||
private var dashAudioReps: [DashHlsRep] = []
|
||
/// Stabiele MEDIA-SEQUENCE per segment-URL (voorkomt AVPlayer -12312).
|
||
private var segmentSeqByKey: [String: Int] = [:]
|
||
private var nextStableSeq = 1
|
||
/// Laatste niet-lege media-playlist per pad (lege refresh → -12312 / [].)
|
||
private var lastMediaPlaylistByPath: [String: String] = [:]
|
||
/// Gedeelde MPD-snapshot zodat /hls/v.m3u8 en /hls/a.m3u8 dezelfde live-edge zien
|
||
/// (anders A/V MEDIA-SEQUENCE-desync → stall na ~8–10s).
|
||
/// Som van EXTINF bij VOD/ENDLIST — VLC live-pad meldt anders duur 0 / niet-zoekbaar.
|
||
private var lastVodDurationMs: Int64 = 0
|
||
/// VLC kan HLS-cbcs via de proxy niet zoeken; we knippen de playlist vanaf dit punt.
|
||
private var playbackStartOffsetSec: Double = 0
|
||
private var cachedMpdXML: String?
|
||
private var cachedMpdAt: Date?
|
||
private let mpdCacheTTL: TimeInterval = 0.8
|
||
private let urlSession: URLSession = {
|
||
let cfg = URLSessionConfiguration.ephemeral
|
||
cfg.timeoutIntervalForRequest = 15
|
||
cfg.timeoutIntervalForResource = 45
|
||
cfg.httpMaximumConnectionsPerHost = 10
|
||
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||
return URLSession(configuration: cfg)
|
||
}()
|
||
|
||
/// CFNetwork overschrijft User-Agent op URLRequest met de app-UA; alleen session-headers blijven staan.
|
||
private let viaplayBrowserSession: URLSession = {
|
||
let cfg = URLSessionConfiguration.ephemeral
|
||
cfg.timeoutIntervalForRequest = 15
|
||
cfg.timeoutIntervalForResource = 45
|
||
cfg.httpMaximumConnectionsPerHost = 10
|
||
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||
cfg.httpAdditionalHeaders = [
|
||
"User-Agent": ViaplayPlaybackHeaders.userAgent,
|
||
"Referer": ViaplayPlaybackHeaders.referer,
|
||
"Origin": ViaplayPlaybackHeaders.origin,
|
||
"Accept": "*/*",
|
||
]
|
||
return URLSession(
|
||
configuration: cfg,
|
||
delegate: viaplayDenyRedirects,
|
||
delegateQueue: nil
|
||
)
|
||
}()
|
||
|
||
/// Serialiseert alleen manifest/playlist-requests (niet segment-decrypt).
|
||
/// Voorheen alles via één gate → segmenten blokkeerden live-refresh → stall na ~10s + zware buffer.
|
||
private let playlistGate = PayloadGate()
|
||
|
||
private init() {}
|
||
|
||
private actor PayloadGate {
|
||
func run<T>(_ operation: () async throws -> T) async throws -> T {
|
||
try await operation()
|
||
}
|
||
}
|
||
|
||
private func withState<T>(_ body: () throws -> T) rethrows -> T {
|
||
stateLock.lock()
|
||
defer { stateLock.unlock() }
|
||
return try body()
|
||
}
|
||
|
||
var isRunning: Bool {
|
||
withState { listener != nil && port > 0 }
|
||
}
|
||
|
||
struct ProxyAudioTrack: Identifiable, Hashable {
|
||
let id: String
|
||
let name: String
|
||
}
|
||
|
||
/// Audio-sporen uit de huidige DASH-sessie (voor UI; niet via HLS multi-rendition).
|
||
func availableAudioTracks() -> [ProxyAudioTrack] {
|
||
withState {
|
||
dashAudioReps.enumerated().map { i, a in
|
||
ProxyAudioTrack(id: a.id, name: Self.audioDisplayName(a, index: i))
|
||
}
|
||
}
|
||
}
|
||
|
||
var preferredAudioId: String? {
|
||
withState { dashAudioRep?.id }
|
||
}
|
||
|
||
/// Wissel actief audio-spoor. Caller herlaadt AVPlayer-item (geen HLS rendition-switch).
|
||
@discardableResult
|
||
func setPreferredAudio(id: String) -> Bool {
|
||
withState {
|
||
guard let match = dashAudioReps.first(where: { $0.id == id }) else { return false }
|
||
if dashAudioRep?.id == id { return true }
|
||
dashAudioRep = match
|
||
cryptoLock.lock()
|
||
cryptoAudio = CencMp4Decryptor.SessionState()
|
||
cryptoLock.unlock()
|
||
// Alleen audio-playlist-cache legen — video MEDIA-SEQUENCE niet resetten (crash/stotter).
|
||
lastMediaPlaylistByPath["/hls/a.m3u8"] = nil
|
||
proxyLog.info("preferred audio → \(match.id, privacy: .public)")
|
||
return true
|
||
}
|
||
}
|
||
|
||
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale URL + sessie-id terug.
|
||
struct StartResult {
|
||
let url: URL
|
||
let session: Int
|
||
let vodDurationMs: Int64
|
||
}
|
||
|
||
func vodDurationMs() -> Int64 {
|
||
withState { lastVodDurationMs }
|
||
}
|
||
|
||
func setPlaybackStartOffset(ms: Int64) {
|
||
withState {
|
||
playbackStartOffsetSec = Double(max(0, ms)) / 1000.0
|
||
lastMediaPlaylistByPath = [:]
|
||
}
|
||
}
|
||
|
||
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD- of m3u8-URL terug.
|
||
/// - Parameter preferHlsForAVPlayer: forceer altijd een lokale `.m3u8` (DASH wordt omgezet).
|
||
func start(
|
||
manifestURL: URL,
|
||
keys: [ClearKeyEntry],
|
||
preferHlsForAVPlayer: Bool = false,
|
||
browserCdnHeaders: Bool = false
|
||
) async throws -> StartResult {
|
||
let map = ClearKeyKeyMap(entries: keys)
|
||
guard !map.isEmpty else {
|
||
throw ProxyError.noKeys
|
||
}
|
||
let hls = Self.isHlsURL(manifestURL)
|
||
|
||
let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in
|
||
queue.async {
|
||
self.withState {
|
||
self.stopLocked()
|
||
self.remoteManifest = manifestURL
|
||
self.resolvedManifest = nil
|
||
self.manifestQuery = URLComponents(url: manifestURL, resolvingAgainstBaseURL: false)?.percentEncodedQuery
|
||
self.keyMap = map
|
||
self.isHlsMode = hls
|
||
self.preferHlsOutput = preferHlsForAVPlayer
|
||
self.useViaplayBrowserHeaders =
|
||
browserCdnHeaders || ViaplayPlaybackHeaders.matches(manifestURL)
|
||
if self.useViaplayBrowserHeaders {
|
||
proxyLog.info("CDN headers=Chrome/122 for whole session")
|
||
}
|
||
self.dashVideoRep = nil
|
||
self.dashAudioRep = nil
|
||
self.dashAudioReps = []
|
||
self.segmentSeqByKey = [:]
|
||
self.nextStableSeq = 1
|
||
self.lastMediaPlaylistByPath = [:]
|
||
self.lastVodDurationMs = 0
|
||
self.playbackStartOffsetSec = 0
|
||
self.cachedMpdXML = nil
|
||
self.cachedMpdAt = nil
|
||
self.cryptoLock.lock()
|
||
self.cryptoVideo = CencMp4Decryptor.SessionState()
|
||
self.cryptoAudio = CencMp4Decryptor.SessionState()
|
||
self.cryptoShared = CencMp4Decryptor.SessionState()
|
||
CencMp4Decryptor.resetDiag()
|
||
self.cryptoLock.unlock()
|
||
self.generation += 1
|
||
}
|
||
let session = self.withState { self.generation }
|
||
|
||
let once = ResumeOnce()
|
||
|
||
do {
|
||
let params = NWParameters.tcp
|
||
params.allowLocalEndpointReuse = true
|
||
let listener = try NWListener(using: params, on: .any)
|
||
self.withState { self.listener = listener }
|
||
|
||
listener.stateUpdateHandler = { [weak self] state in
|
||
guard let self else { return }
|
||
switch state {
|
||
case .ready:
|
||
let p = listener.port?.rawValue ?? 0
|
||
self.withState { self.port = p }
|
||
proxyLog.info("listener ready on port \(p, privacy: .public) session=\(session, privacy: .public)")
|
||
once.run {
|
||
if p > 0 {
|
||
cont.resume(returning: (p, session))
|
||
} else {
|
||
cont.resume(throwing: ProxyError.bindFailed)
|
||
}
|
||
}
|
||
case .failed(let err):
|
||
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
|
||
once.run { cont.resume(throwing: err) }
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
listener.newConnectionHandler = { [weak self] conn in
|
||
self?.handle(connection: conn)
|
||
}
|
||
listener.start(queue: self.queue)
|
||
|
||
self.queue.asyncAfter(deadline: .now() + 5) {
|
||
once.run { cont.resume(throwing: ProxyError.bindFailed) }
|
||
}
|
||
} catch {
|
||
once.run { cont.resume(throwing: error) }
|
||
}
|
||
}
|
||
}
|
||
|
||
guard bound.port > 0 else { throw ProxyError.bindFailed }
|
||
let gen = bound.session
|
||
let entryPath = (hls || preferHlsForAVPlayer) ? "/manifest.m3u8" : "/manifest.mpd"
|
||
|
||
let (body, _) = try await payload(for: entryPath)
|
||
guard !body.isEmpty else {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
let stillActive = withState { gen == self.generation }
|
||
guard stillActive else {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
|
||
let kind = (hls || preferHlsForAVPlayer) ? "HLS" : "MPD"
|
||
proxyLog.info(
|
||
"warmup \(kind, privacy: .public) ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)"
|
||
)
|
||
|
||
if hls || preferHlsForAVPlayer {
|
||
await peekVodDuration(fromMaster: body)
|
||
}
|
||
|
||
// Prefetch init segments zodat CENC track-crypto klaar is vóór AVPlayer media vraagt.
|
||
if preferHlsForAVPlayer {
|
||
await prefetchInitsForAVPlayer()
|
||
}
|
||
|
||
guard let local = URL(string: "http://127.0.0.1:\(bound.port)\(entryPath)") else {
|
||
throw ProxyError.bindFailed
|
||
}
|
||
return StartResult(url: local, session: gen, vodDurationMs: withState { lastVodDurationMs })
|
||
}
|
||
|
||
/// Haal één media-playlist op zodat we ENDLIST/duur kennen vóór VLC start.
|
||
private func peekVodDuration(fromMaster data: Data) async {
|
||
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
|
||
var videoPath: String?
|
||
var audioPath: String?
|
||
for raw in text.components(separatedBy: .newlines) {
|
||
let line = raw.trimmingCharacters(in: .whitespaces)
|
||
let uri: String?
|
||
if line.hasPrefix("#"), let q = Self.hlsMediaAttr(line, "URI") {
|
||
uri = q
|
||
} else if line.hasPrefix("http://127.0.0.1:") {
|
||
uri = line
|
||
} else {
|
||
uri = nil
|
||
}
|
||
guard let uri, let url = URL(string: uri) else { continue }
|
||
let path = url.path + (url.query.map { "?\($0)" } ?? "")
|
||
guard path.hasPrefix("/a/") else { continue }
|
||
if path.contains("ck=v") { videoPath = path }
|
||
else if path.contains("ck=a") { audioPath = path }
|
||
else if videoPath == nil { videoPath = path }
|
||
}
|
||
guard let path = videoPath ?? audioPath else { return }
|
||
_ = try? await payload(for: path)
|
||
let ms = withState { lastVodDurationMs }
|
||
if ms > 0 {
|
||
proxyLog.info("vod duration \(ms, privacy: .public)ms from peek")
|
||
}
|
||
}
|
||
|
||
private func prefetchInitsForAVPlayer() async {
|
||
let (keyMap, videoRep, audioRep): (ClearKeyKeyMap?, DashHlsRep?, DashHlsRep?) = withState {
|
||
(self.keyMap, self.dashVideoRep, self.dashAudioRep)
|
||
}
|
||
guard let keyMap else { return }
|
||
|
||
async let videoDone: Void = {
|
||
guard let rep = videoRep, !rep.initialization.isEmpty else { return }
|
||
let initURL = self.resolveTemplateURL(
|
||
rep.initialization,
|
||
base: rep.baseURL,
|
||
repId: rep.id,
|
||
bandwidth: rep.bandwidth,
|
||
number: nil,
|
||
time: nil
|
||
)
|
||
do {
|
||
_ = try await self.fetchAndDecrypt(initURL, keys: keyMap, lane: .video)
|
||
let info = self.cryptoSummary(.video)
|
||
proxyLog.info("prefetch VIDEO init ok rep=\(rep.id, privacy: .public) \(info, privacy: .public)")
|
||
} catch {
|
||
proxyLog.error("prefetch VIDEO init failed: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
}()
|
||
|
||
async let audioDone: Void = {
|
||
guard let rep = audioRep, !rep.initialization.isEmpty else { return }
|
||
let initURL = self.resolveTemplateURL(
|
||
rep.initialization,
|
||
base: rep.baseURL,
|
||
repId: rep.id,
|
||
bandwidth: rep.bandwidth,
|
||
number: nil,
|
||
time: nil
|
||
)
|
||
do {
|
||
_ = try await self.fetchAndDecrypt(initURL, keys: keyMap, lane: .audio)
|
||
let info = self.cryptoSummary(.audio)
|
||
proxyLog.info("prefetch AUDIO init ok rep=\(rep.id, privacy: .public) \(info, privacy: .public)")
|
||
} catch {
|
||
proxyLog.error("prefetch AUDIO init failed: \(error.localizedDescription, privacy: .public)")
|
||
}
|
||
}()
|
||
|
||
_ = await (videoDone, audioDone)
|
||
}
|
||
|
||
private func cryptoSummary(_ lane: CryptoLane) -> String {
|
||
cryptoLock.lock()
|
||
defer { cryptoLock.unlock() }
|
||
let state: CencMp4Decryptor.SessionState
|
||
switch lane {
|
||
case .video: state = cryptoVideo
|
||
case .audio: state = cryptoAudio
|
||
case .shared: state = cryptoShared
|
||
}
|
||
guard let c = state.byTrackId.values.first ?? state.fallback else {
|
||
return "crypto=none"
|
||
}
|
||
return "scheme=\(c.scheme) ivSize=\(c.perSampleIvSize) pattern=\(c.cryptByteBlock):\(c.skipByteBlock) tracks=\(state.byTrackId.count)"
|
||
}
|
||
|
||
static func isHlsURL(_ url: URL) -> Bool {
|
||
let s = url.absoluteString.lowercased()
|
||
return s.contains(".m3u8") || s.contains("m3u8")
|
||
}
|
||
|
||
func stop() {
|
||
withState { stopLocked() }
|
||
}
|
||
|
||
/// Stop alleen als dit nog de actieve sessie is (voorkomt race met nieuwe play).
|
||
func stop(session: Int) {
|
||
withState {
|
||
guard session == generation else {
|
||
proxyLog.info("skip stop session=\(session, privacy: .public) current=\(self.generation, privacy: .public)")
|
||
return
|
||
}
|
||
stopLocked()
|
||
}
|
||
}
|
||
|
||
private func stopLocked() {
|
||
generation += 1
|
||
listener?.cancel()
|
||
listener = nil
|
||
port = 0
|
||
remoteManifest = nil
|
||
resolvedManifest = nil
|
||
manifestQuery = nil
|
||
keyMap = nil
|
||
isHlsMode = false
|
||
preferHlsOutput = false
|
||
useViaplayBrowserHeaders = false
|
||
dashVideoRep = nil
|
||
dashAudioRep = nil
|
||
dashAudioReps = []
|
||
segmentSeqByKey = [:]
|
||
nextStableSeq = 1
|
||
lastMediaPlaylistByPath = [:]
|
||
lastVodDurationMs = 0
|
||
playbackStartOffsetSec = 0
|
||
cachedMpdXML = nil
|
||
cachedMpdAt = nil
|
||
cryptoLock.lock()
|
||
cryptoVideo = CencMp4Decryptor.SessionState()
|
||
cryptoAudio = CencMp4Decryptor.SessionState()
|
||
cryptoShared = CencMp4Decryptor.SessionState()
|
||
cryptoLock.unlock()
|
||
proxyLog.info("proxy stopped")
|
||
}
|
||
|
||
// MARK: - HTTP
|
||
|
||
private func handle(connection: NWConnection) {
|
||
connection.start(queue: queue)
|
||
receiveRequest(on: connection, buffer: Data())
|
||
}
|
||
|
||
private func receiveRequest(on connection: NWConnection, buffer: Data) {
|
||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in
|
||
guard let self else {
|
||
connection.cancel()
|
||
return
|
||
}
|
||
if error != nil {
|
||
connection.cancel()
|
||
return
|
||
}
|
||
var buf = buffer
|
||
if let data { buf.append(data) }
|
||
if let range = buf.range(of: Data("\r\n\r\n".utf8)) {
|
||
let headerData = buf.subdata(in: 0..<range.lowerBound)
|
||
self.serve(connection: connection, headerData: headerData)
|
||
return
|
||
}
|
||
if isComplete {
|
||
connection.cancel()
|
||
return
|
||
}
|
||
if buf.count > 256 * 1024 {
|
||
connection.cancel()
|
||
return
|
||
}
|
||
self.receiveRequest(on: connection, buffer: buf)
|
||
}
|
||
}
|
||
|
||
private func serve(connection: NWConnection, headerData: Data) {
|
||
guard let header = String(data: headerData, encoding: .utf8),
|
||
let firstLine = header.split(separator: "\r\n", omittingEmptySubsequences: false).first
|
||
else {
|
||
respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8))
|
||
return
|
||
}
|
||
let parts = firstLine.split(separator: " ")
|
||
guard parts.count >= 2 else {
|
||
respond(connection, status: 400, contentType: "text/plain", body: Data("bad request".utf8))
|
||
return
|
||
}
|
||
let method = String(parts[0])
|
||
let pathAndQuery = String(parts[1])
|
||
guard method == "GET" || method == "HEAD" else {
|
||
respond(connection, status: 405, contentType: "text/plain", body: Data("method".utf8))
|
||
return
|
||
}
|
||
|
||
let rangeHeader = Self.parseHeader(header, name: "Range")
|
||
|
||
Task {
|
||
do {
|
||
let (body, contentType) = try await self.payload(for: pathAndQuery)
|
||
if let rangeHeader, let byteRange = Self.parseByteRange(rangeHeader, total: body.count) {
|
||
let slice = body.subdata(in: byteRange.start..<(byteRange.end + 1))
|
||
if method == "HEAD" {
|
||
self.respond(
|
||
connection,
|
||
status: 206,
|
||
contentType: contentType,
|
||
body: Data(),
|
||
headOnly: true,
|
||
contentLength: slice.count,
|
||
extraHeaders: [
|
||
"Accept-Ranges: bytes",
|
||
"Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
|
||
]
|
||
)
|
||
} else {
|
||
self.respond(
|
||
connection,
|
||
status: 206,
|
||
contentType: contentType,
|
||
body: slice,
|
||
extraHeaders: [
|
||
"Accept-Ranges: bytes",
|
||
"Content-Range: bytes \(byteRange.start)-\(byteRange.end)/\(body.count)",
|
||
]
|
||
)
|
||
}
|
||
} else if method == "HEAD" {
|
||
self.respond(
|
||
connection,
|
||
status: 200,
|
||
contentType: contentType,
|
||
body: Data(),
|
||
headOnly: true,
|
||
contentLength: body.count,
|
||
extraHeaders: ["Accept-Ranges: bytes"]
|
||
)
|
||
} else {
|
||
self.respond(
|
||
connection,
|
||
status: 200,
|
||
contentType: contentType,
|
||
body: body,
|
||
extraHeaders: ["Accept-Ranges: bytes"]
|
||
)
|
||
}
|
||
} catch {
|
||
let msg = error.localizedDescription
|
||
proxyLog.error("serve \(pathAndQuery, privacy: .public) → \(msg, privacy: .public)")
|
||
self.respond(connection, status: 502, contentType: "text/plain; charset=utf-8", body: Data(msg.utf8))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func payload(for pathAndQuery: String) async throws -> (Data, String) {
|
||
let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery
|
||
let isPlaylist =
|
||
pathOnly == "/"
|
||
|| pathOnly.hasSuffix(".m3u8")
|
||
|| pathOnly.hasSuffix(".mpd")
|
||
|| pathOnly.hasPrefix("/hls/")
|
||
if isPlaylist {
|
||
return try await playlistGate.run {
|
||
try await self.payloadBody(for: pathAndQuery)
|
||
}
|
||
}
|
||
// Segmenten parallel — cryptoLock beschermt decrypt-state.
|
||
return try await payloadBody(for: pathAndQuery)
|
||
}
|
||
|
||
private func payloadBody(for pathAndQuery: String) async throws -> (Data, String) {
|
||
let snap = withState { () -> (ClearKeyKeyMap, URL, Bool, Bool)? in
|
||
guard let keyMap, let remoteManifest else { return nil }
|
||
return (keyMap, remoteManifest, isHlsMode, preferHlsOutput)
|
||
}
|
||
guard let (keyMap, remoteManifest, isHlsMode, preferHlsOutput) = snap else {
|
||
throw ProxyError.notStarted
|
||
}
|
||
let split = pathAndQuery.split(separator: "?", maxSplits: 1).map(String.init)
|
||
let pathOnly = split.first ?? pathAndQuery
|
||
let query = split.count > 1 ? split[1] : nil
|
||
|
||
if pathOnly == "/" || pathOnly == "/manifest.mpd" || pathOnly == "/manifest.m3u8" {
|
||
let (raw, manifestBase) = try await fetchManifest(remoteManifest)
|
||
if isHlsMode || Self.looksLikeHls(raw) {
|
||
let text = String(data: raw, encoding: .utf8) ?? String(decoding: raw, as: UTF8.self)
|
||
guard text.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("#EXTM3U") else {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
let rewritten = rewriteHls(text, playlistURL: manifestBase)
|
||
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
||
}
|
||
let xml: String = {
|
||
if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s }
|
||
return String(decoding: raw, as: UTF8.self)
|
||
}()
|
||
guard !xml.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
// AVPlayer: DASH → HLS master (+ media playlists via /hls/*).
|
||
if preferHlsOutput || pathOnly == "/manifest.m3u8" {
|
||
let hls = try withState { try dashToHlsMaster(xml, mpdURL: manifestBase) }
|
||
return (Data(hls.utf8), "application/vnd.apple.mpegurl")
|
||
}
|
||
let rewritten = rewriteMpd(xml, mpdURL: manifestBase)
|
||
if !rewritten.localizedCaseInsensitiveContains("<MPD") {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
return (Data(rewritten.utf8), "application/dash+xml")
|
||
}
|
||
|
||
if pathOnly == "/hls/v.m3u8" || pathOnly == "/hls/a.m3u8" {
|
||
try await refreshDashRepsFromRemote()
|
||
let body: String = withState {
|
||
let wantAudio = pathOnly == "/hls/a.m3u8"
|
||
let rep = wantAudio ? (dashAudioRep ?? dashAudioReps.first) : dashVideoRep
|
||
guard let rep else { return "" }
|
||
let lane: CryptoLane = wantAudio ? .audio : .video
|
||
var built = mediaPlaylist(for: rep, lane: lane)
|
||
let hasSegments = built.contains("#EXTINF:")
|
||
if hasSegments {
|
||
lastMediaPlaylistByPath[pathOnly] = built
|
||
} else if let cached = lastMediaPlaylistByPath[pathOnly], !cached.isEmpty {
|
||
proxyLog.error("empty \(pathOnly, privacy: .public) playlist — serving last good")
|
||
built = cached
|
||
}
|
||
return built
|
||
}
|
||
guard !body.isEmpty else { throw ProxyError.emptyManifest }
|
||
return (Data(body.utf8), "application/vnd.apple.mpegurl")
|
||
}
|
||
|
||
if pathOnly.hasPrefix("/a/") {
|
||
let b64 = String(pathOnly.dropFirst(3))
|
||
guard var url = decodeURL(b64) else { throw ProxyError.badPath }
|
||
let (lane, forwardQuery) = Self.splitLaneQuery(query)
|
||
if let forwardQuery, url.query == nil {
|
||
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||
comps?.percentEncodedQuery = forwardQuery
|
||
if let u = comps?.url { url = u }
|
||
}
|
||
return try await fetchAndDecrypt(url, keys: keyMap, lane: lane)
|
||
}
|
||
|
||
if pathOnly.hasPrefix("/p/") {
|
||
let rest = String(pathOnly.dropFirst(3))
|
||
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
|
||
let b64 = String(rest[..<slash])
|
||
var rel = String(rest[rest.index(after: slash)...])
|
||
let (_, forwardQuery) = Self.splitLaneQuery(query)
|
||
if let forwardQuery {
|
||
rel += (rel.contains("?") ? "&" : "?") + forwardQuery
|
||
}
|
||
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
|
||
let joined = join(base: asDirectory(base), relative: rel.removingPercentEncoding ?? rel)
|
||
return try await fetchAndDecrypt(joined, keys: keyMap, lane: .shared)
|
||
}
|
||
|
||
throw ProxyError.badPath
|
||
}
|
||
|
||
/// Haal `ck=v|a` uit proxy-query; stuur die NIET door naar de CDN.
|
||
private static func splitLaneQuery(_ query: String?) -> (CryptoLane, String?) {
|
||
guard let query, !query.isEmpty else { return (.shared, nil) }
|
||
var lane: CryptoLane = .shared
|
||
var kept: [String] = []
|
||
for part in query.split(separator: "&").map(String.init) {
|
||
let lower = part.lowercased()
|
||
if lower == "ck=v" || lower.hasPrefix("ck=v&") {
|
||
lane = .video
|
||
continue
|
||
}
|
||
if lower == "ck=a" {
|
||
lane = .audio
|
||
continue
|
||
}
|
||
if lower.hasPrefix("ck=") {
|
||
let v = String(lower.dropFirst(3))
|
||
if v == "v" { lane = .video; continue }
|
||
if v == "a" { lane = .audio; continue }
|
||
}
|
||
kept.append(part)
|
||
}
|
||
return (lane, kept.isEmpty ? nil : kept.joined(separator: "&"))
|
||
}
|
||
|
||
private func fetchAndDecrypt(
|
||
_ url: URL,
|
||
keys: ClearKeyKeyMap,
|
||
lane: CryptoLane = .shared
|
||
) async throws -> (Data, String) {
|
||
var data = try await fetch(url)
|
||
let lower = url.absoluteString.lowercased()
|
||
if Self.isHlsURL(url) || Self.looksLikeHls(data) {
|
||
let text = String(data: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self)
|
||
let rewritten = rewriteHls(text, playlistURL: url, laneHint: lane)
|
||
return (Data(rewritten.utf8), "application/vnd.apple.mpegurl")
|
||
}
|
||
let isManifest = lower.contains(".mpd")
|
||
if isManifest, let xml = String(data: data, encoding: .utf8) ?? Optional(String(decoding: data, as: UTF8.self)) {
|
||
let rewritten = rewriteMpd(xml, mpdURL: url)
|
||
return (Data(rewritten.utf8), "application/dash+xml")
|
||
}
|
||
cryptoLock.lock()
|
||
defer { cryptoLock.unlock() }
|
||
if CencMp4Decryptor.diagSink == nil {
|
||
CencMp4Decryptor.diagSink = { proxyLog.info("\($0, privacy: .public)") }
|
||
}
|
||
CencMp4Decryptor.diagTag = lane.rawValue
|
||
switch lane {
|
||
case .video:
|
||
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoVideo)
|
||
case .audio:
|
||
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoAudio)
|
||
case .shared:
|
||
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoShared)
|
||
}
|
||
// fMP4: generieke mp4 content-type (AVPlayer accepteert dit voor audio+video CMAF).
|
||
let ct = "application/mp4"
|
||
return (data, ct)
|
||
}
|
||
|
||
private static func looksLikeHls(_ data: Data) -> Bool {
|
||
let head = String(decoding: data.prefix(16), as: UTF8.self)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return head.hasPrefix("#EXTM3U")
|
||
}
|
||
|
||
/// Strip Widevine/PlayReady KEY-tags; herschrijf media-URI's naar de proxy.
|
||
/// ESPN EVENT-playlists met duizenden segmenten → trim naar live-edge (anders audio-only / geen beeld).
|
||
private func rewriteHls(_ text: String, playlistURL: URL, laneHint: CryptoLane = .shared) -> String {
|
||
let lines = text.components(separatedBy: .newlines)
|
||
let isMaster = lines.contains { $0.hasPrefix("#EXT-X-STREAM-INF") }
|
||
var out: [String] = []
|
||
out.reserveCapacity(min(lines.count, 64))
|
||
|
||
if isMaster {
|
||
return rewriteHlsMaster(lines, playlistURL: playlistURL)
|
||
}
|
||
|
||
// Gescheiden crypto-lanes: CMAF audio+video delen vaak track_ID=1.
|
||
// Shared state → audio-init overschrijft video → geluid zonder beeld (ESPN).
|
||
// De master kent TYPE=AUDIO al toe (ck=a); de URL-heuristiek mist o.a. F1TV-audio.
|
||
let guessed = Self.hlsMediaLane(for: playlistURL)
|
||
let lane = laneHint == .shared ? guessed : laneHint
|
||
proxyLog.info("media playlist lane=\(lane.rawValue, privacy: .public) hint=\(laneHint.rawValue, privacy: .public) guess=\(guessed.rawValue, privacy: .public) file=\(playlistURL.lastPathComponent, privacy: .public)")
|
||
|
||
// Media playlist: verzamel tags + segmenten, trim live-edge.
|
||
var header: [String] = []
|
||
var mapLine: String?
|
||
var segments: [(inf: String, uri: String)] = []
|
||
var pendingInf: String?
|
||
var mediaSequence: Int?
|
||
var targetDuration: Int = 6
|
||
var hasEndList = false
|
||
var playlistType: String?
|
||
|
||
for line in lines {
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.hasPrefix("#EXT-X-KEY:") || trimmed.hasPrefix("#EXT-X-SESSION-KEY:") {
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-PLAYLIST-TYPE:") {
|
||
playlistType = trimmed.split(separator: ":").last
|
||
.map { String($0).trimmingCharacters(in: .whitespaces).uppercased() }
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-MEDIA-SEQUENCE:") {
|
||
if let v = Int(trimmed.split(separator: ":").last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "") {
|
||
mediaSequence = v
|
||
}
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-TARGETDURATION:") {
|
||
if let v = Int(trimmed.split(separator: ":").last.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "") {
|
||
targetDuration = max(1, v)
|
||
}
|
||
header.append(line)
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-MAP:") {
|
||
mapLine = rewriteHlsTagURIs(line, base: playlistURL, lane: lane)
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-ENDLIST") {
|
||
hasEndList = true
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXTINF:") {
|
||
pendingInf = line
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#") {
|
||
if trimmed.hasPrefix("#EXTM3U") || trimmed.hasPrefix("#EXT-X-VERSION")
|
||
|| trimmed.hasPrefix("#EXT-X-INDEPENDENT") || trimmed.hasPrefix("#EXT-X-PROGRAM-DATE-TIME")
|
||
|| trimmed.hasPrefix("#EXT-X-DISCONTINUITY") || trimmed.hasPrefix("#EXT-X-BYTERANGE")
|
||
{
|
||
header.append(trimmed.hasPrefix("#EXT-X-PROGRAM-DATE-TIME") || trimmed.hasPrefix("#EXT-X-DISCONTINUITY") || trimmed.hasPrefix("#EXT-X-BYTERANGE")
|
||
? line
|
||
: rewriteHlsTagURIs(line, base: playlistURL, lane: lane))
|
||
}
|
||
continue
|
||
}
|
||
if trimmed.isEmpty { continue }
|
||
if let inf = pendingInf {
|
||
segments.append((inf, trimmed))
|
||
pendingInf = nil
|
||
}
|
||
}
|
||
|
||
// Live-edge alleen voor echte live (ESPN). F1-replays / VOD hebben ENDLIST of
|
||
// een lange statische lijst: trim + START=-12 + geen ENDLIST → ~12s beeld, dan vast.
|
||
let host = (playlistURL.host ?? "").lowercased()
|
||
let isComplete = hasEndList || playlistType == "VOD"
|
||
let isF1 = host.contains("formula1.com")
|
||
let keep = 14
|
||
let startIdx: Int
|
||
let seqBase = mediaSequence ?? 0
|
||
if isComplete || isF1 {
|
||
startIdx = 0
|
||
} else if segments.count > keep {
|
||
startIdx = segments.count - keep
|
||
} else {
|
||
startIdx = 0
|
||
}
|
||
let window = Array(segments.suffix(from: startIdx))
|
||
if isComplete || (isF1 && startIdx == 0 && window.count > 40) {
|
||
let ms = Self.playlistDurationMs(window.map(\.inf), fallbackSeconds: targetDuration)
|
||
withState {
|
||
if ms > self.lastVodDurationMs { self.lastVodDurationMs = ms }
|
||
}
|
||
}
|
||
let seekSec = withState { self.playbackStartOffsetSec }
|
||
var playFrom = 0
|
||
if seekSec > 0.4, isComplete || isF1 {
|
||
playFrom = Self.segmentIndex(at: seekSec, infLines: window.map(\.inf), fallbackSeconds: targetDuration)
|
||
}
|
||
let playWindow = Array(window.suffix(from: playFrom))
|
||
let seq = seqBase + startIdx + playFrom
|
||
proxyLog.info("hls rewrite segs=\(segments.count, privacy: .public) keep=\(playWindow.count, privacy: .public) endlist=\(hasEndList, privacy: .public) type=\(playlistType ?? "-", privacy: .public) f1=\(isF1, privacy: .public) startIdx=\(startIdx, privacy: .public) seek=\(Int(seekSec), privacy: .public)s vodMs=\(self.lastVodDurationMs, privacy: .public)")
|
||
|
||
out.append("#EXTM3U")
|
||
out.append("#EXT-X-VERSION:7")
|
||
out.append("#EXT-X-TARGETDURATION:\(targetDuration)")
|
||
out.append("#EXT-X-MEDIA-SEQUENCE:\(seq)")
|
||
out.append("#EXT-X-INDEPENDENT-SEGMENTS")
|
||
if isComplete {
|
||
out.append("#EXT-X-PLAYLIST-TYPE:VOD")
|
||
}
|
||
// Live-achtig: start dicht bij einde. Nooit op een complete/F1-replay.
|
||
if startIdx > 0 {
|
||
out.append("#EXT-X-START:TIME-OFFSET=-12.0,PRECISE=YES")
|
||
}
|
||
if let mapLine {
|
||
out.append(mapLine)
|
||
} else {
|
||
for h in header where h.hasPrefix("#EXT-X-MAP") {
|
||
out.append(h)
|
||
}
|
||
}
|
||
for seg in playWindow {
|
||
out.append(seg.inf)
|
||
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri)
|
||
out.append(proxyAbsoluteURL(resolved, lane: lane))
|
||
}
|
||
if isComplete {
|
||
out.append("#EXT-X-ENDLIST")
|
||
}
|
||
return out.joined(separator: "\n")
|
||
}
|
||
|
||
private static func playlistDurationMs(_ infLines: [String], fallbackSeconds: Int) -> Int64 {
|
||
var total: Double = 0
|
||
for inf in infLines {
|
||
total += extinfSeconds(inf, fallback: fallbackSeconds)
|
||
}
|
||
return Int64(total * 1000)
|
||
}
|
||
|
||
private static func segmentIndex(at seconds: Double, infLines: [String], fallbackSeconds: Int) -> Int {
|
||
guard !infLines.isEmpty else { return 0 }
|
||
var acc = 0.0
|
||
for (i, inf) in infLines.enumerated() {
|
||
acc += extinfSeconds(inf, fallback: fallbackSeconds)
|
||
if acc >= seconds { return i }
|
||
}
|
||
return max(0, infLines.count - 1)
|
||
}
|
||
|
||
private static func extinfSeconds(_ inf: String, fallback: Int) -> Double {
|
||
let raw = inf.trimmingCharacters(in: .whitespaces)
|
||
let rest = raw.hasPrefix("#EXTINF:") ? String(raw.dropFirst(8)) : raw
|
||
let token = rest.split(separator: ",", maxSplits: 1).first.map(String.init) ?? ""
|
||
if let seconds = Double(token.trimmingCharacters(in: .whitespaces)) {
|
||
return seconds
|
||
}
|
||
return Double(max(fallback, 1))
|
||
}
|
||
|
||
/// Audio- vs video-playlist herkennen (ESPN: `audio-aac-…` vs `cmaf-cenc-…`).
|
||
private static func hlsMediaLane(for playlistURL: URL) -> CryptoLane {
|
||
let s = playlistURL.path.lowercased()
|
||
if s.contains("/audio") || s.contains("audio-") || s.contains("aac")
|
||
|| s.contains(".mp4a") || s.contains("mp4a")
|
||
{
|
||
return .audio
|
||
}
|
||
return .video
|
||
}
|
||
|
||
/// Master: strip DRM keys, proxy alle variant/audio URI's — audio→ck=a, video→ck=v.
|
||
private func rewriteHlsMaster(_ lines: [String], playlistURL: URL) -> String {
|
||
var out: [String] = []
|
||
var audioLines: [String] = []
|
||
var i = 0
|
||
while i < lines.count {
|
||
let line = lines[i]
|
||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||
if trimmed.hasPrefix("#EXT-X-KEY:") || trimmed.hasPrefix("#EXT-X-SESSION-KEY:") {
|
||
i += 1
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-I-FRAME-STREAM-INF") {
|
||
i += 1
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-STREAM-INF") {
|
||
let inf = rewriteHlsTagURIs(line, base: playlistURL, lane: .video)
|
||
i += 1
|
||
while i < lines.count && lines[i].trimmingCharacters(in: .whitespaces).isEmpty { i += 1 }
|
||
guard i < lines.count else { break }
|
||
let uri = lines[i].trimmingCharacters(in: .whitespaces)
|
||
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: uri)
|
||
out.append(inf)
|
||
out.append(proxyAbsoluteURL(resolved, lane: .video))
|
||
i += 1
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#EXT-X-MEDIA:") {
|
||
let lane: CryptoLane = trimmed.contains("TYPE=AUDIO") ? .audio : .shared
|
||
let rewritten = rewriteHlsTagURIs(line, base: playlistURL, lane: lane)
|
||
if lane == .audio {
|
||
audioLines.append(rewritten)
|
||
} else {
|
||
out.append(rewritten)
|
||
}
|
||
i += 1
|
||
continue
|
||
}
|
||
if trimmed.hasPrefix("#") {
|
||
out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: .shared))
|
||
} else if !trimmed.isEmpty {
|
||
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: trimmed)
|
||
out.append(proxyAbsoluteURL(resolved, lane: .video))
|
||
} else {
|
||
out.append(line)
|
||
}
|
||
i += 1
|
||
}
|
||
if !audioLines.isEmpty {
|
||
let ranked = Self.rankHlsAudioLines(audioLines)
|
||
if let idx = out.firstIndex(where: { $0.hasPrefix("#EXT-X-STREAM-INF") }) {
|
||
out.insert(contentsOf: ranked, at: idx)
|
||
} else {
|
||
out.append(contentsOf: ranked)
|
||
}
|
||
}
|
||
return out.joined(separator: "\n")
|
||
}
|
||
|
||
/// NL → Engels → rest. Winnaar krijgt DEFAULT=YES zodat VLC niet op Spaans start.
|
||
private static func rankHlsAudioLines(_ lines: [String]) -> [String] {
|
||
let scored = lines.enumerated().map { i, line in (line, hlsAudioScore(line) * 100 - i) }
|
||
let sorted = scored.sorted { $0.1 > $1.1 }.map(\.0)
|
||
guard let first = sorted.first else { return lines }
|
||
var out: [String] = []
|
||
for (i, line) in sorted.enumerated() {
|
||
out.append(hlsSetAttr(hlsSetAttr(line, key: "DEFAULT", value: i == 0 ? "YES" : "NO"), key: "AUTOSELECT", value: "YES"))
|
||
}
|
||
let lang = hlsMediaAttr(first, "LANGUAGE") ?? "-"
|
||
let name = hlsMediaAttr(first, "NAME") ?? "-"
|
||
proxyLog.info("hls audio default lang=\(lang, privacy: .public) name=\(name, privacy: .public) count=\(sorted.count, privacy: .public)")
|
||
return out
|
||
}
|
||
|
||
private static func hlsAudioScore(_ line: String) -> Int {
|
||
let lang = (hlsMediaAttr(line, "LANGUAGE") ?? "").lowercased()
|
||
let name = (hlsMediaAttr(line, "NAME") ?? "").lowercased()
|
||
if ["nl", "dut", "nld"].contains(lang) || name.contains("nederlands") || name.contains("dutch") {
|
||
return 100
|
||
}
|
||
if ["en", "eng"].contains(lang) || name.contains("english") || name.contains("engels") {
|
||
return 50
|
||
}
|
||
if name.contains("international") { return 35 }
|
||
return 0
|
||
}
|
||
|
||
private static func hlsMediaAttr(_ line: String, _ key: String) -> String? {
|
||
let pattern = #"\#(key)=(?:"([^"]*)"|([^,]*))"#
|
||
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return nil }
|
||
let ns = line as NSString
|
||
guard let m = re.firstMatch(in: line, options: [], range: NSRange(location: 0, length: ns.length)) else { return nil }
|
||
if m.numberOfRanges > 1, m.range(at: 1).location != NSNotFound {
|
||
let v = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
|
||
return v.isEmpty ? nil : v
|
||
}
|
||
if m.numberOfRanges > 2, m.range(at: 2).location != NSNotFound {
|
||
let v = ns.substring(with: m.range(at: 2)).trimmingCharacters(in: .whitespaces)
|
||
return v.isEmpty ? nil : v
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func hlsSetAttr(_ line: String, key: String, value: String) -> String {
|
||
let pattern = #"\#(key)=(?:"[^"]*"|[^,]*)"#
|
||
let replacement = "\(key)=\"\(value)\""
|
||
guard let re = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else {
|
||
return line + ",\(replacement)"
|
||
}
|
||
let ns = line as NSString
|
||
let range = NSRange(location: 0, length: ns.length)
|
||
if re.firstMatch(in: line, options: [], range: range) != nil {
|
||
return re.stringByReplacingMatches(in: line, options: [], range: range, withTemplate: replacement)
|
||
}
|
||
if line.hasPrefix("#EXT-X-MEDIA:") {
|
||
return "#EXT-X-MEDIA:\(replacement)," + String(line.dropFirst("#EXT-X-MEDIA:".count))
|
||
}
|
||
return line + ",\(replacement)"
|
||
}
|
||
|
||
private func rewriteHlsTagURIs(_ line: String, base: URL, lane: CryptoLane = .shared) -> String {
|
||
guard let regex = try? NSRegularExpression(pattern: #"URI="([^"]+)""#, options: []) else {
|
||
return line
|
||
}
|
||
let ns = line as NSString
|
||
let matches = regex.matches(in: line, options: [], range: NSRange(location: 0, length: ns.length))
|
||
var result = line
|
||
for match in matches.reversed() {
|
||
guard match.numberOfRanges >= 2 else { continue }
|
||
let uri = ns.substring(with: match.range(at: 1))
|
||
if uri.hasPrefix("data:") { continue }
|
||
if uri.contains("127.0.0.1:\(port)") { continue }
|
||
let resolved: URL
|
||
if uri.hasPrefix("http://") || uri.hasPrefix("https://") {
|
||
resolved = URL(string: uri) ?? base
|
||
} else {
|
||
resolved = join(base: asDirectory(base.deletingLastPathComponent()), relative: uri)
|
||
}
|
||
let replacement = "URI=\"\(proxyAbsoluteURL(resolved, lane: lane))\""
|
||
let full = match.range
|
||
result = (result as NSString).replacingCharacters(in: full, with: replacement)
|
||
}
|
||
return result
|
||
}
|
||
|
||
private func proxyAbsoluteURL(_ url: URL, lane: CryptoLane = .shared) -> String {
|
||
var s = "http://127.0.0.1:\(port)/a/\(encodeURL(url))"
|
||
switch lane {
|
||
case .video: s += "?ck=v"
|
||
case .audio: s += "?ck=a"
|
||
case .shared: break
|
||
}
|
||
return s
|
||
}
|
||
|
||
private func fetch(_ url: URL) async throws -> Data {
|
||
try await fetchFollowingRedirects(url).data
|
||
}
|
||
|
||
private func fetchFollowingRedirects(_ url: URL) async throws -> (data: Data, finalURL: URL) {
|
||
var current = url
|
||
for _ in 0..<8 {
|
||
var req = URLRequest(url: current)
|
||
req.timeoutInterval = 15
|
||
req.cachePolicy = .reloadIgnoringLocalCacheData
|
||
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
|
||
let browser = withState { useViaplayBrowserHeaders }
|
||
let session: URLSession
|
||
if browser {
|
||
ViaplayPlaybackHeaders.apply(to: &req)
|
||
session = viaplayBrowserSession
|
||
} else {
|
||
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
|
||
session = urlSession
|
||
}
|
||
let (data, resp) = try await session.data(for: req)
|
||
guard let http = resp as? HTTPURLResponse else {
|
||
return (data, current)
|
||
}
|
||
if (300...399).contains(http.statusCode) {
|
||
guard
|
||
let loc = http.value(forHTTPHeaderField: "Location"),
|
||
let next = URL(string: loc, relativeTo: current)?.absoluteURL
|
||
else {
|
||
throw ProxyError.upstream(http.statusCode)
|
||
}
|
||
proxyLog.info("cdn redirect \(http.statusCode, privacy: .public) -> \(next.host ?? "?", privacy: .public)")
|
||
current = next
|
||
continue
|
||
}
|
||
if !(200...299).contains(http.statusCode) {
|
||
throw ProxyError.upstream(http.statusCode)
|
||
}
|
||
return (data, http.url ?? current)
|
||
}
|
||
throw ProxyError.upstream(310)
|
||
}
|
||
|
||
/// Manifest ophalen via de sticky eind-URL (zoals ExoPlayer); faalt die, één keer opnieuw via het origineel.
|
||
private func fetchManifest(_ original: URL) async throws -> (data: Data, baseURL: URL) {
|
||
let sticky = withState { resolvedManifest }
|
||
if let sticky, sticky != original {
|
||
if let got = try? await fetchFollowingRedirects(sticky) {
|
||
withState { resolvedManifest = got.finalURL }
|
||
return (got.data, got.finalURL)
|
||
}
|
||
proxyLog.error("sticky manifest failed — re-resolving via original URL")
|
||
}
|
||
let got = try await fetchFollowingRedirects(original)
|
||
withState { resolvedManifest = got.finalURL }
|
||
if got.finalURL != original {
|
||
proxyLog.info("manifest redirected to \(got.finalURL.host ?? "?", privacy: .public)")
|
||
}
|
||
return (got.data, got.finalURL)
|
||
}
|
||
|
||
private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
|
||
var s = xml
|
||
// Strip ContentProtection (ClearKey/Widevine/PlayReady/mp4protection).
|
||
s = s.replacingOccurrences(
|
||
of: #"<ContentProtection\b[^>]*/>"#,
|
||
with: "",
|
||
options: .regularExpression
|
||
)
|
||
s = s.replacingOccurrences(
|
||
of: #"<ContentProtection\b[^>]*>[\s\S]*?</ContentProtection>"#,
|
||
with: "",
|
||
options: .regularExpression
|
||
)
|
||
|
||
// Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy).
|
||
s = s.replacingOccurrences(
|
||
of: #"<Location\b[^>]*>[\s\S]*?</Location>"#,
|
||
with: "<Location>http://127.0.0.1:\(port)/manifest.mpd</Location>",
|
||
options: .regularExpression
|
||
)
|
||
|
||
// Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p/<b64>/.
|
||
if let baseRegex = try? NSRegularExpression(pattern: #"<BaseURL>([\s\S]*?)</BaseURL>"#, options: []) {
|
||
let ns = s as NSString
|
||
let matches = baseRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||
for match in matches.reversed() {
|
||
guard match.numberOfRanges >= 2 else { continue }
|
||
let content = Self.xmlUnescape(ns.substring(with: match.range(at: 1))
|
||
.trimmingCharacters(in: .whitespacesAndNewlines))
|
||
if content.contains("127.0.0.1:\(port)") {
|
||
continue
|
||
}
|
||
let resolved = resolveBaseURL(content, mpdURL: mpdURL)
|
||
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(resolved)))/"
|
||
s = (s as NSString).replacingCharacters(in: match.range, with: "<BaseURL>\(proxyBase)</BaseURL>")
|
||
}
|
||
}
|
||
|
||
if !s.localizedCaseInsensitiveContains("<BaseURL") {
|
||
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(asDirectory(mpdURL.deletingLastPathComponent())))/"
|
||
if let regex = try? NSRegularExpression(pattern: #"<MPD\b[^>]*>"#, options: []) {
|
||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||
if let match = regex.firstMatch(in: s, options: [], range: range),
|
||
let r = Range(match.range, in: s) {
|
||
s.insert(contentsOf: "\n <BaseURL>\(proxyBase)</BaseURL>", at: r.upperBound)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Absolute http(s) URL's in attributen → /a/<b64>
|
||
if let urlRegex = try? NSRegularExpression(pattern: #"https?://[^\s"'<>]+"#, options: []) {
|
||
let ns = s as NSString
|
||
let matches = urlRegex.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||
for match in matches.reversed() {
|
||
let abs = ns.substring(with: match.range)
|
||
if abs.contains("127.0.0.1:\(port)") { continue }
|
||
let replacement = "http://127.0.0.1:\(port)/a/\(encodeURLString(abs))"
|
||
s = (s as NSString).replacingCharacters(in: match.range, with: replacement)
|
||
}
|
||
}
|
||
|
||
return s
|
||
}
|
||
|
||
// MARK: - DASH → HLS (voor AVPlayer)
|
||
|
||
private struct DashHlsRep {
|
||
let id: String
|
||
let bandwidth: Int
|
||
let codecs: String?
|
||
let width: Int?
|
||
let height: Int?
|
||
let language: String?
|
||
let label: String?
|
||
let baseURL: URL
|
||
let initialization: String
|
||
let media: String
|
||
/// SegmentList media-URLs (relatief of absoluut); leeg bij SegmentTemplate.
|
||
let segmentURLs: [String]
|
||
let timescale: Int
|
||
let duration: Int
|
||
let startNumber: Int
|
||
let isLive: Bool
|
||
let availabilityStart: Date?
|
||
/// Compact SegmentTimeline runs: count = aantal segmenten in de run (ISO r+1).
|
||
let timelineRuns: [(t: Int, d: Int, count: Int)]
|
||
|
||
var isAac: Bool {
|
||
let c = (codecs ?? "").lowercased()
|
||
return c.contains("mp4a") || c.contains("aac")
|
||
}
|
||
|
||
var usesNumberTemplate: Bool {
|
||
media.contains("$Number")
|
||
}
|
||
|
||
var usesTimeTemplate: Bool {
|
||
media.contains("$Time")
|
||
}
|
||
}
|
||
|
||
private func dashToHlsMaster(_ xml: String, mpdURL: URL) throws -> String {
|
||
let reps = parseDashReps(xml, mpdURL: mpdURL)
|
||
let videos = reps.filter { !$0.isAudio }.map(\.rep)
|
||
let audios = reps.filter(\.isAudio).map(\.rep)
|
||
// Live: één vaste ladder (geen ABR).
|
||
// Liever ~1080p binnen cap — niet de max CDN-rung (dat gaf decrypt-stutter).
|
||
// Cap ≈5.5–6 Mbit; mid ≈8 Mbit als er geen 1080 onder de cap is.
|
||
let numberHeavy = videos.contains { $0.usesNumberTemplate }
|
||
let video: DashHlsRep? = {
|
||
let sorted = videos.sorted { $0.bandwidth < $1.bandwidth }
|
||
.filter { $0.bandwidth >= 200_000 }
|
||
let pool = sorted.isEmpty ? videos.sorted { $0.bandwidth < $1.bandwidth } : sorted
|
||
guard !pool.isEmpty else { return nil }
|
||
let cap = numberHeavy ? 5_500_000 : 6_000_000
|
||
let midCap = numberHeavy ? 7_500_000 : 8_000_000
|
||
let underCap = pool.filter { $0.bandwidth <= cap }
|
||
let underMid = pool.filter { $0.bandwidth <= midCap }
|
||
// 1) 1080p (of hoger) onder cap
|
||
if let uhd = underCap.last(where: { ($0.height ?? 0) >= 1080 }) { return uhd }
|
||
// 2) hoogste under cap (vaak 720/900 als height ontbreekt)
|
||
if let best = underCap.last { return best }
|
||
// 3) mid-cap, opnieuw 1080 eerst
|
||
if let uhd = underMid.last(where: { ($0.height ?? 0) >= 1080 }) { return uhd }
|
||
if let mid = underMid.last { return mid }
|
||
return pool.first
|
||
}()
|
||
guard let video = video ?? audios.max(by: { $0.bandwidth < $1.bandwidth })
|
||
else {
|
||
throw ProxyError.emptyManifest
|
||
}
|
||
let videoIsAudioOnly = videos.isEmpty
|
||
if videoIsAudioOnly {
|
||
dashVideoRep = video
|
||
dashAudioRep = nil
|
||
dashAudioReps = []
|
||
return mediaPlaylist(for: video, lane: .audio)
|
||
}
|
||
dashVideoRep = video
|
||
// AAC/mp4a eerst — EC-3/Atmos faalt vaker stil (geen geluid) op iOS HLS.
|
||
let audioPool = Self.preferredAudioOrder(audios)
|
||
dashAudioReps = Array(audioPool.prefix(8))
|
||
// Behoud gekozen spoor bij master-refresh; anders default.
|
||
if let cur = dashAudioRep, let keep = dashAudioReps.first(where: { $0.id == cur.id }) {
|
||
dashAudioRep = keep
|
||
} else {
|
||
dashAudioRep = audioPool.first
|
||
}
|
||
let audio = dashAudioRep
|
||
|
||
var lines: [String] = [
|
||
"#EXTM3U",
|
||
"#EXT-X-VERSION:7",
|
||
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||
]
|
||
// Eén audio-rendition in de master. Meerdere EXT-X-MEDIA + live refresh → AVPlayer -12312.
|
||
// Extra sporen wisselen via setPreferredAudio + item-reload.
|
||
if let audio {
|
||
let name = Self.audioDisplayName(audio, index: 0)
|
||
let lang = (audio.language?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? "und"
|
||
lines.append(
|
||
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"\(name)\",LANGUAGE=\"\(lang)\",DEFAULT=YES,AUTOSELECT=YES,URI=\"hls/a.m3u8\""
|
||
)
|
||
}
|
||
var streamInf = "#EXT-X-STREAM-INF:BANDWIDTH=\(max(video.bandwidth, 1))"
|
||
if let w = video.width, let h = video.height, w > 0, h > 0 {
|
||
streamInf += ",RESOLUTION=\(w)x\(h)"
|
||
}
|
||
var codecParts: [String] = []
|
||
if let c = video.codecs, !c.isEmpty { codecParts.append(c) }
|
||
if let a = audio?.codecs, !a.isEmpty { codecParts.append(a) }
|
||
if !codecParts.isEmpty {
|
||
streamInf += ",CODECS=\"\(codecParts.joined(separator: ","))\""
|
||
}
|
||
if audio != nil {
|
||
streamInf += ",AUDIO=\"aud\""
|
||
}
|
||
lines.append(streamInf)
|
||
lines.append("hls/v.m3u8")
|
||
proxyLog.info(
|
||
"DASH→HLS video bw=\(video.bandwidth, privacy: .public) audioCount=\(self.dashAudioReps.count, privacy: .public) audioId=\(audio?.id ?? "-", privacy: .public) live=\(video.isLive, privacy: .public)"
|
||
)
|
||
return lines.joined(separator: "\n")
|
||
}
|
||
|
||
private static func preferredAudioOrder(_ audios: [DashHlsRep]) -> [DashHlsRep] {
|
||
let aac = audios.filter(\.isAac)
|
||
let pool = aac.isEmpty ? audios : aac
|
||
let preferred = "nl"
|
||
return pool.sorted { a, b in
|
||
let aScore = Self.audioLocaleScore(a, preferred: preferred)
|
||
let bScore = Self.audioLocaleScore(b, preferred: preferred)
|
||
if aScore != bScore { return aScore > bScore }
|
||
// Prefer "main"/omschrijving zonder commentary.
|
||
let aMain = Self.looksPrimary(a)
|
||
let bMain = Self.looksPrimary(b)
|
||
if aMain != bMain { return aMain && !bMain }
|
||
return a.bandwidth < b.bandwidth
|
||
}
|
||
}
|
||
|
||
private static func audioLocaleScore(_ a: DashHlsRep, preferred: String) -> Int {
|
||
let lang = Self.normalizeLang(a.language)
|
||
let label = (a.label ?? "").lowercased()
|
||
if lang == preferred { return 100 }
|
||
if preferred == "nl", lang == "dut" || lang == "nld" { return 100 }
|
||
if preferred == "nl", label.contains("nederlands") || label.contains("dutch") || label.contains("holland") {
|
||
return 90
|
||
}
|
||
if label.contains(preferred) { return 80 }
|
||
if lang == "en" || lang == "eng" { return 20 }
|
||
if lang.isEmpty { return 10 }
|
||
return 0
|
||
}
|
||
|
||
private static func looksPrimary(_ a: DashHlsRep) -> Bool {
|
||
let label = (a.label ?? "").lowercased()
|
||
if label.contains("comment") || label.contains("descr") || label.contains("ad ") { return false }
|
||
return true
|
||
}
|
||
|
||
private static func normalizeLang(_ raw: String?) -> String {
|
||
guard let raw else { return "" }
|
||
let s = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
if s.hasPrefix("nl") { return "nl" }
|
||
if s.hasPrefix("dut") || s == "nld" { return "nl" }
|
||
if s.hasPrefix("en") { return "en" }
|
||
if s.hasPrefix("sv") || s.hasPrefix("swe") { return "sv" }
|
||
if s.count >= 2 { return String(s.prefix(2)) }
|
||
return s
|
||
}
|
||
|
||
private static func audioDisplayName(_ a: DashHlsRep, index: Int) -> String {
|
||
if let label = a.label?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty {
|
||
// Viaplay e.d.: Label is betrouwbaarder dan lang= (vaak "swe" terwijl audio NL is).
|
||
return label
|
||
}
|
||
let lang = normalizeLang(a.language)
|
||
if !lang.isEmpty, let name = Locale.current.localizedString(forLanguageCode: lang) {
|
||
return name.capitalized
|
||
}
|
||
if a.isAac { return "AAC \(index + 1)" }
|
||
return "Audio \(index + 1)"
|
||
}
|
||
|
||
private static func firstLabel(in xml: String) -> String? {
|
||
guard let r = xml.range(
|
||
of: #"<Label[^>]*>([^<]+)</Label>"#,
|
||
options: [.regularExpression, .caseInsensitive]
|
||
) else { return nil }
|
||
let block = String(xml[r])
|
||
guard let start = block.firstIndex(of: ">"),
|
||
let end = block.lastIndex(of: "<"),
|
||
start < end else { return nil }
|
||
let text = String(block[block.index(after: start)..<end])
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
|
||
private struct ParsedRep {
|
||
var rep: DashHlsRep
|
||
var isAudio: Bool
|
||
}
|
||
|
||
private func parseDashReps(_ xml: String, mpdURL: URL) -> [ParsedRep] {
|
||
let isLive = xml.range(of: #"type\s*=\s*"dynamic""#, options: [.regularExpression, .caseInsensitive]) != nil
|
||
|| xml.range(of: #"type\s*=\s*'dynamic'"#, options: [.regularExpression, .caseInsensitive]) != nil
|
||
var availabilityStart: Date?
|
||
if let m = xml.range(of: #"availabilityStartTime\s*=\s*"([^"]+)""#, options: [.regularExpression, .caseInsensitive]) {
|
||
let attr = String(xml[m])
|
||
if let q1 = attr.firstIndex(of: "\""), let q2 = attr.lastIndex(of: "\""), q1 < q2 {
|
||
let raw = String(attr[attr.index(after: q1)..<q2])
|
||
availabilityStart = Self.parseISO8601(raw)
|
||
}
|
||
}
|
||
|
||
// Live multi-period: pak laatste Period (actuele edge).
|
||
var scopeXML = xml
|
||
if let periodRegex = try? NSRegularExpression(
|
||
pattern: #"<Period\b([^>]*)>([\s\S]*?)</Period>"#,
|
||
options: [.caseInsensitive]
|
||
) {
|
||
let ns = xml as NSString
|
||
let matches = periodRegex.matches(in: xml, options: [], range: NSRange(location: 0, length: ns.length))
|
||
if let last = matches.last, last.numberOfRanges >= 3 {
|
||
let attrs = ns.substring(with: last.range(at: 1))
|
||
let body = ns.substring(with: last.range(at: 2))
|
||
scopeXML = attrs + body
|
||
}
|
||
}
|
||
|
||
let mpdBase = Self.firstBaseURL(in: xml).map { resolveBaseURL($0, mpdURL: mpdURL) }
|
||
?? asDirectory(mpdURL.deletingLastPathComponent())
|
||
let periodBase = Self.firstBaseURL(in: scopeXML).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? mpdBase
|
||
|
||
var results: [ParsedRep] = []
|
||
guard let asRegex = try? NSRegularExpression(
|
||
pattern: #"<AdaptationSet\b([^>]*)>([\s\S]*?)</AdaptationSet>"#,
|
||
options: [.caseInsensitive]
|
||
) else { return [] }
|
||
let ns = scopeXML as NSString
|
||
let asMatches = asRegex.matches(in: scopeXML, options: [], range: NSRange(location: 0, length: ns.length))
|
||
for asMatch in asMatches {
|
||
guard asMatch.numberOfRanges >= 3 else { continue }
|
||
let asAttrs = ns.substring(with: asMatch.range(at: 1))
|
||
let asBody = ns.substring(with: asMatch.range(at: 2))
|
||
let contentType = Self.xmlAttr(asAttrs, "contentType")?.lowercased()
|
||
?? Self.xmlAttr(asAttrs, "mimeType")?.lowercased()
|
||
?? ""
|
||
let asMime = Self.xmlAttr(asAttrs, "mimeType")?.lowercased() ?? ""
|
||
let isAudio = contentType.contains("audio") || asMime.hasPrefix("audio/")
|
||
let isVideo = contentType.contains("video") || asMime.hasPrefix("video/") || (!isAudio && asBody.contains("width="))
|
||
guard isAudio || isVideo else { continue }
|
||
|
||
let asBase = Self.firstBaseURL(in: asBody).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? periodBase
|
||
let asTemplate = Self.parseSegmentTemplate(asAttrs + asBody)
|
||
let lang = Self.xmlAttr(asAttrs, "lang")
|
||
let label = Self.firstLabel(in: asBody) ?? Self.xmlAttr(asAttrs, "label")
|
||
|
||
guard let repRegex = try? NSRegularExpression(
|
||
// Self-closing EERST — anders matcht `>(…)</Representation>` over sibling `/>` reps heen
|
||
// (Ziggo/Viaplay) en pakt de audio-SegmentTimeline bij video → 404 segmenten.
|
||
pattern: #"<Representation\b([^>]*)/>|<Representation\b([^>]*)>([\s\S]*?)</Representation>"#,
|
||
options: [.caseInsensitive]
|
||
) else { continue }
|
||
let repMatches = repRegex.matches(in: asBody, options: [], range: NSRange(location: 0, length: (asBody as NSString).length))
|
||
let asNS = asBody as NSString
|
||
for rm in repMatches {
|
||
let attrs: String
|
||
let body: String
|
||
let g1 = rm.range(at: 1)
|
||
let g2 = rm.numberOfRanges > 2 ? rm.range(at: 2) : NSRange(location: NSNotFound, length: 0)
|
||
let g3 = rm.numberOfRanges > 3 ? rm.range(at: 3) : NSRange(location: NSNotFound, length: 0)
|
||
if g1.location != NSNotFound, g2.location == NSNotFound {
|
||
// Self-closing <Representation .../>
|
||
attrs = asNS.substring(with: g1)
|
||
body = ""
|
||
} else if g2.location != NSNotFound {
|
||
attrs = asNS.substring(with: g2)
|
||
body = g3.location != NSNotFound ? asNS.substring(with: g3) : ""
|
||
} else {
|
||
continue
|
||
}
|
||
let id = Self.xmlAttr(attrs, "id") ?? "0"
|
||
let bw = Int(Self.xmlAttr(attrs, "bandwidth") ?? "0") ?? 0
|
||
let codecs = Self.xmlAttr(attrs, "codecs") ?? Self.xmlAttr(asAttrs, "codecs")
|
||
let width = Int(Self.xmlAttr(attrs, "width") ?? Self.xmlAttr(asAttrs, "width") ?? "")
|
||
let height = Int(Self.xmlAttr(attrs, "height") ?? Self.xmlAttr(asAttrs, "height") ?? "")
|
||
let repLang = Self.xmlAttr(attrs, "lang") ?? lang
|
||
let repLabel = Self.firstLabel(in: body) ?? Self.xmlAttr(attrs, "label") ?? label
|
||
let repBase = Self.firstBaseURL(in: body).map { resolveBaseURL($0, mpdURL: mpdURL) } ?? asBase
|
||
var template = Self.parseSegmentTemplate(attrs + body)
|
||
if template.media.isEmpty && template.segmentListMedia.isEmpty { template = asTemplate }
|
||
// SegmentTemplate op AdaptationSet-niveau (alleen attrs, body leeg) al meegenomen.
|
||
if template.media.isEmpty && template.segmentListMedia.isEmpty {
|
||
template = Self.parseSegmentTemplate(asAttrs)
|
||
}
|
||
if template.media.isEmpty && template.segmentListMedia.isEmpty { continue }
|
||
let timescale = template.timescale > 0 ? template.timescale : 1
|
||
// Zonder @duration (Prime/HBO): default 2s segmenten.
|
||
let duration = template.duration > 0 ? template.duration : timescale * 2
|
||
let rep = DashHlsRep(
|
||
id: id,
|
||
bandwidth: bw,
|
||
codecs: codecs,
|
||
width: width,
|
||
height: height,
|
||
language: repLang,
|
||
label: repLabel,
|
||
baseURL: repBase,
|
||
initialization: template.initialization,
|
||
media: template.media,
|
||
segmentURLs: template.segmentListMedia,
|
||
timescale: timescale,
|
||
duration: duration,
|
||
startNumber: template.startNumber,
|
||
isLive: isLive,
|
||
availabilityStart: availabilityStart,
|
||
timelineRuns: template.timelineRuns
|
||
)
|
||
let audioFlag = isAudio || (!isVideo && (codecs?.contains("mp4a") == true || codecs?.contains("ec-3") == true || codecs?.contains("ac-3") == true))
|
||
results.append(ParsedRep(rep: rep, isAudio: audioFlag))
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
private struct SegTemplateParsed {
|
||
var timescale: Int = 1
|
||
var duration: Int = 0
|
||
var startNumber: Int = 1
|
||
var media: String = ""
|
||
var initialization: String = ""
|
||
/// Compact runs — nooit 1800 entries materialiseren bij parse.
|
||
var timelineRuns: [(t: Int, d: Int, count: Int)] = []
|
||
var segmentListMedia: [String] = []
|
||
}
|
||
|
||
private static func parseSegmentTemplate(_ chunk: String) -> SegTemplateParsed {
|
||
var out = SegTemplateParsed()
|
||
if let m = chunk.range(
|
||
of: #"<SegmentTemplate\b([^>]*)>([\s\S]*?)</SegmentTemplate>|<SegmentTemplate\b([^>]*)/>"#,
|
||
options: [.regularExpression, .caseInsensitive]
|
||
) {
|
||
let block = String(chunk[m])
|
||
let attrs: String
|
||
if let openEnd = block.firstIndex(of: ">") {
|
||
attrs = String(block[block.index(block.startIndex, offsetBy: "<SegmentTemplate".count)..<openEnd])
|
||
} else {
|
||
attrs = block
|
||
}
|
||
out.timescale = Int(xmlAttr(attrs, "timescale") ?? "1") ?? 1
|
||
out.duration = Int(xmlAttr(attrs, "duration") ?? "0") ?? 0
|
||
out.startNumber = Int(xmlAttr(attrs, "startNumber") ?? "1") ?? 1
|
||
out.media = xmlAttr(attrs, "media") ?? ""
|
||
out.initialization = xmlAttr(attrs, "initialization") ?? ""
|
||
|
||
if let tl = block.range(of: #"<SegmentTimeline>([\s\S]*?)</SegmentTimeline>"#, options: [.regularExpression, .caseInsensitive]) {
|
||
let body = String(block[tl])
|
||
if let sRegex = try? NSRegularExpression(pattern: #"<S\b([^>]*)/?>"#, options: [.caseInsensitive]) {
|
||
let ns = body as NSString
|
||
var cursor = 0
|
||
for sm in sRegex.matches(in: body, options: [], range: NSRange(location: 0, length: ns.length)) {
|
||
let a = ns.substring(with: sm.range(at: 1))
|
||
let t = Int(xmlAttr(a, "t") ?? "") ?? cursor
|
||
let d = Int(xmlAttr(a, "d") ?? "0") ?? 0
|
||
let r = Int(xmlAttr(a, "r") ?? "0") ?? 0
|
||
guard d > 0 else { continue }
|
||
let count = r + 1
|
||
guard count > 0, count < 1_000_000 else { continue }
|
||
out.timelineRuns.append((t: t, d: d, count: count))
|
||
cursor = t + count * d
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// SegmentList (Prime/HBO e.d.): alle SegmentURL's bewaren — niet alleen de eerste.
|
||
if let listR = chunk.range(
|
||
of: #"<SegmentList\b[^>]*>([\s\S]*?)</SegmentList>"#,
|
||
options: [.regularExpression, .caseInsensitive]
|
||
) {
|
||
let block = String(chunk[listR])
|
||
let open = block.split(separator: ">", maxSplits: 1).first.map(String.init) ?? ""
|
||
out.timescale = Int(xmlAttr(open, "timescale") ?? "1") ?? 1
|
||
out.duration = Int(xmlAttr(open, "duration") ?? "0") ?? 0
|
||
out.startNumber = Int(xmlAttr(open, "startNumber") ?? "1") ?? 1
|
||
if let initMatch = block.range(
|
||
of: #"<Initialization\b[^>]*sourceURL="([^"]+)""#,
|
||
options: [.regularExpression, .caseInsensitive]
|
||
) {
|
||
let s = String(block[initMatch])
|
||
if let q1 = s.range(of: "sourceURL=\"")?.upperBound,
|
||
let q2 = s[q1...].firstIndex(of: "\"") {
|
||
out.initialization = xmlUnescape(String(s[q1..<q2]))
|
||
}
|
||
} else if let initMatch = block.range(of: #"sourceURL="([^"]+)""#, options: [.regularExpression, .caseInsensitive]) {
|
||
let s = String(block[initMatch])
|
||
if let q1 = s.firstIndex(of: "\""), let q2 = s.lastIndex(of: "\""), q1 < q2 {
|
||
out.initialization = xmlUnescape(String(s[s.index(after: q1)..<q2]))
|
||
}
|
||
}
|
||
if let urlRegex = try? NSRegularExpression(
|
||
pattern: #"<SegmentURL\b[^>]*media="([^"]+)""#,
|
||
options: [.caseInsensitive]
|
||
) {
|
||
let ns = block as NSString
|
||
for m in urlRegex.matches(in: block, options: [], range: NSRange(location: 0, length: ns.length)) {
|
||
guard m.numberOfRanges >= 2 else { continue }
|
||
let media = xmlUnescape(ns.substring(with: m.range(at: 1)))
|
||
if !media.isEmpty { out.segmentListMedia.append(media) }
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
private static func firstBaseURL(in xml: String) -> String? {
|
||
guard let r = xml.range(of: #"<BaseURL>([\s\S]*?)</BaseURL>"#, options: [.regularExpression, .caseInsensitive]) else {
|
||
return nil
|
||
}
|
||
let block = String(xml[r])
|
||
guard let start = block.firstIndex(of: ">"), let end = block.lastIndex(of: "<"), start < end else { return nil }
|
||
return xmlUnescape(String(block[block.index(after: start)..<end]).trimmingCharacters(in: .whitespacesAndNewlines))
|
||
}
|
||
|
||
/// MPD is XML: `&` in BaseURL/template-URLs moet `&` worden, anders weigert de CDN (Odido: 403).
|
||
private static func xmlUnescape(_ s: String) -> String {
|
||
guard s.contains("&") else { return s }
|
||
return s
|
||
.replacingOccurrences(of: "<", with: "<")
|
||
.replacingOccurrences(of: ">", with: ">")
|
||
.replacingOccurrences(of: """, with: "\"")
|
||
.replacingOccurrences(of: "'", with: "'")
|
||
.replacingOccurrences(of: "&", with: "&")
|
||
}
|
||
|
||
private static func xmlAttr(_ attrs: String, _ name: String) -> String? {
|
||
let patterns = [
|
||
"\(name)\\s*=\\s*\"([^\"]*)\"",
|
||
"\(name)\\s*=\\s*'([^']*)'",
|
||
]
|
||
for p in patterns {
|
||
guard let re = try? NSRegularExpression(pattern: p, options: [.caseInsensitive]) else { continue }
|
||
let ns = attrs as NSString
|
||
if let m = re.firstMatch(in: attrs, options: [], range: NSRange(location: 0, length: ns.length)),
|
||
m.numberOfRanges >= 2 {
|
||
return xmlUnescape(ns.substring(with: m.range(at: 1)))
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func parseISO8601(_ s: String) -> Date? {
|
||
let f1 = ISO8601DateFormatter()
|
||
f1.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||
if let d = f1.date(from: s) { return d }
|
||
let f2 = ISO8601DateFormatter()
|
||
f2.formatOptions = [.withInternetDateTime]
|
||
return f2.date(from: s)
|
||
}
|
||
|
||
private func stableSeq(for url: URL) -> Int {
|
||
let key = url.absoluteString
|
||
if let s = segmentSeqByKey[key] { return s }
|
||
let s = nextStableSeq
|
||
nextStableSeq += 1
|
||
segmentSeqByKey[key] = s
|
||
return s
|
||
}
|
||
|
||
private func mediaPlaylist(for rep: DashHlsRep, lane: CryptoLane) -> String {
|
||
let scale = max(rep.timescale, 1)
|
||
// duration==0 zou niet meer moeten — parse zet default 2s — maar blijf defensief.
|
||
let segTicks = rep.duration > 0 ? rep.duration : scale * 2
|
||
let defaultDur = Double(segTicks) / Double(scale)
|
||
var maxDur = defaultDur
|
||
var urlsInOrder: [(url: URL, dur: Double)] = []
|
||
/// Absoluut segment-index van eerste URL (voor MEDIA-SEQUENCE bij $Number$).
|
||
var windowStartIndex = 0
|
||
/// Eerste segment $Time$ + tick-duur (voor MEDIA-SEQUENCE bij $Time$ — Apple: moet meestijgen als edge schuift).
|
||
var windowStartTime: Int?
|
||
var windowStartDurTicks: Int?
|
||
let preferNumber = rep.usesNumberTemplate || !rep.usesTimeTemplate
|
||
|
||
if !rep.segmentURLs.isEmpty {
|
||
var urls = rep.segmentURLs
|
||
if rep.isLive, urls.count > 12 {
|
||
urls = Array(urls.suffix(12))
|
||
} else if urls.count > 20 {
|
||
urls = Array(urls.suffix(20))
|
||
}
|
||
windowStartIndex = max(0, rep.segmentURLs.count - urls.count)
|
||
for path in urls {
|
||
let url: URL
|
||
if path.lowercased().hasPrefix("http://") || path.lowercased().hasPrefix("https://") {
|
||
url = withManifestQuery(URL(string: path) ?? join(base: rep.baseURL, relative: path))
|
||
} else {
|
||
url = withManifestQuery(join(base: asDirectory(rep.baseURL), relative: path))
|
||
}
|
||
maxDur = max(maxDur, defaultDur)
|
||
urlsInOrder.append((url, defaultDur))
|
||
}
|
||
} else if !rep.timelineRuns.isEmpty {
|
||
let total = rep.timelineRuns.reduce(0) { $0 + max(0, $1.count) }
|
||
// Apple live HLS: blijf achter de live-edge (≈2–3 target durations).
|
||
// edgePad=0 + schuivende $Time$ → AVPlayer vraagt verlopen URLs → "resource niet beschikbaar".
|
||
// $Number$ (Prime/HBO): korter venster + iets minder pad → snellere start (2s segs).
|
||
var edgePad = 0
|
||
if rep.isLive {
|
||
if preferNumber {
|
||
if total > 100 {
|
||
edgePad = 3
|
||
} else if total > 20 {
|
||
edgePad = 2
|
||
} else {
|
||
edgePad = 1
|
||
}
|
||
} else if total > 100 {
|
||
edgePad = 6
|
||
} else if total > 20 {
|
||
edgePad = 3
|
||
} else if total > 8 {
|
||
edgePad = 2
|
||
} else {
|
||
edgePad = 1
|
||
}
|
||
}
|
||
if total <= 3 {
|
||
edgePad = 0
|
||
} else if total - edgePad < 3 {
|
||
edgePad = max(0, total - 3)
|
||
}
|
||
let end = max(0, total - edgePad)
|
||
// $Time$ (Viaplay/Ziggo): 10 segs. $Number$ (Prime/HBO): 5 segs ≈10s — sneller bufferen.
|
||
let windowLen: Int
|
||
if !rep.isLive {
|
||
windowLen = 12
|
||
} else if preferNumber {
|
||
windowLen = 5
|
||
} else {
|
||
windowLen = 10
|
||
}
|
||
let start = max(0, end - windowLen)
|
||
windowStartIndex = start
|
||
// Alleen het venster materialiseren (Prime/HBO: r≈1800).
|
||
var absIndex = 0
|
||
for run in rep.timelineRuns {
|
||
let runCount = max(0, run.count)
|
||
let runEnd = absIndex + runCount
|
||
let overlapStart = max(start, absIndex)
|
||
let overlapEnd = min(end, runEnd)
|
||
if overlapStart < overlapEnd {
|
||
for i in overlapStart..<overlapEnd {
|
||
let offsetInRun = i - absIndex
|
||
let t = run.t + offsetInRun * run.d
|
||
if windowStartTime == nil {
|
||
windowStartTime = t
|
||
windowStartDurTicks = max(run.d, 1)
|
||
}
|
||
let url = resolveTemplateURL(
|
||
rep.media,
|
||
base: rep.baseURL,
|
||
repId: rep.id,
|
||
bandwidth: rep.bandwidth,
|
||
number: preferNumber ? (rep.startNumber + i) : nil,
|
||
time: preferNumber ? nil : t
|
||
)
|
||
let dur = Double(run.d) / Double(scale)
|
||
maxDur = max(maxDur, dur)
|
||
urlsInOrder.append((url, dur))
|
||
}
|
||
}
|
||
absIndex = runEnd
|
||
if absIndex >= end { break }
|
||
}
|
||
} else {
|
||
// Alleen $Number$ zonder SegmentTimeline (Prime/HBO fallback).
|
||
let count = (rep.isLive && preferNumber) ? 5 : 10
|
||
var edgePad = rep.isLive ? (preferNumber ? 1 : 2) : 0
|
||
var endNum = rep.startNumber + count
|
||
if rep.isLive {
|
||
let startDate = rep.availabilityStart ?? Date().addingTimeInterval(-Double(count + edgePad) * defaultDur)
|
||
let elapsed = max(0, Date().timeIntervalSince(startDate))
|
||
let current = rep.startNumber + max(0, Int(elapsed / max(defaultDur, 0.1)))
|
||
if current - rep.startNumber < 3 { edgePad = 0 }
|
||
endNum = max(rep.startNumber + 2, current - edgePad)
|
||
}
|
||
let startNum = max(rep.startNumber, endNum - count + 1)
|
||
windowStartIndex = startNum - rep.startNumber
|
||
for n in startNum...endNum {
|
||
let url = resolveTemplateURL(
|
||
rep.media,
|
||
base: rep.baseURL,
|
||
repId: rep.id,
|
||
bandwidth: rep.bandwidth,
|
||
number: n,
|
||
time: nil
|
||
)
|
||
urlsInOrder.append((url, defaultDur))
|
||
}
|
||
}
|
||
|
||
// MEDIA-SEQUENCE (RFC 8216 / Apple live playlist):
|
||
// - $Number$: absoluut DASH-segmentnummer (startNumber+index) — schuift met Amazon live edge.
|
||
// - $Time$: NIET relative index (blijft 0 terwijl t0 schuift → stutter/404). Wel t/d zodat
|
||
// sequence meestijgt wanneer SegmentTimeline opschuift (Viaplay/Ziggo).
|
||
let mediaSequence: Int
|
||
if preferNumber, (!rep.timelineRuns.isEmpty || rep.segmentURLs.isEmpty) {
|
||
mediaSequence = rep.startNumber + windowStartIndex
|
||
for item in urlsInOrder {
|
||
_ = stableSeq(for: item.url)
|
||
}
|
||
} else if let t0 = windowStartTime, let dTicks = windowStartDurTicks, dTicks > 0 {
|
||
mediaSequence = t0 / dTicks
|
||
for item in urlsInOrder {
|
||
_ = stableSeq(for: item.url)
|
||
}
|
||
} else if let first = urlsInOrder.first {
|
||
mediaSequence = stableSeq(for: first.url)
|
||
for item in urlsInOrder.dropFirst() {
|
||
_ = stableSeq(for: item.url)
|
||
}
|
||
} else {
|
||
mediaSequence = rep.startNumber
|
||
}
|
||
|
||
let target = max(1, Int(ceil(maxDur)))
|
||
|
||
var lines: [String] = [
|
||
"#EXTM3U",
|
||
"#EXT-X-VERSION:7",
|
||
"#EXT-X-TARGETDURATION:\(target)",
|
||
"#EXT-X-MEDIA-SEQUENCE:\(mediaSequence)",
|
||
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||
]
|
||
if !rep.initialization.isEmpty {
|
||
let initURL = resolveTemplateURL(
|
||
rep.initialization,
|
||
base: rep.baseURL,
|
||
repId: rep.id,
|
||
bandwidth: rep.bandwidth,
|
||
number: nil,
|
||
time: nil
|
||
)
|
||
lines.append("#EXT-X-MAP:URI=\"\(proxyAbsoluteURL(initURL, lane: lane))\"")
|
||
} else {
|
||
proxyLog.error("DASH rep \(rep.id, privacy: .public) mist initialization — AVPlayer faalt zonder EXT-X-MAP")
|
||
}
|
||
|
||
for seg in urlsInOrder {
|
||
lines.append(String(format: "#EXTINF:%.3f,", seg.dur))
|
||
lines.append(proxyAbsoluteURL(seg.url, lane: lane))
|
||
}
|
||
if !rep.isLive {
|
||
lines.append("#EXT-X-ENDLIST")
|
||
}
|
||
return lines.joined(separator: "\n")
|
||
}
|
||
|
||
/// Live: MPD opnieuw ophalen zodat video+audio uit dezelfde snapshot komen.
|
||
/// Korte TTL-cache: v.m3u8 + a.m3u8 delen één fetch (voorkomt A/V-desync).
|
||
private func refreshDashRepsFromRemote() async throws {
|
||
let remote = withState { remoteManifest }
|
||
guard let remoteManifest = remote else { throw ProxyError.notStarted }
|
||
|
||
let cached: (xml: String, base: URL)? = withState {
|
||
if let xml = cachedMpdXML, let at = cachedMpdAt,
|
||
Date().timeIntervalSince(at) < mpdCacheTTL {
|
||
return (xml, resolvedManifest ?? remoteManifest)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
let xml: String
|
||
let manifestBase: URL
|
||
if let cached {
|
||
xml = cached.xml
|
||
manifestBase = cached.base
|
||
} else {
|
||
let (raw, base) = try await fetchManifest(remoteManifest)
|
||
let fetched: String = {
|
||
if let s = String(data: raw, encoding: .utf8), !s.isEmpty { return s }
|
||
return String(decoding: raw, as: UTF8.self)
|
||
}()
|
||
withState {
|
||
cachedMpdXML = fetched
|
||
cachedMpdAt = Date()
|
||
}
|
||
xml = fetched
|
||
manifestBase = base
|
||
}
|
||
|
||
let reps = parseDashReps(xml, mpdURL: manifestBase)
|
||
let videos = reps.filter { !$0.isAudio }.map(\.rep)
|
||
let audios = reps.filter(\.isAudio).map(\.rep)
|
||
|
||
withState {
|
||
if let cached = dashVideoRep {
|
||
dashVideoRep = videos.first(where: { $0.id == cached.id })
|
||
?? videos.sorted { $0.bandwidth < $1.bandwidth }.last(where: { $0.bandwidth <= 8_000_000 })
|
||
?? videos.max(by: { $0.bandwidth < $1.bandwidth })
|
||
?? cached
|
||
}
|
||
if let cached = dashAudioRep {
|
||
let refreshed = audios.first(where: { $0.id == cached.id })
|
||
?? Self.preferredAudioOrder(audios).first
|
||
?? cached
|
||
dashAudioRep = refreshed
|
||
}
|
||
if !dashAudioReps.isEmpty {
|
||
let byId = Dictionary(uniqueKeysWithValues: audios.map { ($0.id, $0) })
|
||
dashAudioReps = dashAudioReps.map { byId[$0.id] ?? $0 }
|
||
if dashAudioReps.allSatisfy({ byId[$0.id] == nil }) {
|
||
dashAudioReps = Array(Self.preferredAudioOrder(audios).prefix(8))
|
||
dashAudioRep = dashAudioReps.first
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func resolveTemplateURL(
|
||
_ template: String,
|
||
base: URL,
|
||
repId: String,
|
||
bandwidth: Int,
|
||
number: Int?,
|
||
time: Int?
|
||
) -> URL {
|
||
var s = template
|
||
s = s.replacingOccurrences(of: "$RepresentationID$", with: repId)
|
||
s = s.replacingOccurrences(of: "$Bandwidth$", with: String(bandwidth))
|
||
if let number {
|
||
if let re = try? NSRegularExpression(pattern: #"\$Number(%0(\d+)d)?\$"#, options: []) {
|
||
let ns = s as NSString
|
||
let matches = re.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||
for m in matches.reversed() {
|
||
let width: Int
|
||
if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound {
|
||
width = Int(ns.substring(with: m.range(at: 2))) ?? 0
|
||
} else {
|
||
width = 0
|
||
}
|
||
let repl = width > 0
|
||
? String(format: "%0\(width)d", number)
|
||
: String(number)
|
||
s = (s as NSString).replacingCharacters(in: m.range, with: repl)
|
||
}
|
||
}
|
||
}
|
||
if let time {
|
||
if let re = try? NSRegularExpression(pattern: #"\$Time(%0(\d+)d)?\$"#, options: []) {
|
||
let ns = s as NSString
|
||
let matches = re.matches(in: s, options: [], range: NSRange(location: 0, length: ns.length))
|
||
for m in matches.reversed() {
|
||
let width: Int
|
||
if m.numberOfRanges >= 3, m.range(at: 2).location != NSNotFound {
|
||
width = Int(ns.substring(with: m.range(at: 2))) ?? 0
|
||
} else {
|
||
width = 0
|
||
}
|
||
let repl = width > 0
|
||
? String(format: "%0\(width)d", time)
|
||
: String(time)
|
||
s = (s as NSString).replacingCharacters(in: m.range, with: repl)
|
||
}
|
||
}
|
||
}
|
||
return withManifestQuery(join(base: asDirectory(base), relative: s))
|
||
}
|
||
|
||
/// Zet manifest-auth query op CDN-URLs die die zelf niet hebben (Ziggo/Akamai hdnts).
|
||
/// Geen nested withState — wordt vanuit mediaPlaylist aangeroepen onder stateLock.
|
||
private func withManifestQuery(_ url: URL) -> URL {
|
||
let q = manifestQuery
|
||
guard let q, !q.isEmpty else { return url }
|
||
if let existing = url.query, !existing.isEmpty { return url }
|
||
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||
comps?.percentEncodedQuery = q
|
||
return comps?.url ?? url
|
||
}
|
||
|
||
/// Resolve BaseURL tekst t.o.v. MPD-URL; altijd als directory-URL.
|
||
private func resolveBaseURL(_ raw: String, mpdURL: URL) -> URL {
|
||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
return asDirectory(mpdURL.deletingLastPathComponent())
|
||
}
|
||
if trimmed.hasPrefix("http://") || trimmed.hasPrefix("https://"),
|
||
let abs = URL(string: trimmed) {
|
||
return asDirectory(abs)
|
||
}
|
||
if let joined = URL(string: trimmed, relativeTo: mpdURL)?.absoluteURL {
|
||
return asDirectory(joined)
|
||
}
|
||
return asDirectory(mpdURL.deletingLastPathComponent())
|
||
}
|
||
|
||
private func asDirectory(_ url: URL) -> URL {
|
||
var s = url.absoluteString
|
||
if s.isEmpty { return url }
|
||
if !s.hasSuffix("/") {
|
||
// Als laatste path-component op een bestand lijkt (.mpd/.mp4), strip; anders slash toevoegen.
|
||
let last = url.lastPathComponent.lowercased()
|
||
if last.contains("."), !last.hasSuffix("/") {
|
||
return asDirectory(url.deletingLastPathComponent())
|
||
}
|
||
s += "/"
|
||
}
|
||
return URL(string: s) ?? url
|
||
}
|
||
|
||
private func join(base: URL, relative: String) -> URL {
|
||
if relative.hasPrefix("http://") || relative.hasPrefix("https://") {
|
||
return URL(string: relative) ?? base
|
||
}
|
||
return URL(string: relative, relativeTo: base)?.absoluteURL ?? base.appendingPathComponent(relative)
|
||
}
|
||
|
||
private func encodeURL(_ url: URL) -> String {
|
||
encodeURLString(url.absoluteString)
|
||
}
|
||
|
||
private func encodeURLString(_ s: String) -> String {
|
||
Data(s.utf8).base64EncodedString()
|
||
.replacingOccurrences(of: "+", with: "-")
|
||
.replacingOccurrences(of: "/", with: "_")
|
||
.replacingOccurrences(of: "=", with: "")
|
||
}
|
||
|
||
private func decodeURL(_ b64url: String) -> URL? {
|
||
var b64 = b64url
|
||
.replacingOccurrences(of: "-", with: "+")
|
||
.replacingOccurrences(of: "_", with: "/")
|
||
let pad = (4 - b64.count % 4) % 4
|
||
if pad > 0 { b64 += String(repeating: "=", count: pad) }
|
||
guard let data = Data(base64Encoded: b64),
|
||
let s = String(data: data, encoding: .utf8)
|
||
else { return nil }
|
||
return URL(string: s)
|
||
}
|
||
|
||
private static func parseHeader(_ header: String, name: String) -> String? {
|
||
let lines = header.split(separator: "\r\n")
|
||
let prefix = name.lowercased() + ":"
|
||
for line in lines.dropFirst() {
|
||
let l = line.trimmingCharacters(in: .whitespaces)
|
||
if l.lowercased().hasPrefix(prefix) {
|
||
return String(l.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private static func parseByteRange(_ value: String, total: Int) -> (start: Int, end: Int)? {
|
||
// bytes=START-END | bytes=START-
|
||
guard value.lowercased().hasPrefix("bytes=") else { return nil }
|
||
let spec = value.dropFirst(6)
|
||
let parts = spec.split(separator: "-", maxSplits: 1).map(String.init)
|
||
guard let start = Int(parts.first ?? "") else { return nil }
|
||
let end: Int
|
||
if parts.count > 1, let e = Int(parts[1]) {
|
||
end = min(e, total - 1)
|
||
} else {
|
||
end = total - 1
|
||
}
|
||
guard total > 0, start >= 0, start <= end, end < total else { return nil }
|
||
return (start, end)
|
||
}
|
||
|
||
private func respond(
|
||
_ connection: NWConnection,
|
||
status: Int,
|
||
contentType: String,
|
||
body: Data,
|
||
headOnly: Bool = false,
|
||
contentLength: Int? = nil,
|
||
extraHeaders: [String] = []
|
||
) {
|
||
let reason: String
|
||
switch status {
|
||
case 200: reason = "OK"
|
||
case 206: reason = "Partial Content"
|
||
case 400: reason = "Bad Request"
|
||
case 405: reason = "Method Not Allowed"
|
||
case 502: reason = "Bad Gateway"
|
||
default: reason = "Error"
|
||
}
|
||
let len = contentLength ?? body.count
|
||
var header = "HTTP/1.1 \(status) \(reason)\r\n"
|
||
header += "Content-Type: \(contentType)\r\n"
|
||
header += "Content-Length: \(len)\r\n"
|
||
header += "Connection: close\r\n"
|
||
header += "Access-Control-Allow-Origin: *\r\n"
|
||
header += "Cache-Control: no-store\r\n"
|
||
for h in extraHeaders {
|
||
header += h + "\r\n"
|
||
}
|
||
header += "\r\n"
|
||
var payload = Data(header.utf8)
|
||
if !headOnly { payload.append(body) }
|
||
connection.send(content: payload, completion: .contentProcessed { _ in
|
||
connection.cancel()
|
||
})
|
||
}
|
||
|
||
enum ProxyError: LocalizedError {
|
||
case noKeys
|
||
case bindFailed
|
||
case notStarted
|
||
case badPath
|
||
case emptyManifest
|
||
case upstream(Int)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .noKeys: return "Geen geldige ClearKey-sleutels"
|
||
case .bindFailed: return "Kon lokale ClearKey-proxy niet starten"
|
||
case .notStarted: return "ClearKey-proxy is niet actief"
|
||
case .badPath: return "Ongeldig proxy-pad"
|
||
case .emptyManifest: return "Leeg of ongeldig stream-manifest ontvangen"
|
||
case .upstream(let code): return "Upstream HTTP \(code)"
|
||
}
|
||
}
|
||
}
|
||
}
|