diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 3b941fc..ed59b7e 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -655,9 +655,15 @@ function productToRailItem(node: Record): ViaplaySportRailItem else if (type === "series" || type === "sportseries" || type === "episode") kind = "series"; 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; - if (href?.includes("play-live.viaplay.com") || (kind === "sport" && startMs != null)) { + if (href?.includes("play-live.viaplay.com") || (kind === "sport" && stillLive)) { playMode = "live"; + } else if (kind === "sport" && endMs != null && endMs <= now) { + // Afgelopen hoofduitzending: web speelt byguid/vod-dash, niet live-TV. + playMode = "vod"; } else if ( href?.includes("play.viaplay.com/api/stream/byguid") || kind === "clip" || @@ -886,14 +892,25 @@ export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean { 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)) { throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); } if (isDontGoPlaceholder(played.mpdUrl)) { 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( "Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)" ); @@ -930,7 +947,6 @@ export async function playByMediaGuid( } ): Promise { void opts.pageUrl; - void opts.allowVod; const mediaGuid = toMediaGuid(mediaGuidRaw); const playUrl = buildPlayLiveUrl(creds, mediaGuid, { deviceKey: opts.deviceKey }); const res = await fetchJson(playUrl, { @@ -945,7 +961,7 @@ export async function playByMediaGuid( throw new Error(msg); } const played = parsePlayStreamBody(body, mediaGuid); - assertPlayNotSoftFail(played); + assertPlayNotSoftFail(played, { allowVod: !!opts.allowVod }); return played; } diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index 0361c45..9b4b7a9 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -184,7 +184,10 @@ function isWeekendLocal(ms: number): boolean { } 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 id = openAs === "event" ? toEventId(toMediaGuid(item.guid)) : `viaplay-content:${item.guid}`; diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 8b753eb..d1d3665 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -18,9 +18,11 @@ import { forgetStreamHref, isDontGoPlaceholder, isLiveDashMpd, + isVodDashMpd, mpdLooksLikeDontGo, mpdHasContentProtection, stripKapWindow, + fetchSportRails, VIAPLAY_USER_AGENT, } from "./client"; import { inventoryFromNames, type CookieInventory } from "./cookies"; @@ -109,10 +111,6 @@ function parseKey(value: string): { kid: string; key: string } | null { return { kid: kid!, key: key! }; } -function hasKapWindow(url: string | null | undefined): boolean { - return !!url && /\/start\/\d+\/stop\/\d+/.test(url); -} - type StoredDrmPlay = { keys: Array<{ kid: string; key: string }>; mpdUrl: string | null; @@ -273,6 +271,35 @@ function channelMpdFromEvents( 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 { if (!isReusableLiveMpd(url)) return false; try { @@ -431,6 +458,142 @@ async function dropEventMpdCache(mediaGuid: string, reason: string) { }); } +function vodGuidCandidates(event: ViaplayEventSnapshot): string[] { + const out: string[] = []; + const seen = new Set(); + 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> = []; + 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 { + 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( sessionSecret: string, event: ViaplayEventSnapshot, @@ -445,6 +608,10 @@ export async function ensureViaplayEventReady( []; const ended = Date.now() > event.endMs; + if (ended) { + return await playEndedEventAsVod(sessionSecret, event); + } + const cachedMpd = channelMpdFromEvents(settings.config.events, event); 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). - if (ended) { - 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 */ - } - } - } + // Terugkijken: altijd verse play-live + KAP van DIT programma. + // Niet de vpxtra van een ander live-event (anders: Switch Dames → mannenvoetbal). // 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 = ended - ? preferLiveChannelUrl([stored.mpdUrl, event.mpdUrl, cachedMpd]) || - stored.mpdUrl || - event.mpdUrl || - cachedMpd - : preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); + if (!ended && !opts?.forceRefresh && cachedKeys.length) { + let url = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); if (url && isReusableLiveMpd(url)) { if (!ended) url = stripKapWindow(url); - if (ended && !hasKapWindow(url)) { - try { - 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))); + const skipLiveTve = isTveSportMpd(url); + const probed = !skipLiveTve && (await liveCachedMpdPlayable(url, tweaks.httpTimeoutMs)); if (probed && url) { recordLivePlay({ 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 const cacheLiveUrl = preferLiveChannelUrl([event.mpdUrl, cachedMpd, stored.mpdUrl]); if ( @@ -770,10 +810,24 @@ export async function ensureViaplayEventReady( const channelUrl = stripKapWindow(play.mpdUrl); const liveUrl = 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 = - ended && isLiveDashMpd(channelUrl) + ended && isLiveDashMpd(kapBase) ? await resolveKapMpd( - channelUrl, + kapBase, event.startMs, event.endMs, 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)"); } - let keys = cachedKeys; + let keys = ended + ? await keysForChannelPath(sessionSecret, kap.mpdUrl) + : cachedKeys; if (!keys.length) { - keys = await keysForChannelPath(sessionSecret, kap.mpdUrl); + keys = ended + ? cachedKeys + : await keysForChannelPath(sessionSecret, kap.mpdUrl); } const wantKeys = opts?.fetchKeys ?? tweaks.fetchKeysIfMissing; if (wantKeys && play.licenseUrl && !keys.length) { @@ -826,12 +884,15 @@ export async function ensureViaplayEventReady( await upsertDrmKey(prisma, sessionSecret, { kid: k.kid, key: k.key, - mpdUrl: isTveSportMpd(kap.mpdUrl) ? null : kap.mpdUrl, - eventName: event.title, + // Catch-up mag geen live-vpxtra van een gedeelde KID overschrijven. + mpdUrl: + ended || isTveSportMpd(kap.mpdUrl) ? undefined : kap.mpdUrl, + eventName: ended ? undefined : event.title, channelName: play.channelName, source: "viaplay", externalId: event.guid, - mediaGuid, + mediaGuid: + ended || isTveSportMpd(kap.mpdUrl) ? undefined : mediaGuid, }); } catch { /* DRM-db mag play niet breken */