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). /// 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) }() /// 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(_ operation: () async throws -> T) async throws -> T { try await operation() } } private func withState(_ 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 ) 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.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 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.. 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(" (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 req = URLRequest(url: url) req.timeoutInterval = 15 req.cachePolicy = .reloadIgnoringLocalCacheData let host = url.host?.lowercased() ?? "" if host.contains("viaplay") { req.setValue( "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", forHTTPHeaderField: "User-Agent" ) req.setValue("https://viaplay.com/", forHTTPHeaderField: "Referer") req.setValue("https://viaplay.com", forHTTPHeaderField: "Origin") } else { 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: #"]*/>"#, with: "", options: .regularExpression ) s = s.replacingOccurrences( of: #"]*>[\s\S]*?"#, with: "", options: .regularExpression ) // Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy). s = s.replacingOccurrences( of: #"]*>[\s\S]*?"#, with: "http://127.0.0.1:\(port)/manifest.mpd", options: .regularExpression ) // Herschrijf elke BaseURL naar de juiste resolved CDN-base via /p//. if let baseRegex = try? NSRegularExpression(pattern: #"([\s\S]*?)"#, 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: "\(proxyBase)") } } if !s.localizedCaseInsensitiveContains("]*>"#, options: []) { let range = NSRange(s.startIndex..\(proxyBase)", at: r.upperBound) } } } // Absolute http(s) URL's in attributen → /a/ 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: #"]*>([^<]+)"#, 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).. [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)..]*)>([\s\S]*?)"#, 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: #"]*)>([\s\S]*?)"#, 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 `>(…)` over sibling `/>` reps heen // (Ziggo/Viaplay) en pakt de audio-SegmentTimeline bij video → 404 segmenten. pattern: #"]*)/>|]*)>([\s\S]*?)"#, 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 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: #"]*)>([\s\S]*?)|]*)/>"#, 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: "([\s\S]*?)"#, options: [.regularExpression, .caseInsensitive]) { let body = String(block[tl]) if let sRegex = try? NSRegularExpression(pattern: #"]*)/?>"#, 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: #"]*>([\s\S]*?)"#, 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: #"]*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..]*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: #"([\s\S]*?)"#, 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).. 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..= 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)" } } } }