From 24051ed48f1a8b20553ec86b56d47df564c8d63c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Thu, 24 Sep 2026 00:00:31 +0200 Subject: [PATCH] Harden Viaplay VOD assert against empty-title Don't go soft-fails. Require response guid/title/product-link, reject short slate durations, record lastVodPlay, and expose an agent vod-probe for production diagnosis. --- apps/master-api/src/admin/routes.ts | 4 +- apps/master-api/src/agent/routes.ts | 47 ++++++++++++++ apps/master-api/src/app.ts | 14 +++-- apps/master-api/src/viaplay/client.ts | 89 ++++++++++++++++++++++----- apps/master-api/src/viaplay/play.ts | 81 ++++++++++++++++++++---- apps/master-api/src/viewer/routes.ts | 10 ++- 6 files changed, 208 insertions(+), 37 deletions(-) diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 41001a9..5b00562 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1928,14 +1928,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) streamUrl: played.streamUrl, keyCount: played.keys.length, promo, - resolver: "viaplay-byguid-har-v5", + resolver: "viaplay-byguid-har-v6", }; } catch (err) { return { ok: false, guid, error: err instanceof Error ? err.message : String(err), - resolver: "viaplay-byguid-har-v5", + resolver: "viaplay-byguid-har-v6", }; } } diff --git a/apps/master-api/src/agent/routes.ts b/apps/master-api/src/agent/routes.ts index 93a2760..e6459ae 100644 --- a/apps/master-api/src/agent/routes.ts +++ b/apps/master-api/src/agent/routes.ts @@ -193,4 +193,51 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) { sessionId: body.sessionId, }); }); + + /** Productie speel-test (tray-key): zelfde pad als Shield VOD. */ + app.post("/api/v1/agent/viaplay/vod-probe", async (request) => { + await requireTrayKey(request, config); + const body = (request.body as { guid?: string }) || {}; + const guid = + body.guid?.trim() || "vp-sports-clip-d9a3b8a7-0804-44d"; + const { resolveViaplayGuidPlay, getLastVodPlay } = await import( + "../viaplay/play" + ); + try { + const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid); + const promo = /don'?t\s*go|viaplay\s*presents|documentary/i.test( + String(played.title || "") + ); + let mediaPath: string | null = null; + try { + mediaPath = new URL(played.streamUrl).pathname + .split("/") + .filter(Boolean) + .slice(-3) + .join("/"); + } catch { + /* ignore */ + } + return { + ok: !promo && !!played.streamUrl, + guid, + title: played.title, + mediaGuid: played.mediaGuid, + accountId: played.accountId, + mediaPath, + keyCount: played.keys.length, + promo, + resolver: "viaplay-byguid-har-v6", + lastVodPlay: getLastVodPlay(), + }; + } catch (err) { + return { + ok: false, + guid, + error: err instanceof Error ? err.message : String(err), + resolver: "viaplay-byguid-har-v6", + lastVodPlay: getLastVodPlay(), + }; + } + }); } diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 514a42e..b631f67 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -101,11 +101,15 @@ async function main() { reply.status(statusCode).send(body); }); - app.get("/health", async () => ({ - status: "ok", - service: "master-api", - version: "1.0.2-vod-refresh-v5", - })); + app.get("/health", async () => { + const { getLastVodPlay } = await import("./viaplay/play"); + return { + status: "ok", + service: "master-api", + version: "1.0.3-vod-assert-v6", + lastVodPlay: getLastVodPlay(), + }; + }); await registerAuthRoutes(app, config); await registerNodeRoutes(app); diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index bbf2abf..9b79c2c 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -907,21 +907,39 @@ 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. */ +/** + * HAR-success: guid + titel + product-link + productType. + * Soft-fail “Don't go” komt vaak als 200 met lege titel / ontbrekende guid — + * dat mag nooit door (anders plakt pageTitle over de promo-MPD). + */ function assertVodPlayMatchesGuid( body: Record, guid: string, - played: ViaplayPlayResult + played: ViaplayPlayResult, + expected?: { durationMs?: number | null; title?: string | null } ): void { const responseGuid = playBodyGuid(body); - if (responseGuid && responseGuid !== guid) { + if (!responseGuid) { + throw new Error("Viaplay play-response mist product.system.guid (promo soft-fail?)"); + } + if (responseGuid !== guid) { throw new Error(`Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`); } + if (!played.title?.trim()) { + throw new Error("Viaplay play-response mist titel (promo soft-fail?)"); + } 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 productHref = asRecord(links?.["viaplay:product"])?.href; + if (typeof productHref !== "string" || !productHref.trim()) { + throw new Error("Viaplay play-response mist viaplay:product-link"); + } + if (!productHref.includes(guid)) { + throw new Error("Viaplay product-link mismatch (viaplay:product)"); + } + for (const key of ["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})`); @@ -932,17 +950,38 @@ function assertVodPlayMatchesGuid( typeof product?.productType === "string" ? product.productType.trim().toLowerCase() : ""; - if (/vp-sports-clip|sports-clip/i.test(guid) && productType && productType !== "clip") { + const wantsClip = + /vp-sports-clip|sports-clip/i.test(guid) || + /productType=clip/i.test(String(asRecord(links?.["viaplay:product"])?.href || "")); + if (wantsClip && 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)`); + if (Number.isFinite(durationMs) && durationMs > 0) { + // Korte slate / bumper i.p.v. echte samenvatting + if (wantsClip && durationMs < 60_000) { + throw new Error( + `Viaplay gaf te korte stream (${Math.round(durationMs / 1000)}s) — promo-slate?` + ); + } + // Feature-lengte i.p.v. clip + if (wantsClip && durationMs > 2.5 * 60 * 60 * 1000) { + throw new Error( + `Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)` + ); + } + const expectedMs = Number(expected?.durationMs); + if (Number.isFinite(expectedMs) && expectedMs > 60_000) { + const ratio = durationMs / expectedMs; + if (ratio < 0.5 || ratio > 2.0) { + throw new Error( + `Viaplay duration mismatch (play ${Math.round(durationMs / 1000)}s vs content ${Math.round(expectedMs / 1000)}s)` + ); + } + } + } + if (expected?.title && isPromoVodTitle(expected.title)) { + throw new Error(`Content-API gaf promo-titel (“${expected.title}”)`); } } @@ -963,7 +1002,12 @@ async function fetchProductStreamMeta( creds: ViaplayAccountCreds, productUrl: string, timeoutMs: number -): Promise<{ streamHref: string | null; title: string | null; guid: string | null }> { +): Promise<{ + streamHref: string | null; + title: string | null; + guid: string | null; + durationMs: number | null; +}> { let url = productUrl; try { const u = new URL(productUrl); @@ -980,7 +1024,7 @@ async function fetchProductStreamMeta( timeoutMs, }); if (res.status !== 200) { - return { streamHref: null, title: null, guid: null }; + return { streamHref: null, title: null, guid: null, durationMs: null }; } const root = asRecord(res.json); const embedded = asRecord(root?._embedded); @@ -1001,7 +1045,11 @@ async function fetchProductStreamMeta( ? String(root.title).trim() : null; const guid = typeof system?.guid === "string" ? system.guid.trim() : null; - return { streamHref: href, title, guid }; + const durationObj = asRecord(content?.duration); + const durationMsRaw = Number(durationObj?.milliseconds ?? durationObj?.ms); + const durationMs = + Number.isFinite(durationMsRaw) && durationMsRaw > 0 ? durationMsRaw : null; + return { streamHref: href, title, guid, durationMs }; } /** @@ -1038,9 +1086,11 @@ export async function playByGuid( const listingHref = cachedStreamHref(guid); let productHref: string | null = null; let pageTitle: string | null = null; + let pageDurationMs: number | null = null; try { const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs); pageTitle = meta.title; + pageDurationMs = meta.durationMs; if (meta.streamHref) { productHref = meta.streamHref; rememberStreamHref(guid, meta.streamHref); @@ -1092,8 +1142,13 @@ export async function playByGuid( throw new Error(msg); } const played = parsePlayStreamBody(body, guid); - assertVodPlayMatchesGuid(body, guid, played); - if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) { + assertVodPlayMatchesGuid(body, guid, played, { + durationMs: pageDurationMs, + title: pageTitle, + }); + // Alleen aanvullen als play al een echte (niet-promo) titel heeft — nooit + // pageTitle over een lege promo-response plakken (dat verborgt Don't go). + if (!played.title?.trim() && pageTitle && !isPromoVodTitle(pageTitle)) { played.title = pageTitle; } return played; diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 538540f..52fa6c8 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -23,6 +23,42 @@ import { findViaplayEvent } from "./sync"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { VIAPLAY_CATCHUP_MS } from "./events"; +export type LastVodPlayDiag = { + at: string; + guid: string; + ok: boolean; + title: string | null; + accountId: string | null; + mediaPath: string | null; + error: string | null; + resolver: string; +}; + +let lastVodPlay: LastVodPlayDiag | null = null; + +export function getLastVodPlay(): LastVodPlayDiag | null { + return lastVodPlay; +} + +function recordVodPlay( partial: Omit ) { + lastVodPlay = { + ...partial, + at: new Date().toISOString(), + resolver: "viaplay-byguid-har-v6", + }; +} + +function mediaPathOf(url: string | null | undefined): string | null { + if (!url) return null; + try { + const u = new URL(url); + const parts = u.pathname.split("/").filter(Boolean); + return parts.slice(-3).join("/") || u.pathname; + } catch { + return url.slice(0, 120); + } +} + function parseKey(value: string): { kid: string; key: string } | null { const [kid, key] = value.trim().toLowerCase().split(":", 2); if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null; @@ -321,19 +357,22 @@ export async function resolveViaplayGuidPlay( deviceKey: tweaks.deviceKey, timeoutMs: Math.min(tweaks.httpTimeoutMs, 15_000), }); - if (refreshed) { - creds = refreshed; - try { - const { updateViaplayAccount } = await import("./settings"); - await updateViaplayAccount(sessionSecret, account.id, { - session: refreshed.session || undefined, - accessToken: refreshed.accessToken || undefined, - persistentLogin: refreshed.persistentLogin || undefined, - }); - } catch { - /* play mag doorgaan met in-memory refresh */ - } + if (!refreshed?.session || !refreshed?.accessToken) { + throw new Error("persistentLogin refresh mislukt — geen verse session"); } + creds = refreshed; + try { + const { updateViaplayAccount } = await import("./settings"); + await updateViaplayAccount(sessionSecret, account.id, { + session: refreshed.session || undefined, + accessToken: refreshed.accessToken || undefined, + persistentLogin: refreshed.persistentLogin || undefined, + }); + } catch { + /* play mag doorgaan met in-memory refresh */ + } + } else if (!account.session?.trim()) { + throw new Error("Geen session-cookie en geen persistentLogin"); } const play = await playByGuid(creds, guid, { @@ -396,6 +435,15 @@ export async function resolveViaplayGuidPlay( } } + recordVodPlay({ + guid, + ok: true, + title: play.title, + accountId: account.id, + mediaPath: mediaPathOf(mpdUrl), + error: null, + }); + return { streamUrl: mpdUrl, format: "dash", @@ -410,5 +458,14 @@ export async function resolveViaplayGuidPlay( } } + recordVodPlay({ + guid, + ok: false, + title: null, + accountId: null, + mediaPath: null, + error: lastError?.message || "Viaplay VOD-play mislukt", + }); + throw lastError || new AppError("UPSTREAM", "Viaplay VOD-play mislukt", 502); } diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index b2d2740..6ed7e26 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1084,7 +1084,15 @@ export function registerViewerRoutes( fallbackStreamUrl: null, fallbackFormat: null, /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ - resolver: "viaplay-byguid-har-v5", + resolver: "viaplay-byguid-har-v6", + mediaPath: (() => { + try { + const u = new URL(played.streamUrl); + return u.pathname.split("/").filter(Boolean).slice(-3).join("/"); + } catch { + return null; + } + })(), drm: played.keys.length ? { type: "clearkey",