Serve live Viaplay from DRM vpxtra MPD, never play-live TVE.
This commit is contained in:
parent
a01bb70a0a
commit
721a2d1f2a
1 changed files with 167 additions and 4 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue