diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index c15c9d1..e83f7e5 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -19,6 +19,7 @@ import { isDontGoPlaceholder, isLiveDashMpd, mpdLooksLikeDontGo, + mpdHasContentProtection, stripKapWindow, VIAPLAY_USER_AGENT, } from "./client"; @@ -147,8 +148,8 @@ async function storedDrmPlay( continue; } const url = row.mpdUrl?.trim() || null; - if (!mpdUrl && url && /^https?:\/\//i.test(url) && !isDontGoPlaceholder(url)) { - mpdUrl = url; + if (url && /^https?:\/\//i.test(url) && !isDontGoPlaceholder(url)) { + mpdUrl = preferLiveChannelUrl([mpdUrl, url]) || mpdUrl || url; } } return { keys, mpdUrl }; @@ -227,23 +228,44 @@ 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/…). */ +function isTveSportMpd(url: string | null | undefined): boolean { + return !!url && /\/tve\/|tve_vsport/i.test(url); +} + +function preferLiveChannelUrl(urls: Array): 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( events: ViaplayEventSnapshot[], event: ViaplayEventSnapshot ): string | null { - if (isReusableLiveMpd(event.mpdUrl)) return event.mpdUrl.trim(); const channel = event.channelName?.trim().toLowerCase(); 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) { const url = other.mpdUrl?.trim(); - if (!isReusableLiveMpd(url)) continue; - if (station && other.stationId?.trim() === station) return stripKapWindow(url); - if (channel && other.channelName?.trim().toLowerCase() === channel) { - return stripKapWindow(url); + if (!url || !isReusableLiveMpd(stripKapWindow(url))) continue; + if (station && other.stationId?.trim() === station) candidates.push(url); + else if (channel && other.channelName?.trim().toLowerCase() === channel) { + candidates.push(url); } } - return null; + return preferLiveChannelUrl(candidates); } async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise { @@ -258,6 +280,24 @@ async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise { + 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) { await patchViaplayEventCache(mediaGuid, { mpdUrl: null, @@ -295,8 +335,11 @@ export async function ensureViaplayEventReady( // 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) { - 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 (!ended) url = stripKapWindow(url); if (ended && !hasKapWindow(url)) { try { const kap = await resolveKapMpd( @@ -310,9 +353,11 @@ export async function ensureViaplayEventReady( /* gebruik de drm-MPD zoals de losse speler */ } } + const skipLiveTve = !ended && isTveSportMpd(url); const trustStoredKap = ended && hasKapWindow(url); const probed = - trustStoredKap || (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs)); + !skipLiveTve && + (trustStoredKap || (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs))); if (probed && url) { recordLivePlay({ guid: mediaGuid, @@ -330,7 +375,7 @@ export async function ensureViaplayEventReady( keys: cachedKeys, fromCache: true, mediaGuid, - live: !ended && !hasKapWindow(url), + live: !ended, }; } } @@ -339,44 +384,52 @@ export async function ensureViaplayEventReady( const cacheFresh = !opts?.forceRefresh && !ended && - isReusableLiveMpd(event.mpdUrl) && + isReusableLiveMpd(stripKapWindow(event.mpdUrl || "")) && isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes); - if (cacheFresh && cachedKeys.length && event.mpdUrl) { - if (await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs)) { + if (cacheFresh && cachedKeys.length) { + const liveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); + const useCachedLive = !!liveUrl && !isTveSportMpd(liveUrl); + if ( + useCachedLive && + liveUrl && + (await liveCachedMpdPlayable(liveUrl, tweaks.httpTimeoutMs)) + ) { recordLivePlay({ guid: mediaGuid, ok: true, title: event.title, - mediaPath: mediaPathOf(event.mpdUrl), + mediaPath: mediaPathOf(liveUrl), hasLicense: true, fromCache: true, cookies: emptyCookies(), error: null, }); return { - streamUrl: event.mpdUrl, + streamUrl: liveUrl, format: "dash", keys: cachedKeys, fromCache: true, mediaGuid, - live: !ended && !hasKapWindow(event.mpdUrl), + live: true, }; } - await dropEventMpdCache( - mediaGuid, - "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live" - ); - recordLivePlay({ - guid: mediaGuid, - ok: false, - title: event.title, - mediaPath: mediaPathOf(event.mpdUrl), - fromCache: true, - dontGo: true, - cookies: emptyCookies(), - error: "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live", - }); + if (useCachedLive) { + await dropEventMpdCache( + mediaGuid, + "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live" + ); + recordLivePlay({ + guid: mediaGuid, + ok: false, + title: event.title, + mediaPath: mediaPathOf(event.mpdUrl), + fromCache: true, + dontGo: true, + cookies: emptyCookies(), + 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). @@ -428,30 +481,31 @@ export async function ensureViaplayEventReady( } // Cache MPD zonder keys is ok als keys later komen — of clear feed + const cacheLiveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); if ( cacheFresh && - event.mpdUrl && - isReusableLiveMpd(event.mpdUrl) && + cacheLiveUrl && + !isTveSportMpd(cacheLiveUrl) && !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) && - (await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs)) + (await liveCachedMpdPlayable(cacheLiveUrl, tweaks.httpTimeoutMs)) ) { recordLivePlay({ guid: mediaGuid, ok: true, title: event.title, - mediaPath: mediaPathOf(event.mpdUrl), + mediaPath: mediaPathOf(cacheLiveUrl), hasLicense: cachedKeys.length > 0, fromCache: true, cookies: emptyCookies(), error: null, }); return { - streamUrl: event.mpdUrl, + streamUrl: cacheLiveUrl, format: "dash", keys: cachedKeys, fromCache: true, mediaGuid, - live: !ended && !hasKapWindow(event.mpdUrl), + live: true, }; } @@ -488,24 +542,26 @@ export async function ensureViaplayEventReady( timeoutMs: tweaks.httpTimeoutMs, pageUrl: event.pageUrl, }); - const kap = isLiveDashMpd(play.mpdUrl) - ? await resolveKapMpd( - play.mpdUrl, - event.startMs, - event.endMs, - Math.min(tweaks.httpTimeoutMs, ended ? 12_000 : tweaks.httpTimeoutMs) - ) - : { - mpdUrl: play.mpdUrl, - xml: "", - hasPssh: false, - pssh: null as string | null, - }; + const channelUrl = stripKapWindow(play.mpdUrl); + const liveUrl = + preferLiveChannelUrl([channelUrl, event.mpdUrl, cachedMpd]) || channelUrl; + const kap = + ended && isLiveDashMpd(channelUrl) + ? await resolveKapMpd( + channelUrl, + event.startMs, + event.endMs, + Math.min(tweaks.httpTimeoutMs, 12_000) + ) + : await probeChannelMpd( + !ended && isLiveDashMpd(liveUrl) ? liveUrl : play.mpdUrl, + tweaks.httpTimeoutMs + ); if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) { 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)"); } if (kap.xml && mpdLooksLikeDontGo(kap.xml)) { @@ -514,7 +570,7 @@ export async function ensureViaplayEventReady( let keys = cachedKeys; if (!keys.length) { - keys = await keysForChannelPath(sessionSecret, play.mpdUrl); + keys = await keysForChannelPath(sessionSecret, kap.mpdUrl); } const wantKeys = opts?.fetchKeys ?? tweaks.fetchKeysIfMissing; if (wantKeys && play.licenseUrl && !keys.length) { @@ -591,7 +647,7 @@ export async function ensureViaplayEventReady( fromCache: false, mediaGuid, accountId: account.id, - live: !ended && !hasKapWindow(kap.mpdUrl), + live: !ended, }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err));