stremio/apps/ios/VMC/Playback/ClearKeyDashProxy.swift
Jos Vooges | STH 89ceccb557 Restore working iOS 0.5.35 player and fix Odido redirected DASH on iOS (0.5.41).
Recovered the ClearKey AVPlayer/proxy stack from the Mac stash, keep later UI fixes, resolve DASH segments against the redirected manifest URL and XML-unescape MPD values.
2026-09-27 23:34:12 +02:00

1877 lines
81 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Network
import os.log
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
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
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).
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)
}()
/// 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
}
/// 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
) 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.dashVideoRep = nil
self.dashAudioRep = nil
self.dashAudioReps = []
self.segmentSeqByKey = [:]
self.nextStableSeq = 1
self.lastMediaPlaylistByPath = [:]
self.cachedMpdXML = nil
self.cachedMpdAt = nil
self.cryptoLock.lock()
self.cryptoVideo = CencMp4Decryptor.SessionState()
self.cryptoAudio = CencMp4Decryptor.SessionState()
self.cryptoShared = CencMp4Decryptor.SessionState()
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)"
)
// 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)
}
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
dashVideoRep = nil
dashAudioRep = nil
dashAudioReps = []
segmentSeqByKey = [:]
nextStableSeq = 1
lastMediaPlaylistByPath = [:]
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)
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() }
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) -> 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).
let lane = Self.hlsMediaLane(for: playlistURL)
// 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
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:") {
// EVENT met uren history → AVPlayer start verkeerd / audio-only. Behandel als live.
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 window: ~12–16 segmenten (±60–90s bij 5–6s ESPN, ±40–60s bij 3.8s).
let keep = 14
let startIdx: Int
let seqBase = mediaSequence ?? 0
if !hasEndList, segments.count > keep {
startIdx = segments.count - keep
} else if segments.count > 40 {
// VOD/EVENT-achtig met enorme history: toch trimmen naar einde.
startIdx = segments.count - keep
} else {
startIdx = 0
}
let window = Array(segments.suffix(from: startIdx))
let seq = seqBase + startIdx
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")
// Live-achtig: start dicht bij einde.
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 window {
out.append(seg.inf)
let resolved = join(base: asDirectory(playlistURL.deletingLastPathComponent()), relative: seg.uri)
out.append(proxyAbsoluteURL(resolved, lane: lane))
}
if hasEndList && startIdx == 0 {
out.append("#EXT-X-ENDLIST")
}
return out.joined(separator: "\n")
}
/// 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 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
out.append(rewriteHlsTagURIs(line, base: playlistURL, lane: lane))
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
}
return out.joined(separator: "\n")
}
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 req = URLRequest(url: url)
req.timeoutInterval = 15
req.cachePolicy = .reloadIgnoringLocalCacheData
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw ProxyError.upstream(http.statusCode)
}
return (data, resp.url ?? url)
}
/// 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 = Locale.current.language.languageCode?.identifier.lowercased() ?? "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: `&amp;` 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: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
.replacingOccurrences(of: "&apos;", with: "'")
.replacingOccurrences(of: "&amp;", 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)"
}
}
}
}