Stop putting User-Agent on URLRequest so CFNetwork cannot replace it, and log the UA iOS actually sent.

Play-diagnose shows bedoeld vs verzonden; Don't go MPD fails with that UA on screen instead of playing the promo.
This commit is contained in:
Jos Vooges | STH 2026-09-28 21:41:47 +02:00
parent e303dc4d37
commit 24913bddf0
6 changed files with 206 additions and 13 deletions

View file

@ -345,7 +345,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -360,7 +360,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.57; MARKETING_VERSION = 0.5.58;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -378,7 +378,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 70; CURRENT_PROJECT_VERSION = 71;
DEVELOPMENT_TEAM = X25NYX3K49; DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@ -393,7 +393,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks" "@executable_path/Frameworks"
); );
MARKETING_VERSION = 0.5.57; MARKETING_VERSION = 0.5.58;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -377,6 +377,18 @@ actor ApiClient {
return try await get("/api/v1/client/viaplay/content/\(enc)/play\(q)") 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. /// Encode path segment (incl. `:` in `custom:uuid`) — strenger dan urlPathAllowed.
private static func encodePathSegment(_ raw: String) -> String { private static func encodePathSegment(_ raw: String) -> String {
var allowed = CharacterSet.alphanumerics var allowed = CharacterSet.alphanumerics

View file

@ -19,7 +19,7 @@ enum ViaplayPlaybackHeaders {
} }
static func apply(to req: inout URLRequest) { 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(referer, forHTTPHeaderField: "Referer")
req.setValue(origin, forHTTPHeaderField: "Origin") req.setValue(origin, forHTTPHeaderField: "Origin")
} }
@ -27,8 +27,8 @@ enum ViaplayPlaybackHeaders {
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy") 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. /// CFNetwork volgt redirects met de app-UA; wij volgen zelf. Log de écht verzonden UA.
private final class DenyRedirects: NSObject, URLSessionTaskDelegate { private final class ViaplayCdnSessionDelegate: NSObject, URLSessionTaskDelegate {
func urlSession( func urlSession(
_ session: URLSession, _ session: URLSession,
task: URLSessionTask, task: URLSessionTask,
@ -38,9 +38,22 @@ private final class DenyRedirects: NSObject, URLSessionTaskDelegate {
) { ) {
completionHandler(nil) 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 final class ResumeOnce: @unchecked Sendable {
private var done = false 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. /// 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. /// VLC speelt daarna plain media vanaf 127.0.0.1.
final class ClearKeyDashProxy { final class ClearKeyDashProxy {
static let shared = 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). /// CMAF: video- én audio-init hebben vaak dezelfde track_ID (1).
/// Gescheiden state voorkomt dat audio-crypto video-decrypt overschrijft → geluid zonder beeld. /// Gescheiden state voorkomt dat audio-crypto video-decrypt overschrijft → geluid zonder beeld.
private enum CryptoLane: String { private enum CryptoLane: String {
@ -132,7 +188,7 @@ final class ClearKeyDashProxy {
] ]
return URLSession( return URLSession(
configuration: cfg, configuration: cfg,
delegate: viaplayDenyRedirects, delegate: viaplayCdnSessionDelegate,
delegateQueue: nil delegateQueue: nil
) )
}() }()
@ -225,6 +281,7 @@ final class ClearKeyDashProxy {
throw ProxyError.noKeys throw ProxyError.noKeys
} }
let hls = Self.isHlsURL(manifestURL) let hls = Self.isHlsURL(manifestURL)
Self.resetCdnDiag()
let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in let bound: (port: UInt16, session: Int) = try await withCheckedThrowingContinuation { cont in
queue.async { queue.async {
@ -1126,7 +1183,7 @@ final class ClearKeyDashProxy {
req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent") req.setValue("VMC-iOS-ClearKey/0.5.5", forHTTPHeaderField: "User-Agent")
session = urlSession 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 { guard let http = resp as? HTTPURLResponse else {
return (data, current) return (data, current)
} }
@ -1149,22 +1206,97 @@ final class ClearKeyDashProxy {
throw ProxyError.upstream(310) 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. /// 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) { private func fetchManifest(_ original: URL) async throws -> (data: Data, baseURL: URL) {
let sticky = withState { resolvedManifest } let sticky = withState { resolvedManifest }
if let sticky, sticky != original { if let sticky, sticky != original {
if let got = try? await fetchFollowingRedirects(sticky) { if let got = try? await fetchFollowingRedirects(sticky) {
withState { resolvedManifest = got.finalURL } try acceptManifest(got, original: original)
return (got.data, got.finalURL) return (got.data, got.finalURL)
} }
proxyLog.error("sticky manifest failed — re-resolving via original URL") proxyLog.error("sticky manifest failed — re-resolving via original URL")
} }
let got = try await fetchFollowingRedirects(original) 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 } withState { resolvedManifest = got.finalURL }
if got.finalURL != original { if got.finalURL != original {
proxyLog.info("manifest redirected to \(got.finalURL.host ?? "?", privacy: .public)") 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 { private func rewriteMpd(_ xml: String, mpdURL: URL) -> String {
@ -2141,6 +2273,7 @@ final class ClearKeyDashProxy {
case badPath case badPath
case emptyManifest case emptyManifest
case upstream(Int) case upstream(Int)
case dontGo(observedUA: String, path: String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@ -2150,6 +2283,8 @@ final class ClearKeyDashProxy {
case .badPath: return "Ongeldig proxy-pad" case .badPath: return "Ongeldig proxy-pad"
case .emptyManifest: return "Leeg of ongeldig stream-manifest ontvangen" case .emptyManifest: return "Leeg of ongeldig stream-manifest ontvangen"
case .upstream(let code): return "Upstream HTTP \(code)" case .upstream(let code): return "Upstream HTTP \(code)"
case .dontGo(let ua, let path):
return "Don't go-MPD. Verzonden UA: \(ua). Pad: \(path)"
} }
} }
} }

View file

@ -616,6 +616,9 @@ struct PlayerScreen: View {
waited += stepNs waited += stepNs
} }
if av.isPlaying && !av.hasError { if av.isPlaying && !av.hasError {
if let diag = ClearKeyDashProxy.lastCdnDiag {
await app.api.reportViaplayCdnDiag(diag)
}
loading = false loading = false
return return
} }
@ -668,6 +671,9 @@ struct PlayerScreen: View {
} }
try? await Task.sleep(nanoseconds: 250_000_000) try? await Task.sleep(nanoseconds: 250_000_000)
loading = false loading = false
if request.usesViaplayCdn, let diag = ClearKeyDashProxy.lastCdnDiag {
await app.api.reportViaplayCdnDiag(diag)
}
if !useClearKeyAV && !vlc.hasError { if !useClearKeyAV && !vlc.hasError {
vlc.refreshTracks() vlc.refreshTracks()
} }

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") { Section("Info") {
LabeledContent("App", value: "VMC iOS") 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("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
} }

View file

@ -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) => { app.get("/api/v1/client/viaplay/content/:guid/play", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { guid: rawGuid } = request.params as { guid: string }; const { guid: rawGuid } = request.params as { guid: string };