Play ended Viaplay events as vod-dash byguid, not an expired live-TV MPD.

This commit is contained in:
Jos Vooges | STH 2026-09-28 23:26:59 +02:00
parent f35200e0c3
commit aa1d733582
3 changed files with 230 additions and 150 deletions

View file

@ -655,9 +655,15 @@ function productToRailItem(node: Record<string, unknown>): ViaplaySportRailItem
else if (type === "series" || type === "sportseries" || type === "episode") kind = "series"; else if (type === "series" || type === "sportseries" || type === "episode") kind = "series";
else if (type === "movie" || type === "film") kind = "series"; // docs/specials → VOD byguid else if (type === "movie" || type === "film") kind = "series"; // docs/specials → VOD byguid
const now = Date.now();
const stillLive =
startMs != null && (endMs == null || endMs > now);
let playMode: ViaplaySportRailItem["playMode"] = null; let playMode: ViaplaySportRailItem["playMode"] = null;
if (href?.includes("play-live.viaplay.com") || (kind === "sport" && startMs != null)) { if (href?.includes("play-live.viaplay.com") || (kind === "sport" && stillLive)) {
playMode = "live"; playMode = "live";
} else if (kind === "sport" && endMs != null && endMs <= now) {
// Afgelopen hoofduitzending: web speelt byguid/vod-dash, niet live-TV.
playMode = "vod";
} else if ( } else if (
href?.includes("play.viaplay.com/api/stream/byguid") || href?.includes("play.viaplay.com/api/stream/byguid") ||
kind === "clip" || kind === "clip" ||
@ -886,14 +892,25 @@ export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && /live-dash/i.test(mpdUrl); return !!mpdUrl && /live-dash/i.test(mpdUrl);
} }
function assertPlayNotSoftFail(played: ViaplayPlayResult): void { /** HAR/web-terugkijken: vod-dash + vp-sports-clip-*.ism — niet live-TV. */
export function isVodDashMpd(mpdUrl: string | null | undefined): boolean {
if (!mpdUrl) return false;
if (/vod-dash/i.test(mpdUrl)) return true;
return /\/vp-sports-clip-/i.test(mpdUrl) && /\.ism\//i.test(mpdUrl);
}
function assertPlayNotSoftFail(
played: ViaplayPlayResult,
opts?: { allowVod?: boolean }
): void {
if (isPromoVodTitle(played.title)) { if (isPromoVodTitle(played.title)) {
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
} }
if (isDontGoPlaceholder(played.mpdUrl)) { if (isDontGoPlaceholder(played.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 (!isLiveDashMpd(played.mpdUrl)) { const vodOk = !!opts?.allowVod && isVodDashMpd(played.mpdUrl);
if (!vodOk && !isLiveDashMpd(played.mpdUrl)) {
throw new Error( throw new Error(
"Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)" "Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)"
); );
@ -930,7 +947,6 @@ export async function playByMediaGuid(
} }
): Promise<ViaplayPlayResult> { ): Promise<ViaplayPlayResult> {
void opts.pageUrl; void opts.pageUrl;
void opts.allowVod;
const mediaGuid = toMediaGuid(mediaGuidRaw); const mediaGuid = toMediaGuid(mediaGuidRaw);
const playUrl = buildPlayLiveUrl(creds, mediaGuid, { deviceKey: opts.deviceKey }); const playUrl = buildPlayLiveUrl(creds, mediaGuid, { deviceKey: opts.deviceKey });
const res = await fetchJson(playUrl, { const res = await fetchJson(playUrl, {
@ -945,7 +961,7 @@ export async function playByMediaGuid(
throw new Error(msg); throw new Error(msg);
} }
const played = parsePlayStreamBody(body, mediaGuid); const played = parsePlayStreamBody(body, mediaGuid);
assertPlayNotSoftFail(played); assertPlayNotSoftFail(played, { allowVod: !!opts.allowVod });
return played; return played;
} }

View file

@ -184,7 +184,10 @@ function isWeekendLocal(ms: number): boolean {
} }
function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem { function railItemToHome(item: ViaplaySportRailItem, nowMs: number): ViaplayHomeItem {
const isEvent = item.kind === "sport" && item.playMode === "live"; const isEvent =
item.kind === "sport" &&
item.playMode === "live" &&
(item.endMs == null || item.endMs > nowMs);
const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content"; const openAs: ViaplayHomeItem["openAs"] = isEvent ? "event" : "content";
const id = const id =
openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`; openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`;

View file

@ -18,9 +18,11 @@ import {
forgetStreamHref, forgetStreamHref,
isDontGoPlaceholder, isDontGoPlaceholder,
isLiveDashMpd, isLiveDashMpd,
isVodDashMpd,
mpdLooksLikeDontGo, mpdLooksLikeDontGo,
mpdHasContentProtection, mpdHasContentProtection,
stripKapWindow, stripKapWindow,
fetchSportRails,
VIAPLAY_USER_AGENT, VIAPLAY_USER_AGENT,
} from "./client"; } from "./client";
import { inventoryFromNames, type CookieInventory } from "./cookies"; import { inventoryFromNames, type CookieInventory } from "./cookies";
@ -109,10 +111,6 @@ function parseKey(value: string): { kid: string; key: string } | null {
return { kid: kid!, key: key! }; return { kid: kid!, key: key! };
} }
function hasKapWindow(url: string | null | undefined): boolean {
return !!url && /\/start\/\d+\/stop\/\d+/.test(url);
}
type StoredDrmPlay = { type StoredDrmPlay = {
keys: Array<{ kid: string; key: string }>; keys: Array<{ kid: string; key: string }>;
mpdUrl: string | null; mpdUrl: string | null;
@ -273,6 +271,35 @@ function channelMpdFromEvents(
return preferLiveChannelUrl(candidates); return preferLiveChannelUrl(candidates);
} }
/**
* Catch-up: alleen vpxtra van dezelfde zender als play-live, nooit de
* MPD van dit event zelf (die kan gestolen zijn door een gedeelde KID).
*/
function xtraMpdForSameChannel(
events: ViaplayEventSnapshot[],
event: ViaplayEventSnapshot,
playChannel: string | null,
playStation: string | null
): string | null {
const channel = (playChannel || event.channelName || "").trim().toLowerCase();
const station = (playStation || event.stationId || "").trim();
if (!channel && !station) return null;
const selfGuid = toMediaGuid(event.mediaGuid || event.guid);
const candidates: string[] = [];
for (const other of events) {
if (toMediaGuid(other.mediaGuid || other.guid) === selfGuid) continue;
const url = other.mpdUrl?.trim();
if (!url) continue;
const clean = stripKapWindow(url);
if (!isXtraMpd(clean) || !isReusableLiveMpd(clean)) continue;
const st = other.stationId?.trim() || "";
const ch = (other.channelName || "").trim().toLowerCase();
if (station && st && st === station) candidates.push(clean);
else if (channel && ch && ch === channel) candidates.push(clean);
}
return preferLiveChannelUrl(candidates);
}
async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<boolean> { async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<boolean> {
if (!isReusableLiveMpd(url)) return false; if (!isReusableLiveMpd(url)) return false;
try { try {
@ -431,6 +458,142 @@ async function dropEventMpdCache(mediaGuid: string, reason: string) {
}); });
} }
function vodGuidCandidates(event: ViaplayEventSnapshot): string[] {
const out: string[] = [];
const seen = new Set<string>();
const push = (raw: string | null | undefined) => {
const g = String(raw || "").trim();
if (!g || seen.has(g)) return;
seen.add(g);
out.push(g);
};
push(event.guid);
push(event.mediaGuid);
for (const g of [...out]) {
if (/-NL$/i.test(g)) push(g.replace(/-NL$/i, ""));
else if (!/vp-sports-clip/i.test(g)) push(`${g}-NL`);
}
return out;
}
function normCatchupTitle(s: string): string {
return s
.toLowerCase()
.replace(
/\b(samenvatting|hoogtepunten|terugblik|terugkijken|highlights|race|kwalificatie)\b/g,
" "
)
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function catchupTitleScore(eventTitle: string, clipTitle: string): number {
const a = normCatchupTitle(eventTitle);
const b = normCatchupTitle(clipTitle);
if (!a || !b) return 0;
if (a === b) return 100;
if (a.includes(b) || b.includes(a)) return Math.min(a.length, b.length) >= 8 ? 80 : 0;
const aw = new Set(a.split(" ").filter((w) => w.length > 2));
const bw = b.split(" ").filter((w) => w.length > 2);
if (!aw.size || !bw.length) return 0;
const hit = bw.filter((w) => aw.has(w)).length;
if (hit >= 3 && hit / bw.length >= 0.5) return 50;
return 0;
}
async function findCatchupClip(
deviceKey: string,
timeoutMs: number,
event: ViaplayEventSnapshot
): Promise<{ guid: string; pageUrl: string | null } | null> {
let rails: Awaited<ReturnType<typeof fetchSportRails>> = [];
try {
rails = await fetchSportRails(deviceKey, Math.min(timeoutMs, 12_000));
} catch {
return null;
}
let best: { guid: string; pageUrl: string | null; score: number } | null = null;
for (const rail of rails) {
for (const item of rail.items) {
if (item.playMode !== "vod") continue;
let score = catchupTitleScore(event.title, item.title);
if (item.startMs != null && event.startMs) {
const delta = Math.abs(item.startMs - event.startMs);
if (delta < 3 * 60 * 60_000) score += 15;
}
if (score < 80) continue;
if (!best || score > best.score) {
best = { guid: item.guid, pageUrl: item.pageUrl, score };
}
}
}
return best;
}
/**
* Terugkijken = VOD (HAR: play.viaplay.com/byguid → vod-dash / vp-sports-clip),
* niet KAP op een live-TV-MPD die al is afgelopen.
*/
async function playEndedEventAsVod(
sessionSecret: string,
event: ViaplayEventSnapshot
): Promise<ResolveViaplayPlayResult> {
const errors: string[] = [];
const guids = vodGuidCandidates(event);
for (const guid of guids) {
try {
const played = await resolveViaplayGuidPlayInner(sessionSecret, guid, {
productUrl: event.pageUrl,
});
if (
isDontGoPlaceholder(played.streamUrl) ||
isLiveDashMpd(played.streamUrl) ||
!isVodDashMpd(played.streamUrl)
) {
errors.push(`${guid}: geen vod-dash (${mediaPathOf(played.streamUrl)})`);
continue;
}
return { ...played, live: false };
} catch (err) {
errors.push(
`${guid}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
const settings = await loadViaplaySettings(sessionSecret);
try {
const clip = await findCatchupClip(
settings.config.deviceKey,
settings.config.httpTimeoutMs,
event
);
if (clip && !guids.includes(clip.guid)) {
const played = await resolveViaplayGuidPlayInner(sessionSecret, clip.guid, {
productUrl: clip.pageUrl,
});
if (
isDontGoPlaceholder(played.streamUrl) ||
isLiveDashMpd(played.streamUrl) ||
!isVodDashMpd(played.streamUrl)
) {
errors.push(`${clip.guid}: clip was Don't go of live-dash`);
} else {
return { ...played, live: false };
}
}
} catch (err) {
errors.push(`clip: ${err instanceof Error ? err.message : String(err)}`);
}
throw new AppError(
"UPSTREAM",
`Geen VOD-replay (vod-dash/vp-sports-clip). ${errors.slice(-3).join(" · ")}`,
502
);
}
export async function ensureViaplayEventReady( export async function ensureViaplayEventReady(
sessionSecret: string, sessionSecret: string,
event: ViaplayEventSnapshot, event: ViaplayEventSnapshot,
@ -445,6 +608,10 @@ export async function ensureViaplayEventReady(
[]; [];
const ended = Date.now() > event.endMs; const ended = Date.now() > event.endMs;
if (ended) {
return await playEndedEventAsVod(sessionSecret, event);
}
const cachedMpd = channelMpdFromEvents(settings.config.events, event); const cachedMpd = channelMpdFromEvents(settings.config.events, event);
const stored = await storedDrmPlay(sessionSecret, mediaGuid); const stored = await storedDrmPlay(sessionSecret, mediaGuid);
@ -493,96 +660,17 @@ export async function ensureViaplayEventReady(
} }
} }
// Terugkijken: KAP op de zender-MPD van dit event (xtra als die bij dit guid hoort). // Terugkijken: altijd verse play-live + KAP van DIT programma.
if (ended) { // Niet de vpxtra van een ander live-event (anders: Switch Dames → mannenvoetbal).
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. // 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 (!ended && !opts?.forceRefresh && cachedKeys.length) {
let url = ended let url = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]);
? preferLiveChannelUrl([stored.mpdUrl, event.mpdUrl, cachedMpd]) ||
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) url = stripKapWindow(url);
if (ended && !hasKapWindow(url)) { const skipLiveTve = isTveSportMpd(url);
try { const probed = !skipLiveTve && (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs));
const kap = await resolveKapMpd(
url,
event.startMs,
event.endMs,
Math.min(tweaks.httpTimeoutMs, 12_000)
);
if (isReusableLiveMpd(kap.mpdUrl)) url = kap.mpdUrl;
} catch {
/* gebruik de drm-MPD zoals de losse speler */
}
}
const skipLiveTve = !ended && isTveSportMpd(url);
const trustStoredKap = ended && hasKapWindow(url);
const probed =
!skipLiveTve &&
(trustStoredKap || (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs)));
if (probed && url) { if (probed && url) {
recordLivePlay({ recordLivePlay({
guid: mediaGuid, guid: mediaGuid,
@ -657,54 +745,6 @@ export async function ensureViaplayEventReady(
} }
} }
// Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go).
if (ended && cachedMpd && !opts?.forceRefresh && isReusableLiveMpd(cachedMpd)) {
try {
const kap = await resolveKapMpd(
cachedMpd,
event.startMs,
event.endMs,
Math.min(tweaks.httpTimeoutMs, 12_000)
);
if (isDontGoPlaceholder(kap.mpdUrl) || !(await liveCachedMpdPlayable(kap.mpdUrl, tweaks.httpTimeoutMs))) {
throw new Error("KAP-MPD is Don't go of ongeldig");
}
if (!cachedKeys.length) {
cachedKeys = await keysForChannelPath(sessionSecret, kap.mpdUrl);
}
if (cachedKeys.length) {
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: cachedKeys,
fromCache: true,
mediaGuid,
live: false,
};
}
} catch {
/* play-live hieronder */
}
}
// 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]); const cacheLiveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]);
if ( if (
@ -770,10 +810,24 @@ export async function ensureViaplayEventReady(
const channelUrl = stripKapWindow(play.mpdUrl); const channelUrl = stripKapWindow(play.mpdUrl);
const liveUrl = const liveUrl =
preferLiveChannelUrl([channelUrl, event.mpdUrl, cachedMpd]) || channelUrl; preferLiveChannelUrl([channelUrl, event.mpdUrl, cachedMpd]) || channelUrl;
// Terugkijken: KAP op de play-live van DIT guid. TVE → hooguit xtra
// van dezelfde zender, nooit vpxtra van een ander live-event.
let kapBase = channelUrl;
if (ended) {
if (isTveSportMpd(channelUrl) || !isXtraMpd(channelUrl)) {
const same = xtraMpdForSameChannel(
settings.config.events,
event,
play.channelName,
play.stationId
);
if (same) kapBase = same;
}
}
const kap = const kap =
ended && isLiveDashMpd(channelUrl) ended && isLiveDashMpd(kapBase)
? await resolveKapMpd( ? await resolveKapMpd(
channelUrl, kapBase,
event.startMs, event.startMs,
event.endMs, event.endMs,
Math.min(tweaks.httpTimeoutMs, 12_000) Math.min(tweaks.httpTimeoutMs, 12_000)
@ -798,9 +852,13 @@ export async function ensureViaplayEventReady(
throw new Error("Viaplay MPD is Don't go-placeholder (duur/media mismatch)"); throw new Error("Viaplay MPD is Don't go-placeholder (duur/media mismatch)");
} }
let keys = cachedKeys; let keys = ended
? await keysForChannelPath(sessionSecret, kap.mpdUrl)
: cachedKeys;
if (!keys.length) { if (!keys.length) {
keys = await keysForChannelPath(sessionSecret, kap.mpdUrl); keys = ended
? cachedKeys
: 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) {
@ -826,12 +884,15 @@ export async function ensureViaplayEventReady(
await upsertDrmKey(prisma, sessionSecret, { await upsertDrmKey(prisma, sessionSecret, {
kid: k.kid, kid: k.kid,
key: k.key, key: k.key,
mpdUrl: isTveSportMpd(kap.mpdUrl) ? null : kap.mpdUrl, // Catch-up mag geen live-vpxtra van een gedeelde KID overschrijven.
eventName: event.title, mpdUrl:
ended || isTveSportMpd(kap.mpdUrl) ? undefined : kap.mpdUrl,
eventName: ended ? undefined : event.title,
channelName: play.channelName, channelName: play.channelName,
source: "viaplay", source: "viaplay",
externalId: event.guid, externalId: event.guid,
mediaGuid, mediaGuid:
ended || isTveSportMpd(kap.mpdUrl) ? undefined : mediaGuid,
}); });
} catch { } catch {
/* DRM-db mag play niet breken */ /* DRM-db mag play niet breken */