Serve live Viaplay as the vpxtra channel MPD, not a TVE KAP window.

This commit is contained in:
Jos Vooges | STH 2026-09-28 22:08:59 +02:00
parent 24913bddf0
commit a01bb70a0a

View file

@ -19,6 +19,7 @@ import {
isDontGoPlaceholder, isDontGoPlaceholder,
isLiveDashMpd, isLiveDashMpd,
mpdLooksLikeDontGo, mpdLooksLikeDontGo,
mpdHasContentProtection,
stripKapWindow, stripKapWindow,
VIAPLAY_USER_AGENT, VIAPLAY_USER_AGENT,
} from "./client"; } from "./client";
@ -147,8 +148,8 @@ async function storedDrmPlay(
continue; continue;
} }
const url = row.mpdUrl?.trim() || null; const url = row.mpdUrl?.trim() || null;
if (!mpdUrl && url && /^https?:\/\//i.test(url) && !isDontGoPlaceholder(url)) { if (url && /^https?:\/\//i.test(url) && !isDontGoPlaceholder(url)) {
mpdUrl = url; mpdUrl = preferLiveChannelUrl([mpdUrl, url]) || mpdUrl || url;
} }
} }
return { keys, mpdUrl }; return { keys, mpdUrl };
@ -227,23 +228,44 @@ function isReusableLiveMpd(url: string | null | undefined): url is string {
return !!u && isLiveDashMpd(u) && !isDontGoPlaceholder(u); return !!u && isLiveDashMpd(u) && !isDontGoPlaceholder(u);
} }
/** TVE/KAP-zender is catch-up; echte live zit op vpxtra (cdn.viaplay.tv/vp/wv/…). */
function isTveSportMpd(url: string | null | undefined): boolean {
return !!url && /\/tve\/|tve_vsport/i.test(url);
}
function preferLiveChannelUrl(urls: Array<string | null | undefined>): string | null {
const cleaned = [
...new Set(
urls
.map((u) => (u ? stripKapWindow(u.trim()) : ""))
.filter((u): u is string => isReusableLiveMpd(u))
),
];
return (
cleaned.find((u) => /\/vp\/wv\/vpxtra|vpxtra\d+|vp-xtra/i.test(u)) ||
cleaned.find((u) => !/\/tve\/|tve_vsport/i.test(u)) ||
cleaned[0] ||
null
);
}
function channelMpdFromEvents( function channelMpdFromEvents(
events: ViaplayEventSnapshot[], events: ViaplayEventSnapshot[],
event: ViaplayEventSnapshot event: ViaplayEventSnapshot
): string | null { ): string | null {
if (isReusableLiveMpd(event.mpdUrl)) return event.mpdUrl.trim();
const channel = event.channelName?.trim().toLowerCase(); const channel = event.channelName?.trim().toLowerCase();
const station = event.stationId?.trim(); const station = event.stationId?.trim();
if (!channel && !station) return null; const candidates: string[] = [];
if (event.mpdUrl) candidates.push(event.mpdUrl);
for (const other of events) { for (const other of events) {
const url = other.mpdUrl?.trim(); const url = other.mpdUrl?.trim();
if (!isReusableLiveMpd(url)) continue; if (!url || !isReusableLiveMpd(stripKapWindow(url))) continue;
if (station && other.stationId?.trim() === station) return stripKapWindow(url); if (station && other.stationId?.trim() === station) candidates.push(url);
if (channel && other.channelName?.trim().toLowerCase() === channel) { else if (channel && other.channelName?.trim().toLowerCase() === channel) {
return stripKapWindow(url); candidates.push(url);
} }
} }
return null; return preferLiveChannelUrl(candidates);
} }
async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<boolean> { async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<boolean> {
@ -258,6 +280,24 @@ async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<bo
} }
} }
async function probeChannelMpd(
url: string,
timeoutMs: number
): Promise<{ mpdUrl: string; xml: string; hasPssh: boolean; pssh: string | null }> {
try {
const mpd = await fetchMpdXml(url, Math.min(timeoutMs, 8_000));
const pssh = extractPsshFromMpd(mpd.xml);
return {
mpdUrl: url,
xml: mpd.xml,
hasPssh: !!pssh || mpdHasContentProtection(mpd.xml),
pssh,
};
} catch {
return { mpdUrl: url, xml: "", hasPssh: false, pssh: null };
}
}
async function dropEventMpdCache(mediaGuid: string, reason: string) { async function dropEventMpdCache(mediaGuid: string, reason: string) {
await patchViaplayEventCache(mediaGuid, { await patchViaplayEventCache(mediaGuid, {
mpdUrl: null, mpdUrl: null,
@ -295,8 +335,11 @@ export async function ensureViaplayEventReady(
// Zelfde bron als de DRM-tabel in admin: opgeslagen MPD + KID/KEY. // 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. // Alleen opnieuw play-live als die set ontbreekt of Don't go is.
if (!opts?.forceRefresh && cachedKeys.length) { if (!opts?.forceRefresh && cachedKeys.length) {
let url = stored.mpdUrl || event.mpdUrl || cachedMpd; let url = ended
? stored.mpdUrl || event.mpdUrl || cachedMpd
: preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]);
if (url && isReusableLiveMpd(url)) { if (url && isReusableLiveMpd(url)) {
if (!ended) url = stripKapWindow(url);
if (ended && !hasKapWindow(url)) { if (ended && !hasKapWindow(url)) {
try { try {
const kap = await resolveKapMpd( const kap = await resolveKapMpd(
@ -310,9 +353,11 @@ export async function ensureViaplayEventReady(
/* gebruik de drm-MPD zoals de losse speler */ /* gebruik de drm-MPD zoals de losse speler */
} }
} }
const skipLiveTve = !ended && isTveSportMpd(url);
const trustStoredKap = ended && hasKapWindow(url); const trustStoredKap = ended && hasKapWindow(url);
const probed = const probed =
trustStoredKap || (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs)); !skipLiveTve &&
(trustStoredKap || (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs)));
if (probed && url) { if (probed && url) {
recordLivePlay({ recordLivePlay({
guid: mediaGuid, guid: mediaGuid,
@ -330,7 +375,7 @@ export async function ensureViaplayEventReady(
keys: cachedKeys, keys: cachedKeys,
fromCache: true, fromCache: true,
mediaGuid, mediaGuid,
live: !ended && !hasKapWindow(url), live: !ended,
}; };
} }
} }
@ -339,30 +384,37 @@ export async function ensureViaplayEventReady(
const cacheFresh = const cacheFresh =
!opts?.forceRefresh && !opts?.forceRefresh &&
!ended && !ended &&
isReusableLiveMpd(event.mpdUrl) && isReusableLiveMpd(stripKapWindow(event.mpdUrl || "")) &&
isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes); isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes);
if (cacheFresh && cachedKeys.length && event.mpdUrl) { if (cacheFresh && cachedKeys.length) {
if (await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs)) { const liveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]);
const useCachedLive = !!liveUrl && !isTveSportMpd(liveUrl);
if (
useCachedLive &&
liveUrl &&
(await liveCachedMpdPlayable(liveUrl, tweaks.httpTimeoutMs))
) {
recordLivePlay({ recordLivePlay({
guid: mediaGuid, guid: mediaGuid,
ok: true, ok: true,
title: event.title, title: event.title,
mediaPath: mediaPathOf(event.mpdUrl), mediaPath: mediaPathOf(liveUrl),
hasLicense: true, hasLicense: true,
fromCache: true, fromCache: true,
cookies: emptyCookies(), cookies: emptyCookies(),
error: null, error: null,
}); });
return { return {
streamUrl: event.mpdUrl, streamUrl: liveUrl,
format: "dash", format: "dash",
keys: cachedKeys, keys: cachedKeys,
fromCache: true, fromCache: true,
mediaGuid, mediaGuid,
live: !ended && !hasKapWindow(event.mpdUrl), live: true,
}; };
} }
if (useCachedLive) {
await dropEventMpdCache( await dropEventMpdCache(
mediaGuid, mediaGuid,
"Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live" "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live"
@ -378,6 +430,7 @@ export async function ensureViaplayEventReady(
error: "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live", error: "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live",
}); });
} }
}
// Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go). // Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go).
if (ended && cachedMpd && !opts?.forceRefresh && isReusableLiveMpd(cachedMpd)) { if (ended && cachedMpd && !opts?.forceRefresh && isReusableLiveMpd(cachedMpd)) {
@ -428,30 +481,31 @@ export async function ensureViaplayEventReady(
} }
// Cache MPD zonder keys is ok als keys later komen — of clear feed // Cache MPD zonder keys is ok als keys later komen — of clear feed
const cacheLiveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]);
if ( if (
cacheFresh && cacheFresh &&
event.mpdUrl && cacheLiveUrl &&
isReusableLiveMpd(event.mpdUrl) && !isTveSportMpd(cacheLiveUrl) &&
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) && !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) &&
(await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs)) (await liveCachedMpdPlayable(cacheLiveUrl, tweaks.httpTimeoutMs))
) { ) {
recordLivePlay({ recordLivePlay({
guid: mediaGuid, guid: mediaGuid,
ok: true, ok: true,
title: event.title, title: event.title,
mediaPath: mediaPathOf(event.mpdUrl), mediaPath: mediaPathOf(cacheLiveUrl),
hasLicense: cachedKeys.length > 0, hasLicense: cachedKeys.length > 0,
fromCache: true, fromCache: true,
cookies: emptyCookies(), cookies: emptyCookies(),
error: null, error: null,
}); });
return { return {
streamUrl: event.mpdUrl, streamUrl: cacheLiveUrl,
format: "dash", format: "dash",
keys: cachedKeys, keys: cachedKeys,
fromCache: true, fromCache: true,
mediaGuid, mediaGuid,
live: !ended && !hasKapWindow(event.mpdUrl), live: true,
}; };
} }
@ -488,24 +542,26 @@ export async function ensureViaplayEventReady(
timeoutMs: tweaks.httpTimeoutMs, timeoutMs: tweaks.httpTimeoutMs,
pageUrl: event.pageUrl, pageUrl: event.pageUrl,
}); });
const kap = isLiveDashMpd(play.mpdUrl) const channelUrl = stripKapWindow(play.mpdUrl);
const liveUrl =
preferLiveChannelUrl([channelUrl, event.mpdUrl, cachedMpd]) || channelUrl;
const kap =
ended && isLiveDashMpd(channelUrl)
? await resolveKapMpd( ? await resolveKapMpd(
play.mpdUrl, channelUrl,
event.startMs, event.startMs,
event.endMs, event.endMs,
Math.min(tweaks.httpTimeoutMs, ended ? 12_000 : tweaks.httpTimeoutMs) Math.min(tweaks.httpTimeoutMs, 12_000)
) )
: { : await probeChannelMpd(
mpdUrl: play.mpdUrl, !ended && isLiveDashMpd(liveUrl) ? liveUrl : play.mpdUrl,
xml: "", tweaks.httpTimeoutMs
hasPssh: false, );
pssh: null as string | null,
};
if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) { if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream"); throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream");
} }
if (!ended && !isLiveDashMpd(play.mpdUrl)) { if (!ended && !isLiveDashMpd(kap.mpdUrl)) {
throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)"); throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)");
} }
if (kap.xml && mpdLooksLikeDontGo(kap.xml)) { if (kap.xml && mpdLooksLikeDontGo(kap.xml)) {
@ -514,7 +570,7 @@ export async function ensureViaplayEventReady(
let keys = cachedKeys; let keys = cachedKeys;
if (!keys.length) { if (!keys.length) {
keys = await keysForChannelPath(sessionSecret, play.mpdUrl); keys = await keysForChannelPath(sessionSecret, kap.mpdUrl);
} }
const wantKeys = opts?.fetchKeys ?? tweaks.fetchKeysIfMissing; const wantKeys = opts?.fetchKeys ?? tweaks.fetchKeysIfMissing;
if (wantKeys && play.licenseUrl && !keys.length) { if (wantKeys && play.licenseUrl && !keys.length) {
@ -591,7 +647,7 @@ export async function ensureViaplayEventReady(
fromCache: false, fromCache: false,
mediaGuid, mediaGuid,
accountId: account.id, accountId: account.id,
live: !ended && !hasKapWindow(kap.mpdUrl), live: !ended,
}; };
} catch (err) { } catch (err) {
lastError = err instanceof Error ? err : new Error(String(err)); lastError = err instanceof Error ? err : new Error(String(err));