Play Viaplay catch-up like the website: cookies only, no Authorization.

This commit is contained in:
Jos Vooges | STH 2026-09-28 04:34:31 +02:00
parent 98c38826e1
commit 65c144d8b6
2 changed files with 135 additions and 25 deletions

View file

@ -44,6 +44,33 @@ export function authHeaders(creds: ViaplayAccountCreds): Record<string, string>
return headers;
}
/**
* HAR afspelen: play.viaplay.com / play-live krijgen GEEN Authorization.
* Alleen cookies + browser-UA. VIAPLAY-AT op play geeft Don't go-fallback.
*/
export function playHeaders(creds: ViaplayAccountCreds): Record<string, string> {
return {
Accept: "*/*",
"Accept-Language":
"nl,en;q=0.9,en-GB;q=0.8,en-US;q=0.7,es-ES;q=0.6,es;q=0.5,nl-NL;q=0.4",
Origin: "https://viaplay.com",
Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT,
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"sec-ch-ua":
'"Microsoft Edge";v="153", "Not_A Brand";v="8", "Chromium";v="153"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
Cookie: cookieHeader(creds),
};
}
/** TCF-string uit web-HAR (afspelen.har). */
const VIAPLAY_TC_STRING =
"CQq8bIAQq8bIAAcABBNLCxFsAP_gAEPgAAYgKaQQYAFAAYABAACsAFwAZAA4AB4AEAAJAAWgAyABoAD-AIoAjQBMAE4AKAAVAAtgBfgDCAMQAaAA3ABzAD8AICAQYBCACGAEYANEAcYA7wB4gD9AIQARMAi0BHAEdAJ2AVkAuoBigDOAGmAPUAe0A_gCAAETAIpARqAmKBOIE5AKTAWKAtkBeYC9gGMgMkAZYA1cBrgD-4IzAjOBO4CmgJ3QIQAFgAVAAuABwADwAIAASAAyABoAEUAJgAYgA0AB-AEIAIYAd4A_QCEQEcAR0AxQBnwD2gJiAUmAsUBeYDJAGWANXAf2BGYCdwAYSACAH4dADAD8ANEAixSAGAH4AaIBFgAA.f_wACHwAAAAA.IKaQQoAFAAYABAACsAFwAZAA4AB4AEAAJAAWgAyABoAD-AIoAjQBMAE4AKAAVAAtgBfgDCAMQAaAA3ABzAD8AICAQYBCACGAEYANEAcYA7wB4gD9AIQARMAi0BHAEdAJ2AVkAuoBigDOAGfANMAeoA9oB_AEAAImARSAjUBMUCcQJyAUmAsUBbIC8wF7AMZAZIAywBq4DXAH9wRmBGcCdwFNAAAA";
/**
* Vernieuw session/accessToken via persistentLogin (zoals de webapp na cookie-expiry).
* Zonder verse session-cookie geeft play.viaplay.com 403 of rare fallbacks.
@ -781,28 +808,55 @@ export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && /live-dash/i.test(mpdUrl);
}
function assertLivePlayNotSoftFail(played: ViaplayPlayResult): void {
function assertPlayNotSoftFail(
played: ViaplayPlayResult,
opts?: { requireLiveDash?: boolean }
): void {
if (isPromoVodTitle(played.title)) {
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
}
if (isDontGoPlaceholder(played.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream");
}
if (!isLiveDashMpd(played.mpdUrl)) {
if (opts?.requireLiveDash && !isLiveDashMpd(played.mpdUrl)) {
throw new Error(
"Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)"
);
}
}
export async function playByMediaGuid(
function sportContentUrls(
pageUrl: string | null | undefined,
deviceKey: string,
mediaGuid: string
): { productUrl: string; contextualUrl: string; contextualv2Url: string } {
const guidPath = mediaGuid.replace(/-NL$/i, "").toLowerCase();
let clean = "";
const raw = (pageUrl || "").trim();
if (raw) {
try {
const u = new URL(raw);
u.search = "";
clean = u.toString().replace(/\/$/, "");
} catch {
clean = "";
}
}
if (!clean) {
clean = `https://content.viaplay.com/${deviceKey}/sport/${guidPath}`;
}
return {
productUrl: `${clean}?partial=true`,
contextualUrl: `${clean}?contextual=true`,
contextualv2Url: `${clean}?contextualV2=true&productType=sport`,
};
}
function buildPlayLiveUrl(
creds: ViaplayAccountCreds,
mediaGuidRaw: string,
opts: { deviceKey: string; timeoutMs: number; pageUrl?: string | null }
): Promise<ViaplayPlayResult> {
const mediaGuid = toMediaGuid(mediaGuidRaw);
// Zelfde query als het werkende live-script. Extra HAR-svod/producturl
// triggert bij terugkijken de Don't go-VOD-fallback.
mediaGuid: string,
opts: { deviceKey: string; pageUrl?: string | null; harStyle: boolean }
): string {
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web");
@ -812,21 +866,74 @@ export async function playByMediaGuid(
u.searchParams.set("mediaGuid", mediaGuid);
u.searchParams.set("nocc", "autoplayer");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
if (!opts.harStyle) return u.toString();
const res = await fetchJson(u.toString(), {
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-live mislukt (${res.status})`;
throw new Error(msg);
const pages = sportContentUrls(opts.pageUrl, opts.deviceKey, mediaGuid);
const returnUrl = new URL(`https://content.viaplay.com/${opts.deviceKey}`);
returnUrl.searchParams.set("language", "nl");
if (creds.profileId) returnUrl.searchParams.set("profileId", creds.profileId);
u.searchParams.set("cse", "true");
u.searchParams.set(
"splitIOFlagIds",
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
);
u.searchParams.set("tcString", VIAPLAY_TC_STRING);
u.searchParams.set("returnurl", returnUrl.toString());
u.searchParams.set("producturl", pages.productUrl);
u.searchParams.set("contextualurl", pages.contextualUrl);
u.searchParams.set("contextualv2url", pages.contextualv2Url);
u.searchParams.set("sectionPath", "/sport");
u.searchParams.set("defaultAvailabilityContext", "svod");
u.searchParams.set("win10edge", "true");
return u.toString();
}
export async function playByMediaGuid(
creds: ViaplayAccountCreds,
mediaGuidRaw: string,
opts: {
deviceKey: string;
timeoutMs: number;
pageUrl?: string | null;
/** Terugkijken: echte VOD-MPD toestaan, Don't go niet. */
allowVod?: boolean;
}
const played = parsePlayStreamBody(body, mediaGuid);
assertLivePlayNotSoftFail(played);
return played;
): Promise<ViaplayPlayResult> {
const mediaGuid = toMediaGuid(mediaGuidRaw);
const urls = [
buildPlayLiveUrl(creds, mediaGuid, {
deviceKey: opts.deviceKey,
pageUrl: opts.pageUrl,
harStyle: true,
}),
buildPlayLiveUrl(creds, mediaGuid, {
deviceKey: opts.deviceKey,
harStyle: false,
}),
];
let lastError: Error | null = null;
for (const playUrl of urls) {
try {
const res = await fetchJson(playUrl, {
headers: playHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);
if (res.status !== 200 || !body) {
const msg =
(typeof body?.message === "string" && body.message) ||
`play-live mislukt (${res.status})`;
throw new Error(msg);
}
const played = parsePlayStreamBody(body, mediaGuid);
assertPlayNotSoftFail(played, { requireLiveDash: !opts.allowVod });
return played;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
}
}
throw lastError || new Error("Viaplay play-live mislukt");
}
function parsePlayStreamBody(
@ -875,6 +982,7 @@ export function expandViaplayStreamHref(
u.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
u.searchParams.set("deviceKey", opts.deviceKey);
u.searchParams.set("cse", "true");
u.searchParams.set("tcString", VIAPLAY_TC_STRING);
u.searchParams.set("win10edge", "true");
if (!u.searchParams.get("defaultAvailabilityContext")) {
u.searchParams.set("defaultAvailabilityContext", "svod");
@ -915,6 +1023,7 @@ function buildHarByguidUrl(
"splitIOFlagIds",
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
);
u.searchParams.set("tcString", VIAPLAY_TC_STRING);
u.searchParams.set("guid", guid);
u.searchParams.set("returnurl", opts.returnUrl);
u.searchParams.set("producturl", productUrlFinal);
@ -1055,7 +1164,7 @@ async function fetchProductStreamMeta(
/* keep */
}
const res = await fetchJson(url, {
headers: { ...authHeaders(creds), Accept: "application/json" },
headers: { ...playHeaders(creds), Accept: "application/json" },
timeoutMs,
});
if (res.status !== 200) {
@ -1166,7 +1275,7 @@ export async function playByGuid(
for (const playUrl of playUrls) {
try {
const res = await fetchJson(playUrl, {
headers: authHeaders(creds),
headers: playHeaders(creds),
timeoutMs: opts.timeoutMs,
});
const body = asRecord(res.json);

View file

@ -308,6 +308,7 @@ export async function ensureViaplayEventReady(
deviceKey: tweaks.deviceKey,
timeoutMs: tweaks.httpTimeoutMs,
pageUrl: event.pageUrl,
allowVod: ended,
});
const kap = isLiveDashMpd(play.mpdUrl)
? await resolveKapMpd(
@ -371,7 +372,7 @@ export async function ensureViaplayEventReady(
if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream");
}
if (!isLiveDashMpd(play.mpdUrl)) {
if (!ended && !isLiveDashMpd(play.mpdUrl)) {
throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)");
}