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