diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index e83f7e5..60f2072 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -125,13 +125,13 @@ async function storedDrmPlay( const rows = await prisma.drmKey.findMany({ where: { mediaGuid, source: "viaplay" }, orderBy: { lastSeenAt: "desc" }, - take: 8, + take: 24, }); if (!rows.length) { const alt = await prisma.drmKey.findMany({ where: { mediaGuid }, orderBy: { lastSeenAt: "desc" }, - take: 8, + take: 24, }); rows.push(...alt); } @@ -228,11 +228,15 @@ function isReusableLiveMpd(url: string | null | undefined): url is string { 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 { 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 { const cleaned = [ ...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 +): Promise { + 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(); + const guidKeys: Array<{ kid: string; key: string }> = []; + const seenGuid = new Set(); + 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) { await patchViaplayEventCache(mediaGuid, { 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. // Alleen opnieuw play-live als die set ontbreekt of Don't go is. if (!opts?.forceRefresh && cachedKeys.length) { @@ -561,6 +719,11 @@ export async function ensureViaplayEventReady( if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) { 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)) { 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, { kid: k.kid, key: k.key, - mpdUrl: kap.mpdUrl, + mpdUrl: isTveSportMpd(kap.mpdUrl) ? null : kap.mpdUrl, eventName: event.title, channelName: play.channelName, source: "viaplay",