From ca5b2b30d0ec6ded439da0e202e62a9690731aa8 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 03:24:23 +0200 Subject: [PATCH] Harden Viaplay VOD play to exact HAR byguid query and reject promo soft-fails. Always use canonical producturl/contextualV2/splitIOFlagIds first; validate product-link, productType and duration so Don't go cannot return as a silent 200. --- apps/master-api/src/app.ts | 2 +- apps/master-api/src/viaplay/client.ts | 153 +++++++++++++++++--------- apps/master-api/src/viewer/routes.ts | 2 + 3 files changed, 104 insertions(+), 53 deletions(-) diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 076fdbc..a0348b4 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -104,7 +104,7 @@ async function main() { app.get("/health", async () => ({ status: "ok", service: "master-api", - version: "1.0.0", + version: "1.0.1-vod-har-v4", })); await registerAuthRoutes(app, config); diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index cbbad8c..2a44360 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -26,18 +26,20 @@ function cookieHeader(creds: ViaplayAccountCreds): string { if (creds.session) parts.push(`session=${creds.session}`); if (creds.accessToken) parts.push(`accessToken=${creds.accessToken}`); if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId}`); + if (creds.persistentLogin) parts.push(`persistentLogin=${creds.persistentLogin}`); return parts.join("; "); } export function authHeaders(creds: ViaplayAccountCreds): Record { const headers: Record = { Accept: "*/*", + "Accept-Language": "nl,en;q=0.9,en-GB;q=0.8,en-US;q=0.7", Origin: "https://viaplay.com", Referer: "https://viaplay.com/", "User-Agent": VIAPLAY_USER_AGENT, Cookie: cookieHeader(creds), }; - // play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT (cookies alleen is vaak promo/Don't go) + // play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT const token = normalizeAccessToken(creds.accessToken); if (token) headers.Authorization = `VIAPLAY-AT ${token}`; return headers; @@ -783,38 +785,39 @@ function buildHarByguidUrl( productUrl: string; returnUrl: string; sectionPath: string; + /** Rijkere contextualV2 uit rail-lijst (HAR). */ + contextualv2url?: string | null; } ): string { - let productUrlFinal = opts.productUrl; - try { - const pu = new URL(opts.productUrl); - if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true"); - productUrlFinal = pu.toString(); - } catch { - /* keep */ - } + // Altijd canonieke producturl zoals web-HAR — geen profileId/extra query. + const productUrlFinal = `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`; const u = new URL("https://play.viaplay.com/api/stream/byguid"); u.searchParams.set("deviceId", creds.deviceId || randomUUID()); u.searchParams.set("deviceName", "web"); u.searchParams.set("deviceType", "pc"); u.searchParams.set("userAgent", VIAPLAY_USER_AGENT); u.searchParams.set("deviceKey", opts.deviceKey); - u.searchParams.set("guid", guid); u.searchParams.set("cse", "true"); - u.searchParams.set("win10edge", "true"); - u.searchParams.set("producturl", productUrlFinal); + u.searchParams.set( + "splitIOFlagIds", + "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on" + ); + u.searchParams.set("guid", guid); u.searchParams.set("returnurl", opts.returnUrl); - u.searchParams.set("sectionPath", opts.sectionPath); + u.searchParams.set("producturl", productUrlFinal); + u.searchParams.set( + "contextualv2url", + (opts.contextualv2url && String(opts.contextualv2url).trim()) || + `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip` + ); u.searchParams.set( "templatedproducturl", `https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true` ); - u.searchParams.set( - "contextualv2url", - `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip` - ); + u.searchParams.set("sectionPath", opts.sectionPath); u.searchParams.set("defaultAvailabilityContext", "svod"); if (creds.profileId) u.searchParams.set("profileId", creds.profileId); + u.searchParams.set("win10edge", "true"); return u.toString(); } @@ -824,6 +827,58 @@ function playBodyGuid(body: Record): string | null { return typeof system?.guid === "string" ? system.guid.trim() : null; } +/** HAR-success: product-link + guid + productType=clip. Promo-docu faalt hierop. */ +function assertVodPlayMatchesGuid( + body: Record, + guid: string, + played: ViaplayPlayResult +): void { + const responseGuid = playBodyGuid(body); + if (responseGuid && responseGuid !== guid) { + throw new Error(`Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`); + } + if (isPromoVodTitle(played.title)) { + throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); + } + const links = asRecord(body._links); + for (const key of ["viaplay:product", "viaplay:productPage"] as const) { + const href = asRecord(links?.[key])?.href; + if (typeof href === "string" && href.trim() && !href.includes(guid)) { + throw new Error(`Viaplay product-link mismatch (${key})`); + } + } + const product = asRecord(body.product); + const productType = + typeof product?.productType === "string" + ? product.productType.trim().toLowerCase() + : ""; + if (/vp-sports-clip|sports-clip/i.test(guid) && productType && productType !== "clip") { + throw new Error(`Viaplay productType “${productType}” i.p.v. clip`); + } + const durationMs = Number(body.duration); + // Samenvattingen < ~2u; Don't go-feature is veel langer + if ( + /vp-sports-clip|sports-clip/i.test(guid) && + Number.isFinite(durationMs) && + durationMs > 2.5 * 60 * 60 * 1000 + ) { + throw new Error(`Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)`); + } +} + +function contextualv2FromStreamHref(href: string | null | undefined): string | null { + if (!href) return null; + try { + const cleaned = href + .replace(/\{\?[^}]+\}/g, "?") + .replace(/\?&/, "?") + .replace(/\?{2,}/g, "?"); + return new URL(cleaned).searchParams.get("contextualv2url"); + } catch { + return null; + } +} + async function fetchProductStreamMeta( creds: ViaplayAccountCreds, productUrl: string, @@ -870,11 +925,10 @@ async function fetchProductStreamMeta( } /** - * VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid + * VOD / clips / samenvattingen — HAR: play.viaplay.com/api/stream/byguid * - * HAR-volgorde: stream-href uit de sport-rail (contextualV2) → byguid met device-params - * + cookies/VIAPLAY-AT. Product-pagina komt pas daarna (metadata). Kale byguid zonder - * context/auth levert Viaplay's promo (“Don't go”). + * Web: rail stream-href → byguid (producturl/cse/contextualV2/win10edge) + session-cookie. + * Zonder die context geeft Viaplay een 200 met promo-docu (“Don't go”) i.p.v. 403. */ export async function playByGuid( creds: ViaplayAccountCreds, @@ -890,7 +944,6 @@ export async function playByGuid( const guid = String(guidRaw || "").trim(); if (!guid) throw new Error("Geen Viaplay guid"); - // pageUrl mag alleen als die de guid bevat — anders verkeerde producturl → promo const rawProduct = (opts.productUrl && String(opts.productUrl).trim()) || ""; const productUrl = rawProduct && rawProduct.includes(guid) @@ -902,7 +955,6 @@ export async function playByGuid( `https://content.viaplay.com/${opts.deviceKey}/sport`; const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport"; - // HAR: web gebruikt eerst de rail-lijst stream-href (rijkere contextualV2). const listingHref = cachedStreamHref(guid); let productHref: string | null = null; let pageTitle: string | null = null; @@ -914,30 +966,36 @@ export async function playByGuid( rememberStreamHref(guid, meta.streamHref); } } catch { - /* fallback hieronder */ + /* har-url hieronder */ } + const contextualv2url = + contextualv2FromStreamHref(listingHref) || + contextualv2FromStreamHref(productHref); + + // Eerst exacte HAR-query (betrouwbaar), daarna eventuele stream-hrefs. const playUrls: string[] = []; const seen = new Set(); - const pushExpanded = (href: string | null) => { - if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) return; - const expanded = expandViaplayStreamHref(href, creds, { - deviceKey: opts.deviceKey, - guid, - }); - if (seen.has(expanded)) return; - seen.add(expanded); - playUrls.push(expanded); + const pushUrl = (url: string) => { + if (!url || seen.has(url)) return; + seen.add(url); + playUrls.push(url); }; - pushExpanded(listingHref); - pushExpanded(productHref); - const harUrl = buildHarByguidUrl(creds, guid, { - deviceKey: opts.deviceKey, - productUrl, - returnUrl, - sectionPath, - }); - if (!seen.has(harUrl)) playUrls.push(harUrl); + pushUrl( + buildHarByguidUrl(creds, guid, { + deviceKey: opts.deviceKey, + productUrl, + returnUrl, + sectionPath, + contextualv2url, + }) + ); + for (const href of [listingHref, productHref]) { + if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) continue; + pushUrl( + expandViaplayStreamHref(href, creds, { deviceKey: opts.deviceKey, guid }) + ); + } let lastError: Error | null = null; for (const playUrl of playUrls) { @@ -954,16 +1012,7 @@ export async function playByGuid( throw new Error(msg); } const played = parsePlayStreamBody(body, guid); - const responseGuid = playBodyGuid(body); - // Nooit product-titel over promo plakken — dat maskeerde Don't go eerder. - if (responseGuid && responseGuid !== guid) { - throw new Error( - `Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}` - ); - } - if (isPromoVodTitle(played.title)) { - throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); - } + assertVodPlayMatchesGuid(body, guid, played); if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) { played.title = pageTitle; } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index eb07127..0c50919 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1083,6 +1083,8 @@ export function registerViewerRoutes( format: played.format, fallbackStreamUrl: null, fallbackFormat: null, + /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ + resolver: "viaplay-byguid-har-v4", drm: played.keys.length ? { type: "clearkey",