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:
parent
e303dc4d37
commit
24913bddf0
6 changed files with 206 additions and 13 deletions
|
|
@ -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)";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue