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; 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). * Vernieuw session/accessToken via persistentLogin (zoals de webapp na cookie-expiry).
* Zonder verse session-cookie geeft play.viaplay.com 403 of rare fallbacks. * 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); return !!mpdUrl && /live-dash/i.test(mpdUrl);
} }
function assertLivePlayNotSoftFail(played: ViaplayPlayResult): void { function assertPlayNotSoftFail(
played: ViaplayPlayResult,
opts?: { requireLiveDash?: boolean }
): void {
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}”)`);
} }
if (isDontGoPlaceholder(played.mpdUrl)) { if (isDontGoPlaceholder(played.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream"); 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( throw new Error(
"Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)" "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, creds: ViaplayAccountCreds,
mediaGuidRaw: string, mediaGuid: string,
opts: { deviceKey: string; timeoutMs: number; pageUrl?: string | null } opts: { deviceKey: string; pageUrl?: string | null; harStyle: boolean }
): Promise<ViaplayPlayResult> { ): string {
const mediaGuid = toMediaGuid(mediaGuidRaw);
// Zelfde query als het werkende live-script. Extra HAR-svod/producturl
// triggert bij terugkijken de Don't go-VOD-fallback.
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid"); const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
u.searchParams.set("deviceId", creds.deviceId || randomUUID()); u.searchParams.set("deviceId", creds.deviceId || randomUUID());
u.searchParams.set("deviceName", "web"); u.searchParams.set("deviceName", "web");
@ -812,21 +866,74 @@ export async function playByMediaGuid(
u.searchParams.set("mediaGuid", mediaGuid); u.searchParams.set("mediaGuid", mediaGuid);
u.searchParams.set("nocc", "autoplayer"); u.searchParams.set("nocc", "autoplayer");
if (creds.profileId) u.searchParams.set("profileId", creds.profileId); if (creds.profileId) u.searchParams.set("profileId", creds.profileId);
if (!opts.harStyle) return u.toString();
const res = await fetchJson(u.toString(), { const pages = sportContentUrls(opts.pageUrl, opts.deviceKey, mediaGuid);
headers: authHeaders(creds), const returnUrl = new URL(`https://content.viaplay.com/${opts.deviceKey}`);
timeoutMs: opts.timeoutMs, returnUrl.searchParams.set("language", "nl");
}); if (creds.profileId) returnUrl.searchParams.set("profileId", creds.profileId);
const body = asRecord(res.json); u.searchParams.set("cse", "true");
if (res.status !== 200 || !body) { u.searchParams.set(
const msg = "splitIOFlagIds",
(typeof body?.message === "string" && body.message) || "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
`play-live mislukt (${res.status})`; );
throw new Error(msg); 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); ): Promise<ViaplayPlayResult> {
assertLivePlayNotSoftFail(played); const mediaGuid = toMediaGuid(mediaGuidRaw);
return played; 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( function parsePlayStreamBody(
@ -875,6 +982,7 @@ 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("tcString", VIAPLAY_TC_STRING);
u.searchParams.set("win10edge", "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");
@ -915,6 +1023,7 @@ function buildHarByguidUrl(
"splitIOFlagIds", "splitIOFlagIds",
"all_sports_beyondLiveBeacon_on,web_player_pause_ads_on" "all_sports_beyondLiveBeacon_on,web_player_pause_ads_on"
); );
u.searchParams.set("tcString", VIAPLAY_TC_STRING);
u.searchParams.set("guid", guid); u.searchParams.set("guid", guid);
u.searchParams.set("returnurl", opts.returnUrl); u.searchParams.set("returnurl", opts.returnUrl);
u.searchParams.set("producturl", productUrlFinal); u.searchParams.set("producturl", productUrlFinal);
@ -1055,7 +1164,7 @@ async function fetchProductStreamMeta(
/* keep */ /* keep */
} }
const res = await fetchJson(url, { const res = await fetchJson(url, {
headers: { ...authHeaders(creds), Accept: "application/json" }, headers: { ...playHeaders(creds), Accept: "application/json" },
timeoutMs, timeoutMs,
}); });
if (res.status !== 200) { if (res.status !== 200) {
@ -1166,7 +1275,7 @@ export async function playByGuid(
for (const playUrl of playUrls) { for (const playUrl of playUrls) {
try { try {
const res = await fetchJson(playUrl, { const res = await fetchJson(playUrl, {
headers: authHeaders(creds), headers: playHeaders(creds),
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
}); });
const body = asRecord(res.json); const body = asRecord(res.json);

View file

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