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);