From 672d3597c647193c2127bbe5c32bbc863db5eac6 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 04:11:22 +0200 Subject: [PATCH] Align Viaplay play-live and clips with website HAR. --- apps/master-api/src/viaplay/client.ts | 115 ++++++++++-------- apps/master-api/src/viaplay/events.ts | 6 +- apps/master-api/src/viaplay/play.ts | 95 +++++++++++---- apps/master-api/src/viewer/routes.ts | 3 +- apps/master-api/src/viewer/schedule-events.ts | 1 + 5 files changed, 147 insertions(+), 73 deletions(-) diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index ba2b7d3..69b8e6f 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -766,12 +766,55 @@ function extractProducts(json: unknown): ViaplayEpgProduct[] { return out; } +/** HAR: Don't go-placeholder (live soft-fail). Clips mogen dit pad wél hebben. */ +export const VIAPLAY_DONT_GO_MEDIA_RE = /\/7c4cd64\/88000100-1778484912807\.ism\b/i; + +export function isDontGoPlaceholder(mpdUrl: string | null | undefined): boolean { + return !!mpdUrl && VIAPLAY_DONT_GO_MEDIA_RE.test(mpdUrl); +} + +export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean { + return !!mpdUrl && /live-dash/i.test(mpdUrl); +} + +function sportContentUrls( + pageUrl: string | null | undefined, + deviceKey: string, + mediaGuid: string +): { productUrl: string; contextualUrl: string; contextualv2Url: string } { + const guidPath = mediaGuid.replace(/-NL$/i, "").toLowerCase(); + let clean = ""; + const raw = (pageUrl || "").trim(); + if (raw) { + try { + const u = new URL(raw); + u.search = ""; + clean = u.toString().replace(/\/$/, ""); + } catch { + clean = ""; + } + } + if (!clean) { + clean = `https://content.viaplay.com/${deviceKey}/sport/${guidPath}`; + } + return { + productUrl: `${clean}?partial=true`, + contextualUrl: `${clean}?contextual=true`, + contextualv2Url: `${clean}?contextualV2=true&productType=sport`, + }; +} + export async function playByMediaGuid( creds: ViaplayAccountCreds, mediaGuidRaw: string, - opts: { deviceKey: string; timeoutMs: number } + opts: { deviceKey: string; timeoutMs: number; pageUrl?: string | null } ): Promise { const mediaGuid = toMediaGuid(mediaGuidRaw); + const pages = sportContentUrls(opts.pageUrl, opts.deviceKey, mediaGuid); + const returnUrl = new URL(`https://content.viaplay.com/${opts.deviceKey}`); + returnUrl.searchParams.set("language", "nl"); + if (creds.profileId) returnUrl.searchParams.set("profileId", creds.profileId); + const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid"); u.searchParams.set("deviceId", creds.deviceId || randomUUID()); u.searchParams.set("deviceName", "web"); @@ -779,7 +822,19 @@ export async function playByMediaGuid( u.searchParams.set("userAgent", VIAPLAY_USER_AGENT); u.searchParams.set("deviceKey", opts.deviceKey); u.searchParams.set("mediaGuid", mediaGuid); + u.searchParams.set("cse", "true"); u.searchParams.set("nocc", "autoplayer"); + u.searchParams.set( + "splitIOFlagIds", + "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on" + ); + u.searchParams.set("returnurl", returnUrl.toString()); + u.searchParams.set("producturl", pages.productUrl); + u.searchParams.set("contextualurl", pages.contextualUrl); + u.searchParams.set("contextualv2url", pages.contextualv2Url); + u.searchParams.set("sectionPath", "/sport"); + u.searchParams.set("defaultAvailabilityContext", "svod"); + u.searchParams.set("win10edge", "true"); if (creds.profileId) u.searchParams.set("profileId", creds.profileId); const res = await fetchJson(u.toString(), { @@ -793,7 +848,13 @@ export async function playByMediaGuid( `play-live mislukt (${res.status})`; throw new Error(msg); } - return parsePlayStreamBody(body, mediaGuid); + const played = parsePlayStreamBody(body, mediaGuid); + if (isDontGoPlaceholder(played.mpdUrl)) { + throw new Error( + "Viaplay play-live gaf Don't go-placeholder i.p.v. de wedstrijdstream" + ); + } + return played; } function parsePlayStreamBody( @@ -907,42 +968,9 @@ function playBodyGuid(body: Record): string | null { return typeof system?.guid === "string" ? system.guid.trim() : null; } -/** Bekende Don't go soft-fail CDN-asset (titel/guid kloppen, media niet). */ -const VIAPLAY_DONT_GO_MEDIA_RE = /\/7c4cd64\/88000100-1778484912807\.ism\b/i; - -function cseMediaGuid(body: Record): string | null { - try { - const cse = asRecord(body.cseReporting); - const link = asRecord(asRecord(cse?.link)?.reportingUrl); - const href = typeof link?.href === "string" ? link.href : ""; - const m = href.match(/\/viaplay\/([^/$]+)/); - if (!m?.[1]) return null; - const payload = JSON.parse( - Buffer.from(m[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString() - ) as { mediaGuid?: string }; - return typeof payload.mediaGuid === "string" ? payload.mediaGuid : null; - } catch { - return null; - } -} - -function mediaHrefFromPlayBody(body: Record): string | null { - const links = asRecord(body._links); - for (const key of [ - "viaplay:media", - "viaplay:playlist", - "viaplay:encryptedPlaylist", - ] as const) { - const href = asRecord(links?.[key])?.href; - if (typeof href === "string" && href.trim()) return href.trim(); - } - return null; -} - /** - * HAR-success: guid + titel + product-link + echte media (geen Don't go soft-fail). - * Soft-fail: 200 met juiste product-metadata maar placeholder-MPD “Don't go” - * (vaak bij max devices / geen stream-recht). + * HAR afspelen.har: web speelt clip als guid+titel kloppen, ook zonder DRM + * en met cse mediaGuid=unknown. Alleen een promo-titel is een echte soft-fail. */ function assertVodPlayMatchesGuid( body: Record, @@ -964,19 +992,6 @@ function assertVodPlayMatchesGuid( throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); } - const mediaHref = mediaHrefFromPlayBody(body) || played.mpdUrl; - if (VIAPLAY_DONT_GO_MEDIA_RE.test(mediaHref)) { - throw new Error( - "Viaplay soft-fail: Don't go-placeholder i.p.v. echte stream (mediaGuid ontbreekt — geen speelrecht of account/stream-fout)" - ); - } - const streamMediaGuid = cseMediaGuid(body); - if (streamMediaGuid === "unknown") { - throw new Error( - "Viaplay soft-fail: mediaGuid=unknown (geen echte stream voor dit account)" - ); - } - const links = asRecord(body._links); const productHref = asRecord(links?.["viaplay:product"])?.href; if (typeof productHref !== "string" || !productHref.trim()) { diff --git a/apps/master-api/src/viaplay/events.ts b/apps/master-api/src/viaplay/events.ts index 1102d4e..0361c45 100644 --- a/apps/master-api/src/viaplay/events.ts +++ b/apps/master-api/src/viaplay/events.ts @@ -90,7 +90,11 @@ export async function getViaplayScheduleEvent( if (!mediaGuid) return null; const config = await loadViaplayConfigOnly(); const event = config?.events.find( - (e) => e.mediaGuid === mediaGuid || e.guid === mediaGuid + (e) => + e.mediaGuid === mediaGuid || + e.guid === mediaGuid || + e.mediaGuid === `${mediaGuid}-NL` || + e.mediaGuid.replace(/-NL$/i, "") === mediaGuid.replace(/-NL$/i, "") ); if (!event) return null; return eventToPublic(event, nowMs); diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 182ffa2..773cd84 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -14,6 +14,8 @@ import { fetchMpdXml, extractPsshFromMpd, fetchViaplayBootstrapPssh, + isDontGoPlaceholder, + isLiveDashMpd, VIAPLAY_USER_AGENT, } from "./client"; import { @@ -49,7 +51,7 @@ function recordVodPlay( partial: Omit ) { lastVodPlay = { ...partial, at: new Date().toISOString(), - resolver: "viaplay-byguid-har-v7", + resolver: "viaplay-byguid-har-v8", }; } @@ -138,6 +140,8 @@ export type ResolveViaplayPlayResult = { mediaGuid: string; accountId?: string; title?: string | null; + /** false = catch-up/VOD — client mag niet naar live-edge zoeken. */ + live: boolean; }; function accountOrder( @@ -205,24 +209,31 @@ export async function ensureViaplayEventReady( !!event.mpdUrl && isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes); - if (cacheFresh && cachedKeys.length) { + if (cacheFresh && cachedKeys.length && !isDontGoPlaceholder(event.mpdUrl)) { return { streamUrl: event.mpdUrl!, format: "dash", keys: cachedKeys, fromCache: true, mediaGuid, + live: !ended, }; } - // Terugkijken: zelfde pad als het werkende script — KAP op zender-MPD, geen play-live. - if (ended && cachedMpd && !opts?.forceRefresh) { + // Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go). + if ( + ended && + cachedMpd && + !opts?.forceRefresh && + isLiveDashMpd(cachedMpd) && + !isDontGoPlaceholder(cachedMpd) + ) { try { const kap = await resolveKapMpd( cachedMpd, event.startMs, event.endMs, - tweaks.httpTimeoutMs + Math.min(tweaks.httpTimeoutMs, 12_000) ); if (!cachedKeys.length) { cachedKeys = await keysForChannelPath(sessionSecret, kap.mpdUrl); @@ -242,6 +253,7 @@ export async function ensureViaplayEventReady( keys: cachedKeys, fromCache: true, mediaGuid, + live: false, }; } } catch { @@ -250,13 +262,19 @@ export async function ensureViaplayEventReady( } // Cache MPD zonder keys is ok als keys later komen — of clear feed - if (cacheFresh && event.mpdUrl && !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)) { + if ( + cacheFresh && + event.mpdUrl && + !isDontGoPlaceholder(event.mpdUrl) && + !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) + ) { return { streamUrl: event.mpdUrl, format: "dash", keys: cachedKeys, fromCache: true, mediaGuid, + live: !ended, }; } @@ -289,13 +307,21 @@ export async function ensureViaplayEventReady( const play = await playByMediaGuid(creds, mediaGuid, { deviceKey: tweaks.deviceKey, timeoutMs: tweaks.httpTimeoutMs, + pageUrl: event.pageUrl, }); - const kap = await resolveKapMpd( - play.mpdUrl, - event.startMs, - event.endMs, - tweaks.httpTimeoutMs - ); + 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, + }; let keys = cachedKeys; if (!keys.length) { @@ -362,6 +388,7 @@ export async function ensureViaplayEventReady( fromCache: false, mediaGuid, accountId: account.id, + live: !ended, }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); @@ -383,17 +410,25 @@ export async function resolveViaplayPlay( sessionSecret: string, eventId: string ): Promise { - const settings = await loadViaplaySettings(sessionSecret); - const event = findViaplayEvent(settings.config, eventId); - if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404); + try { + const settings = await loadViaplaySettings(sessionSecret); + const event = findViaplayEvent(settings.config, eventId); + if (!event) throw new AppError("NOT_FOUND", "Viaplay-event niet gevonden", 404); - const now = Date.now(); - // Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar. - if (now > event.endMs + VIAPLAY_CATCHUP_MS) { - throw new AppError("GONE", "Dit event is afgelopen", 410); + const now = Date.now(); + // Catch-up: officieel ~48u; live-pad (play-live + KAP) blijft na einde bruikbaar. + if (now > event.endMs + VIAPLAY_CATCHUP_MS) { + throw new AppError("GONE", "Dit event is afgelopen", 410); + } + return await ensureViaplayEventReady(sessionSecret, event); + } catch (err) { + if (err instanceof AppError) throw err; + throw new AppError( + "UPSTREAM", + err instanceof Error ? err.message : "Viaplay play mislukt", + 502 + ); } - // Pre-live: wel MPD/keys toestaan binnen preLiveMinutes; viewer play blijft live-only - return ensureViaplayEventReady(sessionSecret, event); } /** @@ -403,6 +438,23 @@ export async function resolveViaplayGuidPlay( sessionSecret: string, guidRaw: string, opts?: { productUrl?: string | null } +): Promise { + try { + return await resolveViaplayGuidPlayInner(sessionSecret, guidRaw, opts); + } catch (err) { + if (err instanceof AppError) throw err; + throw new AppError( + "UPSTREAM", + err instanceof Error ? err.message : "Viaplay VOD-play mislukt", + 502 + ); + } +} + +async function resolveViaplayGuidPlayInner( + sessionSecret: string, + guidRaw: string, + opts?: { productUrl?: string | null } ): Promise { const guid = String(guidRaw || "").trim(); if (!guid) throw new AppError("BAD_REQUEST", "Geen Viaplay guid", 400); @@ -544,6 +596,7 @@ export async function resolveViaplayGuidPlay( mediaGuid: guid, accountId: account.id, title: play.title, + live: false, }; } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index f9333e0..f7a0af2 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1094,8 +1094,9 @@ export function registerViewerRoutes( format: played.format, fallbackStreamUrl: null, fallbackFormat: null, + live: false, /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ - resolver: "viaplay-byguid-har-v7", + resolver: "viaplay-byguid-har-v8", mediaPath: (() => { try { const u = new URL(played.streamUrl); diff --git a/apps/master-api/src/viewer/schedule-events.ts b/apps/master-api/src/viewer/schedule-events.ts index 7d2402d..a0dda30 100644 --- a/apps/master-api/src/viewer/schedule-events.ts +++ b/apps/master-api/src/viewer/schedule-events.ts @@ -1134,6 +1134,7 @@ export async function getScheduleEventPlay( format: played.format, fallbackStreamUrl: null, fallbackFormat: null, + live: played.live, drm: played.keys.length ? { type: "clearkey" as const, keys: played.keys } : null,