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.
This commit is contained in:
Jos Vooges | STH 2026-09-24 00:00:31 +02:00
parent 2cc46d87ce
commit 24051ed48f
6 changed files with 208 additions and 37 deletions

View file

@ -1928,14 +1928,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
streamUrl: played.streamUrl, streamUrl: played.streamUrl,
keyCount: played.keys.length, keyCount: played.keys.length,
promo, promo,
resolver: "viaplay-byguid-har-v5", resolver: "viaplay-byguid-har-v6",
}; };
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
guid, guid,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
resolver: "viaplay-byguid-har-v5", resolver: "viaplay-byguid-har-v6",
}; };
} }
} }

View file

@ -193,4 +193,51 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
sessionId: body.sessionId, 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(),
};
}
});
} }

View file

@ -101,11 +101,15 @@ async function main() {
reply.status(statusCode).send(body); reply.status(statusCode).send(body);
}); });
app.get("/health", async () => ({ app.get("/health", async () => {
status: "ok", const { getLastVodPlay } = await import("./viaplay/play");
service: "master-api", return {
version: "1.0.2-vod-refresh-v5", status: "ok",
})); service: "master-api",
version: "1.0.3-vod-assert-v6",
lastVodPlay: getLastVodPlay(),
};
});
await registerAuthRoutes(app, config); await registerAuthRoutes(app, config);
await registerNodeRoutes(app); await registerNodeRoutes(app);

View file

@ -907,21 +907,39 @@ function playBodyGuid(body: Record<string, unknown>): string | null {
return typeof system?.guid === "string" ? system.guid.trim() : 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( function assertVodPlayMatchesGuid(
body: Record<string, unknown>, body: Record<string, unknown>,
guid: string, guid: string,
played: ViaplayPlayResult played: ViaplayPlayResult,
expected?: { durationMs?: number | null; title?: string | null }
): void { ): void {
const responseGuid = playBodyGuid(body); 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}`); 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)) { if (isPromoVodTitle(played.title)) {
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
} }
const links = asRecord(body._links); 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; const href = asRecord(links?.[key])?.href;
if (typeof href === "string" && href.trim() && !href.includes(guid)) { if (typeof href === "string" && href.trim() && !href.includes(guid)) {
throw new Error(`Viaplay product-link mismatch (${key})`); throw new Error(`Viaplay product-link mismatch (${key})`);
@ -932,17 +950,38 @@ function assertVodPlayMatchesGuid(
typeof product?.productType === "string" typeof product?.productType === "string"
? product.productType.trim().toLowerCase() ? 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`); throw new Error(`Viaplay productType “${productType}” i.p.v. clip`);
} }
const durationMs = Number(body.duration); const durationMs = Number(body.duration);
// Samenvattingen < ~2u; Don't go-feature is veel langer if (Number.isFinite(durationMs) && durationMs > 0) {
if ( // Korte slate / bumper i.p.v. echte samenvatting
/vp-sports-clip|sports-clip/i.test(guid) && if (wantsClip && durationMs < 60_000) {
Number.isFinite(durationMs) && throw new Error(
durationMs > 2.5 * 60 * 60 * 1000 `Viaplay gaf te korte stream (${Math.round(durationMs / 1000)}s) — promo-slate?`
) { );
throw new Error(`Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)`); }
// 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, creds: ViaplayAccountCreds,
productUrl: string, productUrl: string,
timeoutMs: number 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; let url = productUrl;
try { try {
const u = new URL(productUrl); const u = new URL(productUrl);
@ -980,7 +1024,7 @@ async function fetchProductStreamMeta(
timeoutMs, timeoutMs,
}); });
if (res.status !== 200) { 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 root = asRecord(res.json);
const embedded = asRecord(root?._embedded); const embedded = asRecord(root?._embedded);
@ -1001,7 +1045,11 @@ async function fetchProductStreamMeta(
? String(root.title).trim() ? String(root.title).trim()
: null; : null;
const guid = typeof system?.guid === "string" ? system.guid.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); const listingHref = cachedStreamHref(guid);
let productHref: string | null = null; let productHref: string | null = null;
let pageTitle: string | null = null; let pageTitle: string | null = null;
let pageDurationMs: number | null = null;
try { try {
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs); const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
pageTitle = meta.title; pageTitle = meta.title;
pageDurationMs = meta.durationMs;
if (meta.streamHref) { if (meta.streamHref) {
productHref = meta.streamHref; productHref = meta.streamHref;
rememberStreamHref(guid, meta.streamHref); rememberStreamHref(guid, meta.streamHref);
@ -1092,8 +1142,13 @@ export async function playByGuid(
throw new Error(msg); throw new Error(msg);
} }
const played = parsePlayStreamBody(body, guid); const played = parsePlayStreamBody(body, guid);
assertVodPlayMatchesGuid(body, guid, played); assertVodPlayMatchesGuid(body, guid, played, {
if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) { 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; played.title = pageTitle;
} }
return played; return played;

View file

@ -23,6 +23,42 @@ import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
import { VIAPLAY_CATCHUP_MS } from "./events"; 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<LastVodPlayDiag, "at" | "resolver"> ) {
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 { function parseKey(value: string): { kid: string; key: string } | null {
const [kid, key] = value.trim().toLowerCase().split(":", 2); const [kid, key] = value.trim().toLowerCase().split(":", 2);
if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null; 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, deviceKey: tweaks.deviceKey,
timeoutMs: Math.min(tweaks.httpTimeoutMs, 15_000), timeoutMs: Math.min(tweaks.httpTimeoutMs, 15_000),
}); });
if (refreshed) { if (!refreshed?.session || !refreshed?.accessToken) {
creds = refreshed; throw new Error("persistentLogin refresh mislukt — geen verse session");
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 */
}
} }
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, { 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 { return {
streamUrl: mpdUrl, streamUrl: mpdUrl,
format: "dash", 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); throw lastError || new AppError("UPSTREAM", "Viaplay VOD-play mislukt", 502);
} }

View file

@ -1084,7 +1084,15 @@ export function registerViewerRoutes(
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ /** 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 drm: played.keys.length
? { ? {
type: "clearkey", type: "clearkey",