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:
parent
2e6dc3bab9
commit
2aa35b663e
1 changed files with 166 additions and 95 deletions
|
|
@ -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,110 +890,90 @@ 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;
|
const returnUrl =
|
||||||
|
(opts.returnUrl && String(opts.returnUrl).trim()) ||
|
||||||
|
`https://content.viaplay.com/${opts.deviceKey}/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);
|
||||||
|
let productHref: string | null = null;
|
||||||
let pageTitle: string | null = null;
|
let pageTitle: string | null = null;
|
||||||
try {
|
try {
|
||||||
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
|
const meta = await fetchProductStreamMeta(creds, productUrl, opts.timeoutMs);
|
||||||
streamHref = meta.streamHref;
|
|
||||||
pageTitle = meta.title;
|
pageTitle = meta.title;
|
||||||
|
if (meta.streamHref) {
|
||||||
|
productHref = meta.streamHref;
|
||||||
|
rememberStreamHref(guid, meta.streamHref);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* fallback hieronder */
|
/* fallback hieronder */
|
||||||
}
|
}
|
||||||
|
|
||||||
let playUrl: string;
|
const playUrls: string[] = [];
|
||||||
if (streamHref && /play\.viaplay\.com\/api\/stream\/byguid/i.test(streamHref)) {
|
const seen = new Set<string>();
|
||||||
playUrl = expandViaplayStreamHref(streamHref, creds, { deviceKey: opts.deviceKey });
|
const pushExpanded = (href: string | null) => {
|
||||||
} else {
|
if (!href || !/play\.viaplay\.com\/api\/stream\/byguid/i.test(href)) return;
|
||||||
// Fallback: zelfde query als web-HAR (producturl + cse + sectionPath)
|
const expanded = expandViaplayStreamHref(href, creds, {
|
||||||
let productUrlFinal = productUrl;
|
deviceKey: opts.deviceKey,
|
||||||
try {
|
guid,
|
||||||
const pu = new URL(productUrl);
|
|
||||||
if (!pu.searchParams.has("partial")) pu.searchParams.set("partial", "true");
|
|
||||||
productUrlFinal = pu.toString();
|
|
||||||
} catch {
|
|
||||||
/* keep */
|
|
||||||
}
|
|
||||||
const returnUrl =
|
|
||||||
(opts.returnUrl && String(opts.returnUrl).trim()) ||
|
|
||||||
`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);
|
|
||||||
playUrl = u.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetchJson(playUrl, {
|
|
||||||
headers: authHeaders(creds),
|
|
||||||
timeoutMs: opts.timeoutMs,
|
|
||||||
});
|
|
||||||
const body = asRecord(res.json);
|
|
||||||
if (res.status !== 200 || !body) {
|
|
||||||
const msg =
|
|
||||||
(typeof body?.message === "string" && body.message) ||
|
|
||||||
`play-byguid mislukt (${res.status})`;
|
|
||||||
throw new Error(msg);
|
|
||||||
}
|
|
||||||
let played = parsePlayStreamBody(body, guid);
|
|
||||||
if (!played.title && pageTitle) played.title = pageTitle;
|
|
||||||
|
|
||||||
// Kapotte stream-href of ontbrekende producturl → promo-docu.
|
|
||||||
// Dan één keer opnieuw met expliciete HAR-query i.p.v. hard falen.
|
|
||||||
const titleLc = (played.title || "").toLowerCase();
|
|
||||||
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 =
|
|
||||||
(opts.returnUrl && String(opts.returnUrl).trim()) ||
|
|
||||||
`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 (seen.has(expanded)) return;
|
||||||
if (retry.status === 200 && retryBody) {
|
seen.add(expanded);
|
||||||
played = parsePlayStreamBody(retryBody, guid);
|
playUrls.push(expanded);
|
||||||
if (!played.title && pageTitle) played.title = pageTitle;
|
};
|
||||||
|
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, {
|
||||||
|
headers: authHeaders(creds),
|
||||||
|
timeoutMs: opts.timeoutMs,
|
||||||
|
});
|
||||||
|
const body = asRecord(res.json);
|
||||||
|
if (res.status !== 200 || !body) {
|
||||||
|
const msg =
|
||||||
|
(typeof body?.message === "string" && body.message) ||
|
||||||
|
`play-byguid mislukt (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
const played = parsePlayStreamBody(body, guid);
|
||||||
|
const responseGuid = playBodyGuid(body);
|
||||||
|
// 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)) {
|
||||||
|
played.title = pageTitle;
|
||||||
|
}
|
||||||
|
return played;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return played;
|
|
||||||
|
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). */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue