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,
|
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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue