diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index a1d1daa..3c244fb 100644 --- a/apps/ios/VMC.xcodeproj/project.pbxproj +++ b/apps/ios/VMC.xcodeproj/project.pbxproj @@ -345,7 +345,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.57; + MARKETING_VERSION = 0.5.58; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.57; + MARKETING_VERSION = 0.5.58; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index ee91dc1..e956e89 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -377,6 +377,18 @@ actor ApiClient { return try await get("/api/v1/client/viaplay/content/\(enc)/play\(q)") } + func reportViaplayCdnDiag(_ d: CdnFetchDiag) async { + struct Ok: Decodable { let ok: Bool? } + _ = try? await post("/api/v1/client/viaplay/client-diag", json: [ + "intendedUA": d.intendedUA, + "observedUA": d.observedUA, + "host": d.host, + "path": d.path, + "dontGo": d.dontGo, + "status": d.status, + ]) as Ok + } + /// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed. private static func encodePathSegment(_ raw: String) -> String { var allowed = CharacterSet.alphanumerics diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index f96998c..ae50634 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -19,7 +19,7 @@ enum ViaplayPlaybackHeaders { } static func apply(to req: inout URLRequest) { - req.setValue(userAgent, forHTTPHeaderField: "User-Agent") + // User-Agent NIET op URLRequest zetten: CFNetwork vervangt die dan door VMC/… CFNetwork/… req.setValue(referer, forHTTPHeaderField: "Referer") req.setValue(origin, forHTTPHeaderField: "Origin") } @@ -27,8 +27,8 @@ enum ViaplayPlaybackHeaders { private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy") -/// CFNetwork volgt redirects met de app-UA; wij volgen zelf en zetten Chrome/122 opnieuw. -private final class DenyRedirects: NSObject, URLSessionTaskDelegate { +/// 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, @@ -38,9 +38,22 @@ private final class DenyRedirects: NSObject, URLSessionTaskDelegate { ) { 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 viaplayDenyRedirects = DenyRedirects() +private let viaplayCdnSessionDelegate = ViaplayCdnSessionDelegate() + +private final class URLSessionTaskBox: @unchecked Sendable { + var task: URLSessionDataTask? +} private final class ResumeOnce: @unchecked Sendable { private var done = false @@ -54,11 +67,54 @@ private final class ResumeOnce: @unchecked Sendable { } } +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 { @@ -132,7 +188,7 @@ final class ClearKeyDashProxy { ] return URLSession( configuration: cfg, - delegate: viaplayDenyRedirects, + delegate: viaplayCdnSessionDelegate, delegateQueue: nil ) }() @@ -225,6 +281,7 @@ final class ClearKeyDashProxy { throw ProxyError.noKeys } let hls = Self.isHlsURL(manifestURL) + Self.resetCdnDiag() let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in queue.async { @@ -1126,7 +1183,7 @@ final class ClearKeyDashProxy { req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent") session = urlSession } - let (data, resp) = try await session.data(for: req) + let (data, resp) = try await Self.dataWithObservedUA(session: session, request: req) guard let http = resp as? HTTPURLResponse else { return (data, current) } @@ -1149,22 +1206,97 @@ final class ClearKeyDashProxy { 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) { - withState { resolvedManifest = got.finalURL } + 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)") } - return (got.data, got.finalURL) + 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 { @@ -2141,6 +2273,7 @@ final class ClearKeyDashProxy { case badPath case emptyManifest case upstream(Int) + case dontGo(observedUA: String, path: String) var errorDescription: String? { switch self { @@ -2150,6 +2283,8 @@ final class ClearKeyDashProxy { 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)" } } } diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index ed1f065..2921a7c 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -616,6 +616,9 @@ struct PlayerScreen: View { waited += stepNs } if av.isPlaying && !av.hasError { + if let diag = ClearKeyDashProxy.lastCdnDiag { + await app.api.reportViaplayCdnDiag(diag) + } loading = false return } @@ -668,6 +671,9 @@ struct PlayerScreen: View { } try? await Task.sleep(nanoseconds: 250_000_000) loading = false + if request.usesViaplayCdn, let diag = ClearKeyDashProxy.lastCdnDiag { + await app.api.reportViaplayCdnDiag(diag) + } if !useClearKeyAV && !vlc.hasError { vlc.refreshTracks() } diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 45cfaef..2875027 100644 --- a/apps/ios/VMC/UI/SettingsScreen.swift +++ b/apps/ios/VMC/UI/SettingsScreen.swift @@ -96,7 +96,7 @@ struct SettingsScreen: View { Section("Info") { LabeledContent("App", value: "VMC iOS") - LabeledContent("Versie", value: "0.5.57") + LabeledContent("Versie", value: "0.5.58") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 8e61c5a..b2e47fa 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1043,6 +1043,46 @@ export function registerViewerRoutes( }; }); + app.post("/api/v1/client/viaplay/client-diag", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const body = (request.body ?? {}) as { + intendedUA?: string; + observedUA?: string; + host?: string; + path?: string; + dontGo?: boolean; + status?: number; + }; + const observed = String(body.observedUA || "").slice(0, 240); + const intended = String(body.intendedUA || "").slice(0, 240); + const host = String(body.host || "").slice(0, 120); + const path = String(body.path || "").slice(0, 180); + const dontGo = !!body.dontGo; + const { recordViaplayPlay, mediaPathOf } = await import("../viaplay/diag"); + const { inventoryFromNames } = await import("../viaplay/cookies"); + recordViaplayPlay({ + kind: "live", + guid: "ios-cdn", + ok: !dontGo, + title: "iOS CDN-fetch", + accountId: null, + accountLabel: null, + httpStatus: Number.isFinite(body.status) ? Number(body.status) : null, + playHost: host || null, + mediaPath: path || mediaPathOf(host), + hasLicense: false, + dontGo, + dontGoReason: dontGo ? `iOS Don't go. UA=${observed}` : null, + productDurationMs: null, + mpdDurationMs: null, + cookies: inventoryFromNames([], null), + refreshOk: null, + error: `bedoeld=${intended} | verzonden=${observed}`, + resolver: "ios-cdn-ua", + }); + return { ok: true }; + }); + app.get("/api/v1/client/viaplay/content/:guid/play", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { guid: rawGuid } = request.params as { guid: string };