From f35200e0c3529cc707d819f0cd0847002e67b7b2 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 22:33:35 +0200 Subject: [PATCH] Play Viaplay catch-up from the KAP window start, not the live edge. --- apps/ios/VMC.xcodeproj/project.pbxproj | 8 +- .../VMC/Playback/AVLivePlayerController.swift | 6 +- apps/ios/VMC/Playback/ClearKeyDashProxy.swift | 39 +++++++--- apps/ios/VMC/Playback/PlayerScreen.swift | 15 +++- .../VMC/Playback/VLCPlayerController.swift | 8 +- apps/ios/VMC/UI/SettingsScreen.swift | 2 +- apps/master-api/src/viaplay/play.ts | 75 ++++++++++++++++++- 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/apps/ios/VMC.xcodeproj/project.pbxproj b/apps/ios/VMC.xcodeproj/project.pbxproj index 3c244fb..dca88d3 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 = 71; + CURRENT_PROJECT_VERSION = 72; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -360,7 +360,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.58; + MARKETING_VERSION = 0.5.59; 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 = 71; + CURRENT_PROJECT_VERSION = 72; DEVELOPMENT_TEAM = X25NYX3K49; ENABLE_BITCODE = NO; ENABLE_PREVIEWS = YES; @@ -393,7 +393,7 @@ "$(inherited)", "@executable_path/Frameworks" ); - MARKETING_VERSION = 0.5.58; + MARKETING_VERSION = 0.5.59; OTHER_LDFLAGS = ("$(inherited)", "-ObjC"); PRODUCT_BUNDLE_IDENTIFIER = nl.vonas.mediacluster.ios; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/apps/ios/VMC/Playback/AVLivePlayerController.swift b/apps/ios/VMC/Playback/AVLivePlayerController.swift index f98d9b9..0ff9adb 100644 --- a/apps/ios/VMC/Playback/AVLivePlayerController.swift +++ b/apps/ios/VMC/Playback/AVLivePlayerController.swift @@ -48,7 +48,8 @@ final class AVLivePlayerController: NSObject, ObservableObject { fallback: URL? = nil, format: String? = nil, clearKeys: [ClearKeyEntry] = [], - browserCdnHeaders: Bool = false + browserCdnHeaders: Bool = false, + forceVod: Bool = false ) async { generation &+= 1 let gen = generation @@ -80,7 +81,8 @@ final class AVLivePlayerController: NSObject, ObservableObject { manifestURL: url, keys: clearKeys, preferHlsForAVPlayer: true, - browserCdnHeaders: browserCdnHeaders + browserCdnHeaders: browserCdnHeaders, + forceVod: forceVod ) guard gen == generation, !stopping else { return } playURL = started.url diff --git a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift index ae50634..86c53b2 100644 --- a/apps/ios/VMC/Playback/ClearKeyDashProxy.swift +++ b/apps/ios/VMC/Playback/ClearKeyDashProxy.swift @@ -18,6 +18,10 @@ enum ViaplayPlaybackHeaders { return false } + static func isKapWindowURL(_ url: URL) -> Bool { + url.path.range(of: #"/start/\d+/stop/\d+"#, options: .regularExpression) != nil + } + 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") @@ -144,6 +148,8 @@ final class ClearKeyDashProxy { private var isHlsMode = false /// AVPlayer kan geen DASH: serve clear HLS (native of vanuit MPD gegenereerd). private var preferHlsOutput = false + /// Catch-up/KAP: HLS vanaf het begin van het venster, niet live-edge. + private var forceVodPlaylist = false /// Segment-CDN's zonder "viaplay" in de host krijgen dezelfde browser-UA als het manifest. private var useViaplayBrowserHeaders = false private var dashVideoRep: DashHlsRep? @@ -274,7 +280,8 @@ final class ClearKeyDashProxy { manifestURL: URL, keys: [ClearKeyEntry], preferHlsForAVPlayer: Bool = false, - browserCdnHeaders: Bool = false + browserCdnHeaders: Bool = false, + forceVod: Bool = false ) async throws -> StartResult { let map = ClearKeyKeyMap(entries: keys) guard !map.isEmpty else { @@ -293,11 +300,15 @@ final class ClearKeyDashProxy { self.keyMap = map self.isHlsMode = hls self.preferHlsOutput = preferHlsForAVPlayer + self.forceVodPlaylist = forceVod self.useViaplayBrowserHeaders = browserCdnHeaders || ViaplayPlaybackHeaders.matches(manifestURL) if self.useViaplayBrowserHeaders { proxyLog.info("CDN headers=Chrome/122 for whole session") } + if forceVod { + proxyLog.info("catch-up/KAP: VOD HLS from window start (not live-edge)") + } self.dashVideoRep = nil self.dashAudioRep = nil self.dashAudioReps = [] @@ -518,6 +529,7 @@ final class ClearKeyDashProxy { keyMap = nil isHlsMode = false preferHlsOutput = false + forceVodPlaylist = false useViaplayBrowserHeaders = false dashVideoRep = nil dashAudioRep = nil @@ -1564,8 +1576,11 @@ final class ClearKeyDashProxy { } private func parseDashReps(_ xml: String, mpdURL: URL) -> [ParsedRep] { - let isLive = xml.range(of: #"type\s*=\s*"dynamic""#, options: [.regularExpression, .caseInsensitive]) != nil + let vod = withState { self.forceVodPlaylist } || ViaplayPlaybackHeaders.isKapWindowURL(mpdURL) + let isLive = !vod && ( + 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]) @@ -1575,9 +1590,9 @@ final class ClearKeyDashProxy { } } - // Live multi-period: pak laatste Period (actuele edge). + // Live multi-period: pak laatste Period (actuele edge). Catch-up/KAP: hele venster. var scopeXML = xml - if let periodRegex = try? NSRegularExpression( + if !vod, let periodRegex = try? NSRegularExpression( pattern: #"]*)>([\s\S]*?)"#, options: [.caseInsensitive] ) { @@ -1849,8 +1864,6 @@ final class ClearKeyDashProxy { 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 { @@ -1894,16 +1907,17 @@ final class ClearKeyDashProxy { edgePad = max(0, total - 3) } let end = max(0, total - edgePad) - // $Time$ (Viaplay/Ziggo): 10 segs. $Number$ (Prime/HBO): 5 segs ≈10s — sneller bufferen. + // Catch-up/VOD: hele venster vanaf het begin. Live: kort stuk achter de edge. let windowLen: Int if !rep.isLive { - windowLen = 12 + windowLen = min(end, 5000) } else if preferNumber { windowLen = 5 } else { windowLen = 10 } - let start = max(0, end - windowLen) + let start = rep.isLive ? max(0, end - windowLen) : 0 + let clipEnd = min(end, start + windowLen) windowStartIndex = start // Alleen het venster materialiseren (Prime/HBO: r≈1800). var absIndex = 0 @@ -1911,7 +1925,7 @@ final class ClearKeyDashProxy { let runCount = max(0, run.count) let runEnd = absIndex + runCount let overlapStart = max(start, absIndex) - let overlapEnd = min(end, runEnd) + let overlapEnd = min(clipEnd, runEnd) if overlapStart < overlapEnd { for i in overlapStart..= end { break } + if absIndex >= clipEnd { break } } } else { // Alleen $Number$ zonder SegmentTimeline (Prime/HBO fallback). @@ -1996,6 +2010,9 @@ final class ClearKeyDashProxy { "#EXT-X-MEDIA-SEQUENCE:\(mediaSequence)", "#EXT-X-INDEPENDENT-SEGMENTS", ] + if !rep.isLive { + lines.append("#EXT-X-PLAYLIST-TYPE:VOD") + } if !rep.initialization.isEmpty { let initURL = resolveTemplateURL( rep.initialization, diff --git a/apps/ios/VMC/Playback/PlayerScreen.swift b/apps/ios/VMC/Playback/PlayerScreen.swift index 2921a7c..5bca8af 100644 --- a/apps/ios/VMC/Playback/PlayerScreen.swift +++ b/apps/ios/VMC/Playback/PlayerScreen.swift @@ -27,6 +27,12 @@ struct PlayRequest: Identifiable { || (watchId ?? "").localizedCaseInsensitiveContains("viaplay") } + /// KAP-terugkijken is een live-MPD met start/stop — niet naar live-edge (Don't go). + var playAsVod: Bool { + if !isLive { return true } + return url.path.range(of: #"/start/\d+/stop/\d+"#, options: .regularExpression) != nil + } + static func vod( url: URL, title: String, @@ -586,7 +592,8 @@ struct PlayerScreen: View { format: request.format, clearKeys: keys, preferHls: true, - browserCdnHeaders: request.usesViaplayCdn + browserCdnHeaders: request.usesViaplayCdn, + forceVod: request.playAsVod ) let maxWaitNs: UInt64 = 18_000_000_000 let stepNs: UInt64 = 200_000_000 @@ -605,7 +612,8 @@ struct PlayerScreen: View { fallback: request.fallbackUrl, format: request.format, clearKeys: keys, - browserCdnHeaders: request.usesViaplayCdn + browserCdnHeaders: request.usesViaplayCdn, + forceVod: request.playAsVod ) let maxWaitNs: UInt64 = 18_000_000_000 let stepNs: UInt64 = 200_000_000 @@ -632,7 +640,8 @@ struct PlayerScreen: View { format: request.format, clearKeys: keys, preferHls: true, - browserCdnHeaders: request.usesViaplayCdn + browserCdnHeaders: request.usesViaplayCdn, + forceVod: request.playAsVod ) waited = 0 while waited < 8_000_000_000 && !vlc.isPlaying && !vlc.hasError { diff --git a/apps/ios/VMC/Playback/VLCPlayerController.swift b/apps/ios/VMC/Playback/VLCPlayerController.swift index decc9e9..cc3d9d0 100644 --- a/apps/ios/VMC/Playback/VLCPlayerController.swift +++ b/apps/ios/VMC/Playback/VLCPlayerController.swift @@ -279,7 +279,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg format: String? = nil, clearKeys: [ClearKeyEntry] = [], preferHls: Bool = false, - browserCdnHeaders: Bool = false + browserCdnHeaders: Bool = false, + forceVod: Bool = false ) async { // Annuleer pending finishStop / delayed proxy-stop van vorige sessie. stopGeneration &+= 1 @@ -296,7 +297,7 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg restoreAudioHint = "" knownVodDurationMs = 0 seekOriginMs = 0 - liveMode = true + liveMode = !forceVod usePipDrawable = false pipPossible = false @@ -312,7 +313,8 @@ final class VLCPlayerController: NSObject, ObservableObject, VLCMediaPlayerDeleg manifestURL: url, keys: clearKeys, preferHlsForAVPlayer: preferHls, - browserCdnHeaders: browserCdnHeaders + browserCdnHeaders: browserCdnHeaders, + forceVod: forceVod ) playURL = started.url proxySession = started.session diff --git a/apps/ios/VMC/UI/SettingsScreen.swift b/apps/ios/VMC/UI/SettingsScreen.swift index 2875027..732f275 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.58") + LabeledContent("Versie", value: "0.5.59") LabeledContent("Live TV", value: app.iptvEnabled ? "Aan" : "Uit") LabeledContent("Events", value: app.eventsEnabled ? "Aan" : "Uit") } diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 60f2072..8b753eb 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -116,6 +116,7 @@ function hasKapWindow(url: string | null | undefined): boolean { type StoredDrmPlay = { keys: Array<{ kid: string; key: string }>; mpdUrl: string | null; + eventMatch?: boolean; }; async function storedDrmPlay( @@ -407,16 +408,18 @@ async function storedXtraChannelPlay( (a, b) => b[1].score - a[1].score || b[1].lastSeen - a[1].lastSeen ); for (const [url, g] of ranked) { - if (g.keys.length) return { keys: g.keys, mpdUrl: url }; + if (g.keys.length) { + return { keys: g.keys, mpdUrl: url, eventMatch: g.score >= 200 }; + } } const salvage = preferLiveChannelUrl([ ...xtraUrls, ...(extraMpdUrls || []), ]); if (guidKeys.length && salvage && isXtraMpd(salvage)) { - return { keys: guidKeys, mpdUrl: salvage }; + return { keys: guidKeys, mpdUrl: salvage, eventMatch: true }; } - return { keys: [], mpdUrl: null }; + return { keys: [], mpdUrl: null, eventMatch: false }; } async function dropEventMpdCache(mediaGuid: string, reason: string) { @@ -490,11 +493,75 @@ export async function ensureViaplayEventReady( } } + // Terugkijken: KAP op de zender-MPD van dit event (xtra als die bij dit guid hoort). + if (ended) { + const xtra = await storedXtraChannelPlay( + sessionSecret, + mediaGuid, + event.title, + settings.config.events.map((e) => e.mpdUrl) + ); + const baseRaw = + (xtra.eventMatch && xtra.mpdUrl) || + preferLiveChannelUrl([stored.mpdUrl, event.mpdUrl, cachedMpd]) || + stored.mpdUrl || + event.mpdUrl || + cachedMpd; + const base = baseRaw ? stripKapWindow(baseRaw) : null; + const keys = xtra.eventMatch && xtra.keys.length ? xtra.keys : cachedKeys; + if (base && keys.length && isReusableLiveMpd(base)) { + try { + const kap = await resolveKapMpd( + base, + event.startMs, + event.endMs, + Math.min(tweaks.httpTimeoutMs, 12_000) + ); + if ( + !isDontGoPlaceholder(kap.mpdUrl) && + !(kap.xml && mpdLooksLikeDontGo(kap.xml)) + ) { + await patchViaplayEventCache(mediaGuid, { + mpdUrl: kap.mpdUrl, + mpdCachedAt: new Date().toISOString(), + hasPssh: kap.hasPssh, + hasKeys: true, + lastPlayError: null, + lastPlayAt: new Date().toISOString(), + }); + recordLivePlay({ + guid: mediaGuid, + ok: true, + title: event.title, + mediaPath: mediaPathOf(kap.mpdUrl), + hasLicense: true, + fromCache: true, + cookies: emptyCookies(), + error: null, + }); + return { + streamUrl: kap.mpdUrl, + format: "dash", + keys, + fromCache: true, + mediaGuid, + live: false, + }; + } + } catch { + /* bestaande catch-up-paden hieronder */ + } + } + } + // Zelfde bron als de DRM-tabel in admin: opgeslagen MPD + KID/KEY. // Alleen opnieuw play-live als die set ontbreekt of Don't go is. if (!opts?.forceRefresh && cachedKeys.length) { let url = ended - ? stored.mpdUrl || event.mpdUrl || cachedMpd + ? preferLiveChannelUrl([stored.mpdUrl, event.mpdUrl, cachedMpd]) || + stored.mpdUrl || + event.mpdUrl || + cachedMpd : preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); if (url && isReusableLiveMpd(url)) { if (!ended) url = stripKapWindow(url);