Fix Viaplay VOD Don't go: auth header, list stream-href, reject promo.

Play used cookies only and overlaid the real title on empty promo responses. Match HAR: VIAPLAY-AT, rail stream-href with contextualV2, win10edge, and fail closed on guid/title mismatch.
This commit is contained in:
Jos Vooges | STH 2026-09-23 03:10:11 +02:00
parent 2e6dc3bab9
commit 2aa35b663e

View file

@ -30,13 +30,17 @@ function cookieHeader(creds: ViaplayAccountCreds): string {
} }
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> { export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
return { const headers: Record<string, string> = {
Accept: "*/*", Accept: "*/*",
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)
const token = normalizeAccessToken(creds.accessToken);
if (token) headers.Authorization = `VIAPLAY-AT ${token}`;
return headers;
} }
async function fetchJson( async function fetchJson(
@ -415,6 +419,27 @@ export type ViaplaySportRail = {
items: ViaplaySportRailItem[]; items: ViaplaySportRailItem[];
}; };
/** HAR: web speelt via stream-href uit de rail-lijst (contextualV2), niet pas na product-fetch. */
const streamHrefCache = new Map<string, { href: string; at: number }>();
const STREAM_HREF_TTL_MS = 15 * 60_000;
function rememberStreamHref(guid: string, href: string | null | undefined): void {
const g = String(guid || "").trim();
const h = typeof href === "string" ? href.trim() : "";
if (!g || !h) return;
streamHrefCache.set(g, { href: h, at: Date.now() });
}
function cachedStreamHref(guid: string): string | null {
const hit = streamHrefCache.get(guid);
if (!hit) return null;
if (Date.now() - hit.at > STREAM_HREF_TTL_MS) {
streamHrefCache.delete(guid);
return null;
}
return hit.href;
}
function streamHref(product: Record<string, unknown>): string | null { function streamHref(product: Record<string, unknown>): string | null {
const links = asRecord(product._links); const links = asRecord(product._links);
const stream = asRecord(links?.["viaplay:stream"]); const stream = asRecord(links?.["viaplay:stream"]);
@ -422,6 +447,12 @@ function streamHref(product: Record<string, unknown>): string | null {
return href?.trim() || null; return href?.trim() || null;
} }
function isPromoVodTitle(title: string | null | undefined): boolean {
return /viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(
String(title || "")
);
}
function parseDurationLabel(content: Record<string, unknown> | null): string | null { function parseDurationLabel(content: Record<string, unknown> | null): string | null {
const duration = asRecord(content?.duration); const duration = asRecord(content?.duration);
if (typeof duration?.readable === "string" && duration.readable.trim()) { if (typeof duration?.readable === "string" && duration.readable.trim()) {
@ -495,6 +526,8 @@ function productToRailItem(node: Record<string, unknown>): ViaplaySportRailItem
content?.originalTitle ?? content?.synopsis ?? content?.description ?? "" content?.originalTitle ?? content?.synopsis ?? content?.description ?? ""
).trim() || null; ).trim() || null;
rememberStreamHref(guid, href);
return { return {
guid, guid,
title, title,
@ -714,7 +747,7 @@ function parsePlayStreamBody(
export function expandViaplayStreamHref( export function expandViaplayStreamHref(
hrefRaw: string, hrefRaw: string,
creds: ViaplayAccountCreds, creds: ViaplayAccountCreds,
opts: { deviceKey: string } opts: { deviceKey: string; guid?: string }
): string { ): string {
const cleaned = hrefRaw const cleaned = hrefRaw
.replace(/\{\?[^}]+\}/g, "?") .replace(/\{\?[^}]+\}/g, "?")
@ -727,13 +760,70 @@ export function expandViaplayStreamHref(
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("cse", "true"); u.searchParams.set("cse", "true");
u.searchParams.set("win10edge", "true");
if (!u.searchParams.get("defaultAvailabilityContext")) { if (!u.searchParams.get("defaultAvailabilityContext")) {
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);
const guid = opts.guid || u.searchParams.get("guid") || "";
if (guid && !u.searchParams.get("templatedproducturl")) {
u.searchParams.set(
"templatedproducturl",
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
);
}
return u.toString(); return u.toString();
} }
function buildHarByguidUrl(
creds: ViaplayAccountCreds,
guid: string,
opts: {
deviceKey: string;
productUrl: string;
returnUrl: string;
sectionPath: string;
}
): string {
let productUrlFinal = opts.productUrl;
try {
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");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("guid", guid);
u.searchParams.set("cse", "true");
u.searchParams.set("win10edge", "true");
u.searchParams.set("producturl", productUrlFinal);
u.searchParams.set("returnurl", opts.returnUrl);
u.searchParams.set("sectionPath", opts.sectionPath);
u.searchParams.set(
"templatedproducturl",
`https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true`
);
u.searchParams.set(
"contextualv2url",
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?contextualV2=true&productType=clip`
);
u.searchParams.set("defaultAvailabilityContext", "svod");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
return u.toString();
}
function playBodyGuid(body: Record<string, unknown>): string | null {
const product = asRecord(body.product);
const system = asRecord(product?.system);
return typeof system?.guid === "string" ? system.guid.trim() : null;
}
async function fetchProductStreamMeta( async function fetchProductStreamMeta(
creds: ViaplayAccountCreds, creds: ViaplayAccountCreds,
productUrl: string, productUrl: string,
@ -782,8 +872,9 @@ async function fetchProductStreamMeta(
/** /**
* VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid * VOD / clips / samenvattingen / docs — HAR: play.viaplay.com/api/stream/byguid
* *
* Belangrijk: eerst product-pagina (met cookies) → stream-href mét producturl/context. * HAR-volgorde: stream-href uit de sport-rail (contextualV2) → byguid met device-params
* Kale byguid zonder die params levert Viaplay's fallback (“Don't go” documentary). * + cookies/VIAPLAY-AT. Product-pagina komt pas daarna (metadata). Kale byguid zonder
* context/auth levert Viaplay's promo (“Don't go”).
*/ */
export async function playByGuid( export async function playByGuid(
creds: ViaplayAccountCreds, creds: ViaplayAccountCreds,
@ -799,53 +890,58 @@ 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 productUrl = const productUrl =
(opts.productUrl && String(opts.productUrl).trim()) || rawProduct && rawProduct.includes(guid)
`https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`; ? rawProduct
: `https://content.viaplay.com/${opts.deviceKey}/sport/${guid}?partial=true`;
let streamHref: string | null = null;
let pageTitle: string | null = null;
try {
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
streamHref = meta.streamHref;
pageTitle = meta.title;
} catch {
/* fallback hieronder */
}
let playUrl: string;
if (streamHref && /play\.viaplay\.com\/api\/stream\/byguid/i.test(streamHref)) {
playUrl = expandViaplayStreamHref(streamHref, creds, { deviceKey: opts.deviceKey });
} else {
// Fallback: zelfde query als web-HAR (producturl + cse + sectionPath)
let productUrlFinal = productUrl;
try {
const pu = new URL(productUrl);
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
productUrlFinal = pu.toString();
} catch {
/* keep */
}
const returnUrl = const returnUrl =
(opts.returnUrl && String(opts.returnUrl).trim()) || (opts.returnUrl && String(opts.returnUrl).trim()) ||
`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";
const u = new URL("https://play.viaplay.com/api/stream/byguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID()); // HAR: web gebruikt eerst de rail-lijst stream-href (rijkere contextualV2).
u.searchParams.set("deviceName", "web"); const listingHref = cachedStreamHref(guid);
u.searchParams.set("deviceType", "pc"); let productHref: string | null = null;
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT); let pageTitle: string | null = null;
u.searchParams.set("deviceKey", opts.deviceKey); try {
u.searchParams.set("guid", guid); const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
u.searchParams.set("cse", "true"); pageTitle = meta.title;
u.searchParams.set("producturl", productUrlFinal); if (meta.streamHref) {
u.searchParams.set("returnurl", returnUrl); productHref = meta.streamHref;
u.searchParams.set("sectionPath", sectionPath); rememberStreamHref(guid, meta.streamHref);
u.searchParams.set("defaultAvailabilityContext", "svod"); }
if (creds.profileId) u.searchParams.set("profileId", creds.profileId); } catch {
playUrl = u.toString(); /* fallback hieronder */
} }
const playUrls: string[] = [];
const seen = new Set<string>();
const pushExpanded = (href: string | null) => {
if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) return;
const expanded = expandViaplayStreamHref(href, creds, {
deviceKey: opts.deviceKey,
guid,
});
if (seen.has(expanded)) return;
seen.add(expanded);
playUrls.push(expanded);
};
pushExpanded(listingHref);
pushExpanded(productHref);
const harUrl = buildHarByguidUrl(creds, guid, {
deviceKey: opts.deviceKey,
productUrl,
returnUrl,
sectionPath,
});
if (!seen.has(harUrl)) playUrls.push(harUrl);
let lastError: Error | null = null;
for (const playUrl of playUrls) {
try {
const res = await fetchJson(playUrl, { const res = await fetchJson(playUrl, {
headers: authHeaders(creds), headers: authHeaders(creds),
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
@ -857,52 +953,27 @@ export async function playByGuid(
`play-byguid mislukt (${res.status})`; `play-byguid mislukt (${res.status})`;
throw new Error(msg); throw new Error(msg);
} }
let played = parsePlayStreamBody(body, guid); const played = parsePlayStreamBody(body, guid);
if (!played.title && pageTitle) played.title = pageTitle; const responseGuid = playBodyGuid(body);
// Nooit product-titel over promo plakken — dat maskeerde Don't go eerder.
// Kapotte stream-href of ontbrekende producturl → promo-docu. if (responseGuid && responseGuid !== guid) {
// Dan één keer opnieuw met expliciete HAR-query i.p.v. hard falen. throw new Error(
const titleLc = (played.title || "").toLowerCase(); `Viaplay gaf andere guid (${responseGuid}) i.p.v. ${guid}`
const looksLikePromo = /viaplay\s*presents|don'?t\s*go|viaplay\s*documentary/i.test(
titleLc
); );
if (looksLikePromo && streamHref) {
let productUrlFinal = productUrl;
try {
const pu = new URL(productUrl);
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
productUrlFinal = pu.toString();
} catch {
/* keep */
} }
const returnUrl = if (isPromoVodTitle(played.title)) {
(opts.returnUrl && String(opts.returnUrl).trim()) || throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
`https://content.viaplay.com/${opts.deviceKey}/sport`;
const sectionPath = (opts.sectionPath && String(opts.sectionPath).trim()) || "/sport";
const u = new URL("https://play.viaplay.com/api/stream/byguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
u.searchParams.set("deviceType", "pc");
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("guid", guid);
u.searchParams.set("cse", "true");
u.searchParams.set("producturl", productUrlFinal);
u.searchParams.set("returnurl", returnUrl);
u.searchParams.set("sectionPath", sectionPath);
u.searchParams.set("defaultAvailabilityContext", "svod");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
const retry = await fetchJson(u.toString(), {
headers: authHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const retryBody = asRecord(retry.json);
if (retry.status === 200 && retryBody) {
played = parsePlayStreamBody(retryBody, guid);
if (!played.title && pageTitle) played.title = pageTitle;
} }
if (!played.title && pageTitle && !isPromoVodTitle(pageTitle)) {
played.title = pageTitle;
} }
return played; return played;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
throw lastError || new Error("Viaplay VOD-play mislukt");
} }
/** Bouw MPD-URL met EPG start/stop kap (seconden). */ /** Bouw MPD-URL met EPG start/stop kap (seconden). */