Play Viaplay catch-up from the KAP window start, not the live edge.

This commit is contained in:
Jos Vooges | STH 2026-09-28 22:33:35 +02:00
parent 721a2d1f2a
commit f35200e0c3
7 changed files with 125 additions and 28 deletions

View file

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

View file

@ -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

View file

@ -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: #"<Period\b([^>]*)>([\s\S]*?)</Period>"#,
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..<overlapEnd {
let offsetInRun = i - absIndex
@ -1934,7 +1948,7 @@ final class ClearKeyDashProxy {
}
}
absIndex = runEnd
if absIndex >= 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,

View file

@ -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 {

View file

@ -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

View file

@ -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")
}

View file

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