Serve live Viaplay from DRM vpxtra MPD, never play-live TVE.

This commit is contained in:
Jos Vooges | STH 2026-09-28 22:20:22 +02:00
parent a01bb70a0a
commit 721a2d1f2a

View file

@ -125,13 +125,13 @@ async function storedDrmPlay(
const rows = await prisma.drmKey.findMany({ const rows = await prisma.drmKey.findMany({
where: { mediaGuid, source: "viaplay" }, where: { mediaGuid, source: "viaplay" },
orderBy: { lastSeenAt: "desc" }, orderBy: { lastSeenAt: "desc" },
take: 8, take: 24,
}); });
if (!rows.length) { if (!rows.length) {
const alt = await prisma.drmKey.findMany({ const alt = await prisma.drmKey.findMany({
where: { mediaGuid }, where: { mediaGuid },
orderBy: { lastSeenAt: "desc" }, orderBy: { lastSeenAt: "desc" },
take: 8, take: 24,
}); });
rows.push(...alt); rows.push(...alt);
} }
@ -228,11 +228,15 @@ 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/…). */ /** TVE is de play-live-placeholder (Don't go). Echte live zit op vpxtra. */
function isTveSportMpd(url: string | null | undefined): boolean { function isTveSportMpd(url: string | null | undefined): boolean {
return !!url && /\/tve\/|tve_vsport/i.test(url); return !!url && /\/tve\/|tve_vsport/i.test(url);
} }
function isXtraMpd(url: string | null | undefined): boolean {
return !!url && /\/vp\/wv\/vpxtra|vpxtra\d+|vp-xtra/i.test(url);
}
function preferLiveChannelUrl(urls: Array<string | null | undefined>): string | null { function preferLiveChannelUrl(urls: Array<string | null | undefined>): string | null {
const cleaned = [ const cleaned = [
...new Set( ...new Set(
@ -298,6 +302,123 @@ async function probeChannelMpd(
} }
} }
/**
* Live: zelfde bron als de losse DRM-speler (vpxtra + keys), niet play-live/TVE.
*/
async function storedXtraChannelPlay(
sessionSecret: string,
mediaGuid: string,
eventTitle?: string | null,
extraMpdUrls?: Array<string | null | undefined>
): Promise<StoredDrmPlay> {
const { decryptSecret } = await import("../security/crypto");
const drmRows = await prisma.drmKey.findMany({
where: {
OR: [
{ mediaGuid },
{ mpdUrl: { contains: "vpxtra", mode: "insensitive" } },
{ mpdUrl: { contains: "vp-xtra", mode: "insensitive" } },
{ mpdUrl: { contains: "/vp/wv/", mode: "insensitive" } },
],
},
orderBy: { lastSeenAt: "desc" },
take: 80,
});
const channels = await prisma.liveChannel.findMany({
where: {
enabled: true,
OR: [
{ mpdUrl: { contains: "vpxtra", mode: "insensitive" } },
{ streamUrlCache: { contains: "vpxtra", mode: "insensitive" } },
],
},
take: 40,
});
type Group = {
keys: Array<{ kid: string; key: string }>;
score: number;
lastSeen: number;
};
const groups = new Map<string, Group>();
const guidKeys: Array<{ kid: string; key: string }> = [];
const seenGuid = new Set<string>();
const xtraUrls: string[] = [];
const titleHint = (eventTitle || "").trim().toLowerCase().slice(0, 18);
const addXtra = (
urlRaw: string | null | undefined,
kid: string,
key: string,
score: number,
lastSeen: number
) => {
const url = urlRaw ? stripKapWindow(urlRaw.trim()) : "";
if (!isXtraMpd(url) || !isReusableLiveMpd(url)) return;
xtraUrls.push(url);
const g = groups.get(url) || { keys: [], score: 0, lastSeen: 0 };
if (!g.keys.some((k) => k.kid === kid)) g.keys.push({ kid, key });
g.score += score;
g.lastSeen = Math.max(g.lastSeen, lastSeen);
groups.set(url, g);
};
for (const row of drmRows) {
let key: string;
try {
key = decryptSecret(row.keyEnc, sessionSecret);
} catch {
continue;
}
if (row.mediaGuid === mediaGuid && !seenGuid.has(row.kid)) {
seenGuid.add(row.kid);
guidKeys.push({ kid: row.kid, key });
}
let score = 1;
if (row.mediaGuid === mediaGuid) score += 1000;
const name = (row.eventName || "").toLowerCase();
if (titleHint && name.includes(titleHint)) score += 200;
addXtra(row.mpdUrl, row.kid, key, score, row.lastSeenAt.getTime());
}
for (const ch of channels) {
const url = ch.streamUrlCache || ch.mpdUrl;
if (!ch.kidEnc || !ch.keyEnc) {
const dummy = preferLiveChannelUrl([url]);
if (dummy) xtraUrls.push(dummy);
continue;
}
try {
const kid = decryptSecret(ch.kidEnc, sessionSecret)
.replace(/-/g, "")
.toLowerCase();
const key = decryptSecret(ch.keyEnc, sessionSecret)
.replace(/-/g, "")
.toLowerCase();
if (!/^[0-9a-f]{32}$/.test(kid) || !/^[0-9a-f]{32}$/.test(key)) continue;
addXtra(url, kid, key, 10, 0);
} catch {
const dummy = preferLiveChannelUrl([url]);
if (dummy) xtraUrls.push(dummy);
}
}
const ranked = [...groups.entries()].sort(
(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 };
}
const salvage = preferLiveChannelUrl([
...xtraUrls,
...(extraMpdUrls || []),
]);
if (guidKeys.length && salvage && isXtraMpd(salvage)) {
return { keys: guidKeys, mpdUrl: salvage };
}
return { keys: [], mpdUrl: 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,
@ -332,6 +453,43 @@ export async function ensureViaplayEventReady(
); );
} }
// Live: DRM-tabel / live-lijst (vpxtra), nooit play-live TVE (Don't go).
if (!ended) {
const xtra = await storedXtraChannelPlay(
sessionSecret,
mediaGuid,
event.title,
settings.config.events.map((e) => e.mpdUrl)
);
if (xtra.keys.length && xtra.mpdUrl) {
await patchViaplayEventCache(mediaGuid, {
mpdUrl: xtra.mpdUrl,
mpdCachedAt: new Date().toISOString(),
hasKeys: true,
lastPlayError: null,
lastPlayAt: new Date().toISOString(),
});
recordLivePlay({
guid: mediaGuid,
ok: true,
title: event.title,
mediaPath: mediaPathOf(xtra.mpdUrl),
hasLicense: true,
fromCache: true,
cookies: emptyCookies(),
error: null,
});
return {
streamUrl: xtra.mpdUrl,
format: "dash",
keys: xtra.keys,
fromCache: true,
mediaGuid,
live: true,
};
}
}
// 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) {
@ -561,6 +719,11 @@ export async function ensureViaplayEventReady(
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 && (isTveSportMpd(play.mpdUrl) || isTveSportMpd(kap.mpdUrl))) {
throw new Error(
"Viaplay play-live gaf TVE-placeholder (tve_vsport) i.p.v. de Xtra-livestream"
);
}
if (!ended && !isLiveDashMpd(kap.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)");
} }
@ -596,7 +759,7 @@ 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: kap.mpdUrl, mpdUrl: isTveSportMpd(kap.mpdUrl) ? null : kap.mpdUrl,
eventName: event.title, eventName: event.title,
channelName: play.channelName, channelName: play.channelName,
source: "viaplay", source: "viaplay",