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,
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",
};
}
}

View file

@ -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(),
};
}
});
}

View file

@ -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);

View file

@ -907,21 +907,39 @@ function playBodyGuid(body: Record<string, unknown>): 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<string, unknown>,
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;

View file

@ -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<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 {
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);
}

View file

@ -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",