Harden Viaplay VOD play to exact HAR byguid query and reject promo soft-fails.
Always use canonical producturl/contextualV2/splitIOFlagIds first; validate product-link, productType and duration so Don't go cannot return as a silent 200.
This commit is contained in:
parent
2aa35b663e
commit
ca5b2b30d0
3 changed files with 104 additions and 53 deletions
|
|
@ -104,7 +104,7 @@ async function main() {
|
||||||
app.get("/health", async () => ({
|
app.get("/health", async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
service: "master-api",
|
service: "master-api",
|
||||||
version: "1.0.0",
|
version: "1.0.1-vod-har-v4",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await registerAuthRoutes(app, config);
|
await registerAuthRoutes(app, config);
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,20 @@ function cookieHeader(creds: ViaplayAccountCreds): string {
|
||||||
if (creds.session) parts.push(`session=${creds.session}`);
|
if (creds.session) parts.push(`session=${creds.session}`);
|
||||||
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken}`);
|
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken}`);
|
||||||
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId}`);
|
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId}`);
|
||||||
|
if (creds.persistentLogin) parts.push(`persistentLogin=${creds.persistentLogin}`);
|
||||||
return parts.join("; ");
|
return parts.join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
|
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "*/*",
|
Accept: "*/*",
|
||||||
|
"Accept-Language": "nl,en;q=0.9,en-GB;q=0.8,en-US;q=0.7",
|
||||||
Origin: "https://viaplay.com",
|
Origin: "https://viaplay.com",
|
||||||
Referer: "https://viaplay.com/",
|
Referer: "https://viaplay.com/",
|
||||||
"User-Agent": VIAPLAY_USER_AGENT,
|
"User-Agent": VIAPLAY_USER_AGENT,
|
||||||
Cookie: cookieHeader(creds),
|
Cookie: cookieHeader(creds),
|
||||||
};
|
};
|
||||||
// play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT (cookies alleen is vaak promo/Don't go)
|
// play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT
|
||||||
const token = normalizeAccessToken(creds.accessToken);
|
const token = normalizeAccessToken(creds.accessToken);
|
||||||
if (token) headers.Authorization = `VIAPLAY-AT ${token}`;
|
if (token) headers.Authorization = `VIAPLAY-AT ${token}`;
|
||||||
return headers;
|
return headers;
|
||||||
|
|
@ -783,38 +785,39 @@ function buildHarByguidUrl(
|
||||||
productUrl: string;
|
productUrl: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
sectionPath: string;
|
sectionPath: string;
|
||||||
|
/** Rijkere contextualV2 uit rail-lijst (HAR). */
|
||||||
|
contextualv2url?: string | null;
|
||||||
}
|
}
|
||||||
): string {
|
): string {
|
||||||
let productUrlFinal = opts.productUrl;
|
// Altijd canonieke producturl zoals web-HAR — geen profileId/extra query.
|
||||||
try {
|
const productUrlFinal = `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
|
||||||
const pu = new URL(opts.productUrl);
|
|
||||||
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
|
|
||||||
productUrlFinal = pu.toString();
|
|
||||||
} catch {
|
|
||||||
/* keep */
|
|
||||||
}
|
|
||||||
const u = new URL("https://play.viaplay.com/api/stream/byguid");
|
const u = new URL("https://play.viaplay.com/api/stream/byguid");
|
||||||
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
||||||
u.searchParams.set("deviceName", "web");
|
u.searchParams.set("deviceName", "web");
|
||||||
u.searchParams.set("deviceType", "pc");
|
u.searchParams.set("deviceType", "pc");
|
||||||
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
|
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
|
||||||
u.searchParams.set("deviceKey", opts.deviceKey);
|
u.searchParams.set("deviceKey", opts.deviceKey);
|
||||||
u.searchParams.set("guid", guid);
|
|
||||||
u.searchParams.set("cse", "true");
|
u.searchParams.set("cse", "true");
|
||||||
u.searchParams.set("win10edge", "true");
|
u.searchParams.set(
|
||||||
u.searchParams.set("producturl", productUrlFinal);
|
"splitIOFlagIds",
|
||||||
|
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
|
||||||
|
);
|
||||||
|
u.searchParams.set("guid", guid);
|
||||||
u.searchParams.set("returnurl", opts.returnUrl);
|
u.searchParams.set("returnurl", opts.returnUrl);
|
||||||
u.searchParams.set("sectionPath", opts.sectionPath);
|
u.searchParams.set("producturl", productUrlFinal);
|
||||||
|
u.searchParams.set(
|
||||||
|
"contextualv2url",
|
||||||
|
(opts.contextualv2url && String(opts.contextualv2url).trim()) ||
|
||||||
|
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip`
|
||||||
|
);
|
||||||
u.searchParams.set(
|
u.searchParams.set(
|
||||||
"templatedproducturl",
|
"templatedproducturl",
|
||||||
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
|
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
|
||||||
);
|
);
|
||||||
u.searchParams.set(
|
u.searchParams.set("sectionPath", opts.sectionPath);
|
||||||
"contextualv2url",
|
|
||||||
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip`
|
|
||||||
);
|
|
||||||
u.searchParams.set("defaultAvailabilityContext", "svod");
|
u.searchParams.set("defaultAvailabilityContext", "svod");
|
||||||
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
|
||||||
|
u.searchParams.set("win10edge", "true");
|
||||||
return u.toString();
|
return u.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -824,6 +827,58 @@ 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. */
|
||||||
|
function assertVodPlayMatchesGuid(
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
guid: string,
|
||||||
|
played: ViaplayPlayResult
|
||||||
|
): void {
|
||||||
|
const responseGuid = playBodyGuid(body);
|
||||||
|
if (responseGuid && responseGuid !== guid) {
|
||||||
|
throw new Error(`Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`);
|
||||||
|
}
|
||||||
|
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 href = asRecord(links?.[key])?.href;
|
||||||
|
if (typeof href === "string" && href.trim() && !href.includes(guid)) {
|
||||||
|
throw new Error(`Viaplay product-link mismatch (${key})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const product = asRecord(body.product);
|
||||||
|
const productType =
|
||||||
|
typeof product?.productType === "string"
|
||||||
|
? product.productType.trim().toLowerCase()
|
||||||
|
: "";
|
||||||
|
if (/vp-sports-clip|sports-clip/i.test(guid) && 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)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextualv2FromStreamHref(href: string | null | undefined): string | null {
|
||||||
|
if (!href) return null;
|
||||||
|
try {
|
||||||
|
const cleaned = href
|
||||||
|
.replace(/\{\?[^}]+\}/g, "?")
|
||||||
|
.replace(/\?&/, "?")
|
||||||
|
.replace(/\?{2,}/g, "?");
|
||||||
|
return new URL(cleaned).searchParams.get("contextualv2url");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchProductStreamMeta(
|
async function fetchProductStreamMeta(
|
||||||
creds: ViaplayAccountCreds,
|
creds: ViaplayAccountCreds,
|
||||||
productUrl: string,
|
productUrl: string,
|
||||||
|
|
@ -870,11 +925,10 @@ async function fetchProductStreamMeta(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid
|
* VOD / clips / samenvattingen — HAR: play.viaplay.com/api/stream/byguid
|
||||||
*
|
*
|
||||||
* HAR-volgorde: stream-href uit de sport-rail (contextualV2) → byguid met device-params
|
* Web: rail stream-href → byguid (producturl/cse/contextualV2/win10edge) + session-cookie.
|
||||||
* + cookies/VIAPLAY-AT. Product-pagina komt pas daarna (metadata). Kale byguid zonder
|
* Zonder die context geeft Viaplay een 200 met promo-docu (“Don't go”) i.p.v. 403.
|
||||||
* context/auth levert Viaplay's promo (“Don't go”).
|
|
||||||
*/
|
*/
|
||||||
export async function playByGuid(
|
export async function playByGuid(
|
||||||
creds: ViaplayAccountCreds,
|
creds: ViaplayAccountCreds,
|
||||||
|
|
@ -890,7 +944,6 @@ export async function playByGuid(
|
||||||
const guid = String(guidRaw || "").trim();
|
const guid = String(guidRaw || "").trim();
|
||||||
if (!guid) throw new Error("Geen Viaplay guid");
|
if (!guid) throw new Error("Geen Viaplay guid");
|
||||||
|
|
||||||
// pageUrl mag alleen als die de guid bevat — anders verkeerde producturl → promo
|
|
||||||
const rawProduct = (opts.productUrl && String(opts.productUrl).trim()) || "";
|
const rawProduct = (opts.productUrl && String(opts.productUrl).trim()) || "";
|
||||||
const productUrl =
|
const productUrl =
|
||||||
rawProduct && rawProduct.includes(guid)
|
rawProduct && rawProduct.includes(guid)
|
||||||
|
|
@ -902,7 +955,6 @@ export async function playByGuid(
|
||||||
`https://content.viaplay.com/${opts.deviceKey}/sport`;
|
`https://content.viaplay.com/${opts.deviceKey}/sport`;
|
||||||
const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport";
|
const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport";
|
||||||
|
|
||||||
// HAR: web gebruikt eerst de rail-lijst stream-href (rijkere contextualV2).
|
|
||||||
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;
|
||||||
|
|
@ -914,30 +966,36 @@ export async function playByGuid(
|
||||||
rememberStreamHref(guid, meta.streamHref);
|
rememberStreamHref(guid, meta.streamHref);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fallback hieronder */
|
/* har-url hieronder */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextualv2url =
|
||||||
|
contextualv2FromStreamHref(listingHref) ||
|
||||||
|
contextualv2FromStreamHref(productHref);
|
||||||
|
|
||||||
|
// Eerst exacte HAR-query (betrouwbaar), daarna eventuele stream-hrefs.
|
||||||
const playUrls: string[] = [];
|
const playUrls: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const pushExpanded = (href: string | null) => {
|
const pushUrl = (url: string) => {
|
||||||
if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) return;
|
if (!url || seen.has(url)) return;
|
||||||
const expanded = expandViaplayStreamHref(href, creds, {
|
seen.add(url);
|
||||||
deviceKey: opts.deviceKey,
|
playUrls.push(url);
|
||||||
guid,
|
|
||||||
});
|
|
||||||
if (seen.has(expanded)) return;
|
|
||||||
seen.add(expanded);
|
|
||||||
playUrls.push(expanded);
|
|
||||||
};
|
};
|
||||||
pushExpanded(listingHref);
|
pushUrl(
|
||||||
pushExpanded(productHref);
|
buildHarByguidUrl(creds, guid, {
|
||||||
const harUrl = buildHarByguidUrl(creds, guid, {
|
|
||||||
deviceKey: opts.deviceKey,
|
deviceKey: opts.deviceKey,
|
||||||
productUrl,
|
productUrl,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
sectionPath,
|
sectionPath,
|
||||||
});
|
contextualv2url,
|
||||||
if (!seen.has(harUrl)) playUrls.push(harUrl);
|
})
|
||||||
|
);
|
||||||
|
for (const href of [listingHref, productHref]) {
|
||||||
|
if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) continue;
|
||||||
|
pushUrl(
|
||||||
|
expandViaplayStreamHref(href, creds, { deviceKey: opts.deviceKey, guid })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
for (const playUrl of playUrls) {
|
for (const playUrl of playUrls) {
|
||||||
|
|
@ -954,16 +1012,7 @@ export async function playByGuid(
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
const played = parsePlayStreamBody(body, guid);
|
const played = parsePlayStreamBody(body, guid);
|
||||||
const responseGuid = playBodyGuid(body);
|
assertVodPlayMatchesGuid(body, guid, played);
|
||||||
// Nooit product-titel over promo plakken — dat maskeerde Don't go eerder.
|
|
||||||
if (responseGuid && responseGuid !== guid) {
|
|
||||||
throw new Error(
|
|
||||||
`Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isPromoVodTitle(played.title)) {
|
|
||||||
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
|
|
||||||
}
|
|
||||||
if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) {
|
if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) {
|
||||||
played.title = pageTitle;
|
played.title = pageTitle;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1083,6 +1083,8 @@ export function registerViewerRoutes(
|
||||||
format: played.format,
|
format: played.format,
|
||||||
fallbackStreamUrl: null,
|
fallbackStreamUrl: null,
|
||||||
fallbackFormat: null,
|
fallbackFormat: null,
|
||||||
|
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */
|
||||||
|
resolver: "viaplay-byguid-har-v4",
|
||||||
drm: played.keys.length
|
drm: played.keys.length
|
||||||
? {
|
? {
|
||||||
type: "clearkey",
|
type: "clearkey",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue