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:
parent
2cc46d87ce
commit
24051ed48f
6 changed files with 208 additions and 37 deletions
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue