Fix iOS ClearKey live-list DASH proxy immediate stop (0.5.2).

Preserve BaseURL/Location, warm up MPD, clear sinf for VLC, and retry early stops so custom live DRM plays instead of Gestopt.
This commit is contained in:
Jos Vooges | STH 2026-09-19 06:53:13 +02:00
parent fac38d905e
commit d1b627752c
10 changed files with 558 additions and 64 deletions

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>X25NYX3K49</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>

View file

@ -42,10 +42,10 @@ open VMCTV.xcodeproj
`Vendor/MobileVLCKit.xcframework` en `Vendor/TVVLCKit.xcframework` staan niet in git (te groot). Altijd op de Mac via de scripts installeren.
## Live TV / Events / ClearKey (0.5.1+)
## Live TV / Events / ClearKey (0.5.2+)
- `streamId` is een **String** (`"123"` of `"custom:uuid"`); path-segmenten worden URL-geëncodeerd (inclusief `:`).
- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId`.
- Play-response parseert `format`, `drm` (ClearKey kids/keys) en `watchSessionId` voor live-lijsten én events.
- Heartbeat: `POST /api/v1/client/watching/heartbeat` ~elke 25s bij custom live + events.
- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op, decrypt CENC/CBCS met de API-keys (zelfde hex kid/key + GUID-endianness als Android), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy.
- **ClearKey/DASH:** lokale in-app proxy (`ClearKeyDashProxy`) haalt MPD/segmenten op (dynamic live: BaseURL/Location/query/Range), decrypt CENC/CBCS (`sinf`→`free`), en speelt plain DASH via MobileVLCKit. Xtream HLS/TS speelt direct zonder proxy.
- Events-tab verschijnt als `/me` → `events.enabled` true is.

View file

@ -337,7 +337,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -352,7 +352,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.1;
MARKETING_VERSION = 0.5.2;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -370,7 +370,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 13;
CURRENT_PROJECT_VERSION = 14;
DEVELOPMENT_TEAM = X25NYX3K49;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@ -385,7 +385,7 @@
"$(inherited)",
"@executable_path/Frameworks"
);
MARKETING_VERSION = 0.5.1;
MARKETING_VERSION = 0.5.2;
OTHER_LDFLAGS = ("$(inherited)", "-ObjC");
PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -196,17 +196,11 @@ enum CencMp4Decryptor {
}
// Replace encv/enca with original format (same 4 bytes → no size rewrite).
// Mark sinf as 'free' zodat VLC geen DRM-sample-entry meer ziet (anders: meteen Gestopt).
if let originalFormat {
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
// Sample entries live in stsd; type is at box.type for encv/enca boxes themselves.
return true
}
// Find encv/enca boxes by scanning stsd children manually.
for stsd in findBoxes(data, type: "stsd", recursive: true) {
guard stsd.start >= trak.start, stsd.payloadEnd <= trak.payloadEnd else { continue }
var o = stsd.payloadStart + 4 // version/flags + entry_count follows version/flags (4) then count (4)
// FullBox (4) + entry_count (4)
o = stsd.payloadStart + 8
var o = stsd.payloadStart + 8 // FullBox (4) + entry_count (4)
while o + 8 <= stsd.payloadEnd {
guard let entry = readBox(data, at: o, limit: stsd.payloadEnd) else { break }
if entry.type == "encv" || entry.type == "enca" {
@ -216,6 +210,12 @@ enum CencMp4Decryptor {
}
}
}
walk(data, from: trak.payloadStart, to: trak.payloadEnd, recursive: true) { box in
if box.type == "sinf" {
writeFourCC(&data, box.start + 4, "free")
}
return true
}
if let crypto {
if let trackId {

View file

@ -1,5 +1,8 @@
import Foundation
import Network
import os.log
private let proxyLog = Logger(subsystem: "nl.vonas.mediacluster.ios", category: "ClearKeyProxy")
/// Lokale HTTP-proxy: haalt DASH MPD/segmenten op, strip DRM-metadata, decrypt CENC met ClearKey.
/// VLC speelt daarna plain DASH vanaf 127.0.0.1.
@ -13,12 +16,21 @@ final class ClearKeyDashProxy {
private var keyMap: ClearKeyKeyMap?
private var cryptoState = CencMp4Decryptor.SessionState()
private var generation = 0
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 25
cfg.timeoutIntervalForResource = 60
cfg.httpMaximumConnectionsPerHost = 8
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
return URLSession(configuration: cfg)
}()
private init() {}
var isRunning: Bool { listener != nil && port > 0 }
/// Start (of herstart) proxy voor dit manifest + keys. Geeft lokale MPD-URL terug.
/// Warmt de MPD-fetch op zodat VLC niet meteen een lege/fout-response krijgt.
func start(manifestURL: URL, keys: [ClearKeyEntry]) throws -> URL {
let map = ClearKeyKeyMap(entries: keys)
guard !map.isEmpty else {
@ -29,6 +41,7 @@ final class ClearKeyDashProxy {
keyMap = map
cryptoState = CencMp4Decryptor.SessionState()
generation += 1
let gen = generation
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
@ -45,8 +58,10 @@ final class ClearKeyDashProxy {
if let p = listener.port?.rawValue {
self.port = p
}
proxyLog.info("listener ready on port \(self.port, privacy: .public)")
ready.signal()
case .failed(let err):
proxyLog.error("listener failed: \(String(describing: err), privacy: .public)")
readyError = err
ready.signal()
default:
@ -63,16 +78,38 @@ final class ClearKeyDashProxy {
if let readyError { throw readyError }
guard port > 0 else { throw ProxyError.bindFailed }
// Warm-up: MPD ophalen + herschrijven vóór VLC start (live dynamic MPD's falen anders meteen).
let warm = DispatchSemaphore(value: 0)
var warmError: Error?
Task {
do {
let (body, _) = try await self.payload(for: "/manifest.mpd")
guard !body.isEmpty, gen == self.generation else {
throw ProxyError.emptyManifest
}
let preview = String(data: body.prefix(200), encoding: .utf8) ?? ""
proxyLog.info("warmup MPD ok bytes=\(body.count, privacy: .public) head=\(preview, privacy: .public)")
} catch {
warmError = error
proxyLog.error("warmup failed: \(error.localizedDescription, privacy: .public)")
}
warm.signal()
}
_ = warm.wait(timeout: .now() + 20)
if let warmError { throw warmError }
return URL(string: "http://127.0.0.1:\(port)/manifest.mpd")!
}
func stop() {
generation += 1
listener?.cancel()
listener = nil
port = 0
remoteManifest = nil
keyMap = nil
cryptoState = CencMp4Decryptor.SessionState()
proxyLog.info("proxy stopped")
}
// MARK: - HTTP
@ -124,22 +161,66 @@ final class ClearKeyDashProxy {
return
}
let method = String(parts[0])
let path = String(parts[1])
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: path)
if method == "HEAD" {
self.respond(connection, status: 200, contentType: contentType, body: Data(), headOnly: true, contentLength: body.count)
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)
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))
}
}
@ -149,17 +230,36 @@ final class ClearKeyDashProxy {
guard let keyMap, let remoteManifest else {
throw ProxyError.notStarted
}
let pathOnly = pathAndQuery.split(separator: "?", maxSplits: 1).first.map(String.init) ?? pathAndQuery
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" {
let raw = try await fetch(remoteManifest)
let rewritten = rewriteMpd(String(data: raw, encoding: .utf8) ?? "", mpdURL: remoteManifest)
guard let xml = String(data: raw, encoding: .utf8), !xml.isEmpty else {
// Sommige CDN's sturen UTF-16 BOM; probeer lossy.
let lossy = String(decoding: raw, as: UTF8.self)
guard !lossy.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ProxyError.emptyManifest
}
let rewritten = rewriteMpd(lossy, mpdURL: remoteManifest)
return (Data(rewritten.utf8), "application/dash+xml")
}
let rewritten = rewriteMpd(xml, mpdURL: remoteManifest)
if !rewritten.localizedCaseInsensitiveContains("<MPD") {
throw ProxyError.emptyManifest
}
return (Data(rewritten.utf8), "application/dash+xml")
}
if pathOnly.hasPrefix("/a/") {
let b64 = String(pathOnly.dropFirst(3))
guard let url = decodeURL(b64) else { throw ProxyError.badPath }
guard var url = decodeURL(b64) else { throw ProxyError.badPath }
if let query, url.query == nil {
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)
comps?.percentEncodedQuery = query
if let u = comps?.url { url = u }
}
return try await fetchAndDecrypt(url, keys: keyMap)
}
@ -167,9 +267,12 @@ final class ClearKeyDashProxy {
let rest = String(pathOnly.dropFirst(3))
guard let slash = rest.firstIndex(of: "/") else { throw ProxyError.badPath }
let b64 = String(rest[..<slash])
let rel = String(rest[rest.index(after: slash)...])
var rel = String(rest[rest.index(after: slash)...])
if let query {
rel += (rel.contains("?") ? "&" : "?") + query
}
guard let base = decodeURL(b64) else { throw ProxyError.badPath }
let joined = join(base: base, relative: rel.removingPercentEncoding ?? rel)
let joined = join(base: asDirectory(base), relative: rel.removingPercentEncoding ?? rel)
return try await fetchAndDecrypt(joined, keys: keyMap)
}
@ -178,14 +281,17 @@ final class ClearKeyDashProxy {
private func fetchAndDecrypt(_ url: URL, keys: ClearKeyKeyMap) async throws -> (Data, String) {
var data = try await fetch(url)
let lower = url.absoluteString.lowercased()
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")
}
queue.sync {
_ = CencMp4Decryptor.process(data: &data, keys: keys, state: &cryptoState)
}
let ct: String
let lower = url.absoluteString.lowercased()
if lower.contains(".mpd") {
ct = "application/dash+xml"
} else if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") {
if lower.contains(".m4s") || lower.contains(".mp4") || lower.contains("init") || lower.contains(".cmfv") || lower.contains(".cmfa") {
ct = "video/mp4"
} else {
ct = "application/octet-stream"
@ -195,9 +301,11 @@ final class ClearKeyDashProxy {
private func fetch(_ url: URL) async throws -> Data {
var req = URLRequest(url: url)
req.timeoutInterval = 20
req.setValue("VMC-iOS-ClearKey/0.5.1", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
req.timeoutInterval = 25
req.cachePolicy = .reloadIgnoringLocalCacheData
req.setValue("VMC-iOS-ClearKey/0.5.2", forHTTPHeaderField: "User-Agent")
req.setValue("bytes", forHTTPHeaderField: "Accept-Ranges")
let (data, resp) = try await urlSession.data(for: req)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw ProxyError.upstream(http.statusCode)
}
@ -218,17 +326,32 @@ final class ClearKeyDashProxy {
options: .regularExpression
)
let originDir = mpdURL.deletingLastPathComponent()
let proxyBase = "http://127.0.0.1:\(port)/p/\(encodeURL(originDir))/"
// Herschrijf bestaande BaseURL-tekst naar proxy.
// Live dynamic: Location mag niet naar remote DRM-MPD wijzen (VLC omzeilt proxy).
s = s.replacingOccurrences(
of: #"<BaseURL>([\s\S]*?)</BaseURL>"#,
with: "<BaseURL>\(proxyBase)</BaseURL>",
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 = 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),
@ -244,7 +367,6 @@ final class ClearKeyDashProxy {
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)
// Laat lokale proxy-URL's met rust.
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)
@ -254,6 +376,36 @@ final class ClearKeyDashProxy {
return s
}
/// 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
@ -284,17 +436,47 @@ final class ClearKeyDashProxy {
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
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"
@ -307,6 +489,9 @@ final class ClearKeyDashProxy {
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) }
@ -320,6 +505,7 @@ final class ClearKeyDashProxy {
case bindFailed
case notStarted
case badPath
case emptyManifest
case upstream(Int)
var errorDescription: String? {
@ -328,6 +514,7 @@ final class ClearKeyDashProxy {
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 DASH-manifest ontvangen"
case .upstream(let code): return "Upstream HTTP \(code)"
}
}

View file

@ -393,14 +393,14 @@ struct PlayerScreen: View {
format: request.format,
clearKeys: keys
)
// ClearKey/DASH heeft meer opstarttijd (proxy + init/media segments).
let waitNs: UInt64 = request.needsClearKey ? 8_000_000_000 : 2_500_000_000
// ClearKey/DASH: proxy-warmup + init/media + eventuele retry.
let waitNs: UInt64 = request.needsClearKey ? 10_000_000_000 : 2_500_000_000
try? await Task.sleep(nanoseconds: waitNs)
if request.needsClearKey && (player.hasError || !player.isPlaying) {
try? await Task.sleep(nanoseconds: 4_000_000_000)
try? await Task.sleep(nanoseconds: 6_000_000_000)
if player.hasError || !player.isPlaying {
error = player.errorMessage
?? "Kon deze ClearKey-stream niet afspelen. Controleer netwerk of probeer opnieuw."
?? "Kon deze ClearKey-livezender niet starten. Het DASH-manifest of de sleutels zijn mogelijk ongeldig."
}
}
} else {

View file

@ -20,6 +20,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
private var lengthMs: Int32 = 0
private var tick: AnyCancellable?
private var clearKeyProxyActive = false
private var livePlayURL: URL?
private var livePlayFormat: String?
private var liveStartedAt: Date?
private var liveRetryCount = 0
private var everPlayed = false
override init() {
super.init()
@ -78,6 +83,9 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
hasError = false
errorMessage = nil
stateText = "Live laden…"
liveRetryCount = 0
everPlayed = false
liveStartedAt = Date()
var playURL = url
var playFormat = format
@ -86,40 +94,75 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
if !clearKeys.isEmpty {
do {
// MobileVLCKit kan CENC/ClearKey niet zelf; lokale proxy decrypt segmenten.
// start() wacht tot listener + MPD-warmup klaar zijn.
playURL = try ClearKeyDashProxy.shared.start(manifestURL: url, keys: clearKeys)
playFormat = "dash"
clearKeyProxyActive = true
stateText = "ClearKey laden…"
} catch {
hasError = true
errorMessage = error.localizedDescription
errorMessage = dutchClearKeyError(error)
stateText = "Fout"
return
}
}
let media = VLCMedia(url: playURL)
media.delegate = self
Self.applyLiveOptions(to: media, url: playURL, format: playFormat)
mediaPlayer.media = media
media.parse()
mediaPlayer.play()
applySubtitleStyle()
livePlayURL = playURL
livePlayFormat = playFormat
startLiveMedia(url: playURL, format: playFormat)
if let fallback, clearKeys.isEmpty {
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, !self.isPlaying || self.hasError else { return }
let fb = VLCMedia(url: fallback)
fb.delegate = self
Self.applyLiveOptions(to: fb, url: fallback, format: nil)
self.mediaPlayer.media = fb
fb.parse()
self.mediaPlayer.play()
self.applySubtitleStyle()
self.startLiveMedia(url: fallback, format: nil)
}
}
startTicker()
}
private func startLiveMedia(url: URL, format: String?) {
let media = VLCMedia(url: url)
media.delegate = self
Self.applyLiveOptions(to: media, url: url, format: format)
mediaPlayer.media = media
// Geen parse() op live/DASH — blokkeert/stopt soms meteen op dynamic MPD.
mediaPlayer.play()
applySubtitleStyle()
}
private func dutchClearKeyError(_ error: Error) -> String {
let msg = error.localizedDescription
if msg.localizedCaseInsensitiveContains("upstream") {
return "Stream-server gaf een fout (\(msg)). Probeer opnieuw."
}
if msg.localizedCaseInsensitiveContains("leeg") || msg.localizedCaseInsensitiveContains("ongeldig") {
return "DASH-manifest kon niet worden geladen. Controleer de zender-URL."
}
if msg.localizedCaseInsensitiveContains("proxy") || msg.localizedCaseInsensitiveContains("bind") {
return "Lokale ClearKey-proxy startte niet. Herstart de app en probeer opnieuw."
}
if msg.localizedCaseInsensitiveContains("sleutel") {
return "Geen geldige ClearKey-sleutels voor deze zender."
}
return "ClearKey-stream mislukt: \(msg)"
}
/// Na vroege Gestopt/error: één keer opnieuw afspelen (proxy blijft actief).
private func retryLiveIfNeeded() {
guard clearKeyProxyActive, !everPlayed, liveRetryCount < 2 else { return }
guard let url = livePlayURL else { return }
let elapsed = Date().timeIntervalSince(liveStartedAt ?? Date())
guard elapsed < 12 else { return }
liveRetryCount += 1
stateText = "Opnieuw verbinden…"
hasError = false
errorMessage = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self, self.clearKeyProxyActive else { return }
self.startLiveMedia(url: url, format: self.livePlayFormat)
}
}
private static func applyLiveOptions(
to media: VLCMedia,
url: URL,
@ -406,6 +449,11 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
tick = nil
mediaPlayer.stop()
mediaPlayer.media = nil
livePlayURL = nil
livePlayFormat = nil
liveStartedAt = nil
liveRetryCount = 0
everPlayed = false
if clearKeyProxyActive {
ClearKeyDashProxy.shared.stop()
clearKeyProxyActive = false
@ -422,15 +470,31 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg
case .playing:
self.stateText = "Afspelen"
self.hasError = false
self.errorMessage = nil
self.everPlayed = true
self.refreshTracks()
self.applySubtitleStyle()
case .paused: self.stateText = "Pauze"
case .stopped: self.stateText = "Gestopt"
case .stopped:
// Vroege "Gestopt" bij ClearKey is vaak tijdelijk (init/segment race) — niet meteen opgeven.
if self.clearKeyProxyActive && !self.everPlayed {
self.stateText = "Verbinden…"
self.retryLiveIfNeeded()
} else {
self.stateText = "Gestopt"
}
case .ended: self.stateText = "Einde"
case .error:
self.hasError = true
self.errorMessage = "Kon dit bestand niet afspelen"
self.stateText = "Fout"
if self.clearKeyProxyActive && !self.everPlayed && self.liveRetryCount < 2 {
self.stateText = "Opnieuw…"
self.retryLiveIfNeeded()
} else {
self.hasError = true
self.errorMessage = self.clearKeyProxyActive
? "Kon deze ClearKey-livezender niet afspelen. Controleer netwerk of probeer opnieuw."
: "Kon dit bestand niet afspelen"
self.stateText = "Fout"
}
case .esAdded:
self.refreshTracks()
@unknown default: break

View file

@ -96,7 +96,7 @@ struct SettingsScreen: View {
Section("Info") {
LabeledContent("App", value: "VMC iOS")
LabeledContent("Versie", value: "0.5.1")
LabeledContent("Versie", value: "0.5.2")
LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit")
LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit")
}

View file

@ -0,0 +1,16 @@
#!/bin/bash
exec > /tmp/vmc-tf-upload.log 2>&1
echo "TF_UPLOAD_START $(date)"
security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
security show-keychain-info "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
# Allow codesign non-interactively in this GUI login session (worked for prior export)
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" 2>&1 || true
export KEY_ID=C4LTZP6328
export ISSUER_ID=ab4414a1-90fc-4fbf-b051-59d95c48097e
export AUTH_KEY_PATH="$HOME/AuthKey.p8"
bash "$HOME/upload-testflight.sh" --key-id "$KEY_ID" --issuer-id "$ISSUER_ID" --auth-key "$AUTH_KEY_PATH"
EC=$?
echo "TF_UPLOAD_EXIT:$EC"
date > /tmp/vmc-tf-upload.done
echo "$EC" > /tmp/vmc-tf-upload.ec
exit "$EC"

View file

@ -0,0 +1,209 @@
#!/bin/bash
# TestFlight upload via App Store Connect API key (headless, no GUI).
# Expects: ~/AuthKey.p8 (or AUTH_KEY_PATH)
# Requires: KEY_ID, ISSUER_ID (env or --key-id / --issuer-id)
# Optional: KEYCHAIN_PASS to unlock login keychain
set -euo pipefail
IOS_DIR="${IOS_DIR:-$HOME/Code/mediacluster/apps/ios}"
AUTH_KEY_PATH="${AUTH_KEY_PATH:-$HOME/AuthKey.p8}"
EXPORT_OPTIONS="${EXPORT_OPTIONS:-$IOS_DIR/ExportOptions.plist}"
ARCHIVE_PATH="${ARCHIVE_PATH:-$IOS_DIR/build/VMC.xcarchive}"
EXPORT_PATH="${EXPORT_PATH:-$IOS_DIR/build/export}"
SCHEME="${SCHEME:-VMC}"
CONFIGURATION="${CONFIGURATION:-Release}"
TEAM_ID="${TEAM_ID:-X25NYX3K49}"
BUNDLE_ID="${BUNDLE_ID:-nl.vonas.mediacluster.ios}"
SKIP_ARCHIVE=0
SKIP_EXPORT=0
IPA_PATH=""
usage() {
cat <<'EOF'
Usage: upload-testflight.sh [options]
--key-id ID App Store Connect API Key ID (or env KEY_ID)
--issuer-id UUID App Store Connect Issuer ID (or env ISSUER_ID)
--auth-key PATH Path to .p8 (default: ~/AuthKey.p8)
--ipa PATH Skip archive/export; upload existing IPA
--skip-archive Reuse existing xcarchive
--skip-export Reuse existing IPA in export path
-h, --help Show help
Env:
KEY_ID, ISSUER_ID, AUTH_KEY_PATH, KEYCHAIN_PASS, IOS_DIR
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--key-id) KEY_ID="${2:-}"; shift 2 ;;
--issuer-id) ISSUER_ID="${2:-}"; shift 2 ;;
--auth-key) AUTH_KEY_PATH="${2:-}"; shift 2 ;;
--ipa) IPA_PATH="${2:-}"; SKIP_ARCHIVE=1; SKIP_EXPORT=1; shift 2 ;;
--skip-archive) SKIP_ARCHIVE=1; shift ;;
--skip-export) SKIP_EXPORT=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
KEY_ID="${KEY_ID:-}"
ISSUER_ID="${ISSUER_ID:-}"
# Derive Key ID from AuthKey_XXXXX.p8 filename if missing
if [[ -z "$KEY_ID" && -f "$AUTH_KEY_PATH" ]]; then
base="$(basename "$AUTH_KEY_PATH")"
if [[ "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
KEY_ID="${BASH_REMATCH[1]}"
echo "KEY_ID derived from filename: $KEY_ID"
fi
fi
# Also accept ~/AuthKey_XXXXX.p8 if ~/AuthKey.p8 missing
if [[ ! -f "$AUTH_KEY_PATH" ]]; then
for cand in "$HOME"/AuthKey_*.p8; do
if [[ -f "$cand" ]]; then
AUTH_KEY_PATH="$cand"
base="$(basename "$cand")"
if [[ -z "$KEY_ID" && "$base" =~ ^AuthKey_([A-Z0-9]+)\.p8$ ]]; then
KEY_ID="${BASH_REMATCH[1]}"
fi
echo "Using API key file: $AUTH_KEY_PATH"
break
fi
done
fi
die() { echo "ERROR: $*" >&2; exit 1; }
[[ -n "$KEY_ID" ]] || die "KEY_ID ontbreekt (env KEY_ID of --key-id, of AuthKey_XXXXX.p8)"
[[ -n "$ISSUER_ID" ]] || die "ISSUER_ID ontbreekt (env ISSUER_ID of --issuer-id)"
[[ -f "$AUTH_KEY_PATH" ]] || die "API key niet gevonden: $AUTH_KEY_PATH (scp AuthKey*.p8 naar ~ op de Mac)"
# Private keys for altool/transporter must live in a predictable API Keys folder
API_KEYS_DIR="$HOME/.appstoreconnect/private_keys"
mkdir -p "$API_KEYS_DIR"
API_KEY_DEST="$API_KEYS_DIR/AuthKey_${KEY_ID}.p8"
if [[ "$(cd "$(dirname "$AUTH_KEY_PATH")" && pwd)/$(basename "$AUTH_KEY_PATH")" != "$API_KEY_DEST" ]]; then
cp -f "$AUTH_KEY_PATH" "$API_KEY_DEST"
chmod 600 "$API_KEY_DEST"
fi
# Also keep canonical ~/AuthKey.p8 symlink/copy for convenience
if [[ ! -f "$HOME/AuthKey.p8" ]]; then
cp -f "$AUTH_KEY_PATH" "$HOME/AuthKey.p8"
chmod 600 "$HOME/AuthKey.p8"
fi
echo "=== VMC TestFlight upload ==="
echo "KEY_ID=$KEY_ID"
echo "ISSUER_ID=$ISSUER_ID"
echo "AUTH_KEY=$AUTH_KEY_PATH"
echo "BUNDLE_ID=$BUNDLE_ID"
echo "TEAM_ID=$TEAM_ID"
# Unlock keychain if password provided (headless codesign)
if [[ -n "${KEYCHAIN_PASS:-}" ]]; then
echo "Unlocking login keychain..."
security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain-db" || \
security unlock-keychain -p "$KEYCHAIN_PASS" "$HOME/Library/Keychains/login.keychain" || true
security set-keychain-settings -t 3600 -u "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
fi
security list-keychains -s "$HOME/Library/Keychains/login.keychain-db" /Library/Keychains/System.keychain || true
cd "$IOS_DIR"
mkdir -p build "$EXPORT_PATH" store
# Ensure ExportOptions.plist for App Store
if [[ ! -f "$EXPORT_OPTIONS" ]]; then
cat > "$EXPORT_OPTIONS" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store</string>
<key>teamID</key>
<string>${TEAM_ID}</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>destination</key>
<string>export</string>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
PLIST
echo "Created $EXPORT_OPTIONS"
fi
if [[ "$SKIP_ARCHIVE" -eq 0 ]]; then
echo "=== Archive (Release / generic iOS) ==="
rm -rf "$ARCHIVE_PATH"
xcodebuild \
-project VMC.xcodeproj \
-scheme "$SCHEME" \
-configuration "$CONFIGURATION" \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
-allowProvisioningUpdates \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_STYLE=Automatic \
archive
echo "ARCHIVE_OK: $ARCHIVE_PATH"
fi
if [[ "$SKIP_EXPORT" -eq 0 ]]; then
[[ -d "$ARCHIVE_PATH" ]] || die "Archive ontbreekt: $ARCHIVE_PATH"
echo "=== Export IPA ==="
rm -rf "$EXPORT_PATH"
mkdir -p "$EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f | head -1)"
[[ -n "$IPA_PATH" && -f "$IPA_PATH" ]] || die "Geen IPA na export in $EXPORT_PATH"
VER="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleShortVersionString' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo '0.5.0')"
BUILD="$(/usr/libexec/PlistBuddy -c 'Print :ApplicationProperties:CFBundleVersion' "$ARCHIVE_PATH/Info.plist" 2>/dev/null || echo 'unknown')"
cp -f "$IPA_PATH" "$IOS_DIR/store/VMC-${VER}-${BUILD}.ipa"
echo "IPA_OK: $IPA_PATH (copied to store/VMC-${VER}-${BUILD}.ipa)"
fi
if [[ -z "${IPA_PATH:-}" ]]; then
IPA_PATH="$(find "$EXPORT_PATH" -name '*.ipa' -type f 2>/dev/null | head -1 || true)"
fi
[[ -n "${IPA_PATH:-}" && -f "$IPA_PATH" ]] || die "Geen IPA om te uploaden"
echo "=== Upload via altool (API key) ==="
# Prefer modern notary/upload path; altool still works for ASC API key upload
set +e
xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--apiKey "$KEY_ID" \
--apiIssuer "$ISSUER_ID" \
2>&1 | tee "$IOS_DIR/build/testflight-upload.log"
UPLOAD_EC=${PIPESTATUS[0]}
set -e
if [[ "$UPLOAD_EC" -ne 0 ]]; then
echo "altool failed (exit $UPLOAD_EC); trying iTMSTransporter..."
TRANSPORTER="/Applications/Xcode.app/Contents/SharedFrameworks/ContentDeliveryServices.framework/Versions/A/itms/bin/iTMSTransporter"
if [[ -x "$TRANSPORTER" ]]; then
"$TRANSPORTER" -m upload \
-assetFile "$IPA_PATH" \
-apiKey "$KEY_ID" \
-apiIssuer "$ISSUER_ID" \
-v informational 2>&1 | tee -a "$IOS_DIR/build/testflight-upload.log"
else
die "Upload mislukt (altool exit $UPLOAD_EC) en iTMSTransporter niet gevonden"
fi
fi
echo "UPLOAD_OK $(date)"
echo "IPA=$IPA_PATH"
echo "Log: $IOS_DIR/build/testflight-upload.log"