Stop serving Viaplay Don't go: encode cookies like the live script, require live-dash, and reject promo MPDs by duration.

This commit is contained in:
Jos Vooges | STH 2026-09-28 04:57:55 +02:00
parent 379fbdab6d
commit ebb986f6c1
10 changed files with 184 additions and 129 deletions

View file

@ -1928,14 +1928,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
streamUrl: played.streamUrl, streamUrl: played.streamUrl,
keyCount: played.keys.length, keyCount: played.keys.length,
promo, promo,
resolver: "viaplay-byguid-har-v7", resolver: "viaplay-byguid-v9",
}; };
} catch (err) { } catch (err) {
return { return {
ok: false, ok: false,
guid, guid,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
resolver: "viaplay-byguid-har-v7", resolver: "viaplay-byguid-v9",
}; };
} }
} }

View file

@ -163,6 +163,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
accessToken?: string; accessToken?: string;
profileId?: string; profileId?: string;
persistentLogin?: string; persistentLogin?: string;
boid?: string;
}; };
const { upsertViaplayAccountByEmail } = await import("../viaplay/settings"); const { upsertViaplayAccountByEmail } = await import("../viaplay/settings");
return upsertViaplayAccountByEmail(config.SESSION_SECRET, { return upsertViaplayAccountByEmail(config.SESSION_SECRET, {
@ -172,6 +173,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
accessToken: String(body.accessToken ?? ""), accessToken: String(body.accessToken ?? ""),
profileId: body.profileId, profileId: body.profileId,
persistentLogin: body.persistentLogin, persistentLogin: body.persistentLogin,
boid: body.boid,
}); });
}); });

View file

@ -11,6 +11,8 @@ export type ViaplayAccountStored = {
sessionEnc?: string; sessionEnc?: string;
accessTokenEnc?: string; accessTokenEnc?: string;
profileId?: string; profileId?: string;
/** Chrome __Secure-Viaplay-Boid — livescript stuurt hem mee. */
boid?: string;
deviceId?: string; deviceId?: string;
/** Optioneel — voor latere tray/login. */ /** Optioneel — voor latere tray/login. */
passwordEnc?: string; passwordEnc?: string;
@ -52,6 +54,7 @@ export type ViaplayAccountCreds = {
session: string | null; session: string | null;
accessToken: string | null; accessToken: string | null;
profileId: string | null; profileId: string | null;
boid: string | null;
deviceId: string; deviceId: string;
password: string | null; password: string | null;
persistentLogin: string | null; persistentLogin: string | null;
@ -106,6 +109,7 @@ export function decryptAccount(
? decryptSecret(account.accessTokenEnc, sessionSecret) ? decryptSecret(account.accessTokenEnc, sessionSecret)
: null, : null,
profileId: account.profileId?.trim() || null, profileId: account.profileId?.trim() || null,
boid: account.boid?.trim() || null,
deviceId: account.deviceId?.trim() || randomUUID(), deviceId: account.deviceId?.trim() || randomUUID(),
password: account.passwordEnc password: account.passwordEnc
? decryptSecret(account.passwordEnc, sessionSecret) ? decryptSecret(account.passwordEnc, sessionSecret)
@ -127,6 +131,7 @@ export function createAccountStored(
session?: string; session?: string;
accessToken?: string; accessToken?: string;
profileId?: string; profileId?: string;
boid?: string;
deviceId?: string; deviceId?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
@ -152,6 +157,7 @@ export function createAccountStored(
? encryptSecret(accessToken, sessionSecret) ? encryptSecret(accessToken, sessionSecret)
: undefined, : undefined,
profileId: input.profileId?.trim() || undefined, profileId: input.profileId?.trim() || undefined,
boid: input.boid?.trim() || undefined,
deviceId: input.deviceId?.trim() || randomUUID(), deviceId: input.deviceId?.trim() || randomUUID(),
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined, passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
persistentLoginEnc: input.persistentLogin?.trim() persistentLoginEnc: input.persistentLogin?.trim()
@ -178,6 +184,7 @@ export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] {
sessionEnc: String(row.sessionEnc ?? "").trim() || undefined, sessionEnc: String(row.sessionEnc ?? "").trim() || undefined,
accessTokenEnc: String(row.accessTokenEnc ?? "").trim() || undefined, accessTokenEnc: String(row.accessTokenEnc ?? "").trim() || undefined,
profileId: String(row.profileId ?? "").trim() || undefined, profileId: String(row.profileId ?? "").trim() || undefined,
boid: String(row.boid ?? "").trim() || undefined,
deviceId: String(row.deviceId ?? "").trim() || undefined, deviceId: String(row.deviceId ?? "").trim() || undefined,
passwordEnc: String(row.passwordEnc ?? "").trim() || undefined, passwordEnc: String(row.passwordEnc ?? "").trim() || undefined,
persistentLoginEnc: String(row.persistentLoginEnc ?? "").trim() || undefined, persistentLoginEnc: String(row.persistentLoginEnc ?? "").trim() || undefined,

View file

@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import type { ViaplayAccountCreds } from "./accounts"; import type { ViaplayAccountCreds } from "./accounts";
import { toMediaGuid } from "./accounts"; import { toMediaGuid } from "./accounts";
/** Zelfde UA als het werkende livescript (viaplay3.py). Edge/153 + sec-ch-ua gaf Don't go. */
export const VIAPLAY_USER_AGENT = export const VIAPLAY_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36 Edg/153.0.0.0"; "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
export type ViaplayPlayResult = { export type ViaplayPlayResult = {
mediaGuid: string; mediaGuid: string;
@ -13,6 +14,8 @@ export type ViaplayPlayResult = {
channelName: string | null; channelName: string | null;
stationId: string | null; stationId: string | null;
title: string | null; title: string | null;
/** Product-duration uit play-JSON (niet de MPD — Don't go liegt hier). */
durationMs: number | null;
}; };
function asRecord(v: unknown): Record<string, unknown> | null { function asRecord(v: unknown): Record<string, unknown> | null {
@ -21,20 +24,44 @@ function asRecord(v: unknown): Record<string, unknown> | null {
: null; : null;
} }
/** Netscape/browser-cookies blijven encoded; decoded JSON in Cookie-header = Don't go. */ /**
* Electron/Playwright geeft cookies decoded (`s:j:{…}`).
* In de Cookie-header moeten die percent-encoded — anders parst Viaplay
* de session niet en geeft 200 + Don't go.
* Al encoded (`s%3Aj%3A%7B`) laten we met rust.
*/
function cookieValue(raw: string): string { function cookieValue(raw: string): string {
const v = raw.trim(); const v = raw.trim();
if (!v) return v; if (!v) return v;
if (/%[0-9A-Fa-f]{2}/.test(v)) return v; if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
if (/[{}"\s]/.test(v)) return encodeURIComponent(v); return cookieValue(v.slice(1, -1));
}
if (/[{}"\s,;\\]/.test(v)) return encodeURIComponent(v);
return v; return v;
} }
function parseSetCookieJar(setCookies: string[]): Record<string, string> {
const jar: Record<string, string> = {};
for (const sc of setCookies) {
const m = sc.match(/^([^=]+)=([^;]*)/);
if (!m) continue;
let v = m[2] ?? "";
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
v = v.slice(1, -1);
}
jar[m[1]!] = v;
}
return jar;
}
function cookieHeader(creds: ViaplayAccountCreds): string { function cookieHeader(creds: ViaplayAccountCreds): string {
const parts: string[] = []; const parts: string[] = [];
if (creds.session) parts.push(`session=${cookieValue(creds.session)}`); if (creds.session) parts.push(`session=${cookieValue(creds.session)}`);
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken.trim()}`); if (creds.accessToken) parts.push(`accessToken=${creds.accessToken.trim()}`);
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId.trim()}`); if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId.trim()}`);
if (creds.boid) {
parts.push(`__Secure-Viaplay-Boid=${creds.boid.trim()}`);
}
if (creds.persistentLogin) { if (creds.persistentLogin) {
parts.push(`persistentLogin=${cookieValue(creds.persistentLogin)}`); parts.push(`persistentLogin=${cookieValue(creds.persistentLogin)}`);
} }
@ -62,18 +89,9 @@ export function authHeaders(creds: ViaplayAccountCreds): Record<string, string>
export function playHeaders(creds: ViaplayAccountCreds): Record<string, string> { export function playHeaders(creds: ViaplayAccountCreds): Record<string, string> {
return { return {
Accept: "*/*", 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", Origin: "https://viaplay.com",
Referer: "https://viaplay.com/", Referer: "https://viaplay.com/",
"User-Agent": VIAPLAY_USER_AGENT, "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), Cookie: cookieHeader(creds),
}; };
} }
@ -107,20 +125,16 @@ export async function refreshViaplaySession(
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: `persistentLogin=${pl}`, Cookie: `persistentLogin=${cookieValue(pl)}`,
}, },
signal: AbortSignal.timeout(opts.timeoutMs), signal: AbortSignal.timeout(opts.timeoutMs),
}); });
const jar: Record<string, string> = {};
const rawSet = const rawSet =
typeof res.headers.getSetCookie === "function" typeof res.headers.getSetCookie === "function"
? res.headers.getSetCookie() ? res.headers.getSetCookie()
: []; : [];
for (const sc of rawSet) { const jar = parseSetCookieJar(rawSet);
const m = sc.match(/^([^=]+)=([^;]*)/);
if (m) jar[m[1]!] = m[2]!;
}
let body: Record<string, unknown> | null = null; let body: Record<string, unknown> | null = null;
try { try {
@ -147,6 +161,7 @@ export async function refreshViaplaySession(
session, session,
accessToken, accessToken,
persistentLogin: nextPl, persistentLogin: nextPl,
boid: jar["__Secure-Viaplay-Boid"] || creds.boid,
}; };
} }
@ -793,73 +808,78 @@ function extractProducts(json: unknown): ViaplayEpgProduct[] {
/** /**
* Don't go is Viaplay's soft-fail (200 + promo-VOD) i.p.v. 403. * Don't go is Viaplay's soft-fail (200 + promo-VOD) i.p.v. 403.
* Titel/guid van het event kloppen vaak wél; alleen de media is fout. * Titel/guid/body.duration van het gevraagde product kloppen wél;
* alleen de media (en de echte MPD-duur) is de promo-docu (~PT43M36S).
*/ */
const VIAPLAY_DONT_GO_MEDIA_RE = const VIAPLAY_DONT_GO_MEDIA_RE =
/88000100-1778484912807|\b7c4cd64\b|\/dont[-_]?go/i; /88000100-1778484912807|\b7c4cd64\b|\/dont[-_]?go/i;
/** Bekende mediaPresentationDuration van de Don't go-docu. */
const DONT_GO_MPD_DURATION_MS = 2_616_000;
export function isDontGoPlaceholder(mpdUrl: string | null | undefined): boolean { export function isDontGoPlaceholder(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && VIAPLAY_DONT_GO_MEDIA_RE.test(mpdUrl); return !!mpdUrl && VIAPLAY_DONT_GO_MEDIA_RE.test(mpdUrl);
} }
export function parseIsoDurationMs(iso: string): number | null {
const t = String(iso || "").trim();
if (!t) return null;
const m = t.match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/i);
if (!m) return null;
const h = Number(m[1] || 0);
const min = Number(m[2] || 0);
const s = Number(m[3] || 0);
if (![h, min, s].some((n) => Number.isFinite(n) && n > 0)) return null;
return Math.round(((h * 60 + min) * 60 + s) * 1000);
}
export function parseMpdDurationMs(xml: string): number | null {
const raw =
xml.match(/\bmediaPresentationDuration=["']([^"']+)["']/i)?.[1] ||
xml.match(/<Period\b[^>]*\bduration=["']([^"']+)["']/i)?.[1];
return raw ? parseIsoDurationMs(raw) : null;
}
export function mpdLooksLikeDontGo(
xml: string,
expectedDurationMs?: number | null
): boolean {
if (/don'?t\s*go|viaplay\s*presents/i.test(xml)) return true;
const mpdMs = parseMpdDurationMs(xml);
if (mpdMs == null) return false;
if (Math.abs(mpdMs - DONT_GO_MPD_DURATION_MS) <= 4_000) return true;
const expected = Number(expectedDurationMs);
if (Number.isFinite(expected) && expected > 30_000) {
const ratio = mpdMs / expected;
if (ratio < 0.55 || ratio > 1.8) return true;
}
return false;
}
export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean { export function isLiveDashMpd(mpdUrl: string | null | undefined): boolean {
return !!mpdUrl && /live-dash/i.test(mpdUrl); return !!mpdUrl && /live-dash/i.test(mpdUrl);
} }
function assertPlayNotSoftFail( function assertPlayNotSoftFail(played: ViaplayPlayResult): void {
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 (/vod-dash/i.test(played.mpdUrl) && !played.licenseUrl) { if (!isLiveDashMpd(played.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-VOD zonder licentie");
}
if (!played.licenseUrl) {
throw new Error("Viaplay play-response mist Widevine-licentie (Don't go-fallback)");
}
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)"
); );
} }
} if (!played.licenseUrl) {
throw new Error("Viaplay play-response mist Widevine-licentie (Don't go-fallback)");
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( function buildPlayLiveUrl(
creds: ViaplayAccountCreds, creds: ViaplayAccountCreds,
mediaGuid: string, mediaGuid: string,
opts: { deviceKey: string; pageUrl?: string | null; harStyle: boolean } opts: { deviceKey: string }
): string { ): string {
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());
@ -869,26 +889,6 @@ function buildPlayLiveUrl(
u.searchParams.set("deviceKey", opts.deviceKey); u.searchParams.set("deviceKey", opts.deviceKey);
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 (!opts.harStyle) return u.toString();
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(); return u.toString();
} }
@ -899,45 +899,27 @@ export async function playByMediaGuid(
deviceKey: string; deviceKey: string;
timeoutMs: number; timeoutMs: number;
pageUrl?: string | null; pageUrl?: string | null;
/** Terugkijken: echte VOD-MPD toestaan, Don't go niet. */
allowVod?: boolean; allowVod?: boolean;
} }
): Promise<ViaplayPlayResult> { ): Promise<ViaplayPlayResult> {
void opts.pageUrl;
void opts.allowVod;
const mediaGuid = toMediaGuid(mediaGuidRaw); const mediaGuid = toMediaGuid(mediaGuidRaw);
const urls = [ const playUrl = buildPlayLiveUrl(creds, mediaGuid, { deviceKey: opts.deviceKey });
buildPlayLiveUrl(creds, mediaGuid, { const res = await fetchJson(playUrl, {
deviceKey: opts.deviceKey, headers: playHeaders(creds),
harStyle: false, timeoutMs: opts.timeoutMs,
}), });
buildPlayLiveUrl(creds, mediaGuid, { const body = asRecord(res.json);
deviceKey: opts.deviceKey, if (res.status !== 200 || !body) {
pageUrl: opts.pageUrl, const msg =
harStyle: true, (typeof body?.message === "string" && body.message) ||
}), `play-live mislukt (${res.status})`;
]; throw new Error(msg);
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"); const played = parsePlayStreamBody(body, mediaGuid);
assertPlayNotSoftFail(played);
return played;
} }
function parsePlayStreamBody( function parsePlayStreamBody(
@ -952,6 +934,7 @@ function parsePlayStreamBody(
if (!mpdUrl) throw new Error("Geen viaplay:media in play-response"); if (!mpdUrl) throw new Error("Geen viaplay:media in play-response");
const product = asRecord(body.product); const product = asRecord(body.product);
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel); const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
const durationRaw = Number(body.duration);
return { return {
mediaGuid: id, mediaGuid: id,
mpdUrl, mpdUrl,
@ -963,6 +946,8 @@ function parsePlayStreamBody(
typeof asRecord(product?.content)?.title === "string" typeof asRecord(product?.content)?.title === "string"
? String(asRecord(product!.content)!.title) ? String(asRecord(product!.content)!.title)
: null, : null,
durationMs:
Number.isFinite(durationRaw) && durationRaw > 0 ? durationRaw : null,
}; };
} }
@ -1080,9 +1065,6 @@ function assertVodPlayMatchesGuid(
"Viaplay gaf Don't go-placeholder i.p.v. de gevraagde stream" "Viaplay gaf Don't go-placeholder i.p.v. de gevraagde stream"
); );
} }
if (/vod-dash/i.test(played.mpdUrl) && !played.licenseUrl) {
throw new Error("Viaplay gaf Don't go-VOD zonder licentie");
}
const links = asRecord(body._links); const links = asRecord(body._links);
const productHref = asRecord(links?.["viaplay:product"])?.href; const productHref = asRecord(links?.["viaplay:product"])?.href;
@ -1254,7 +1236,6 @@ export async function playByGuid(
contextualv2FromStreamHref(listingHref) || contextualv2FromStreamHref(listingHref) ||
contextualv2FromStreamHref(productHref); 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 pushUrl = (url: string) => { const pushUrl = (url: string) => {
@ -1262,6 +1243,14 @@ export async function playByGuid(
seen.add(url); seen.add(url);
playUrls.push(url); playUrls.push(url);
}; };
const simple = new URL("https://play.viaplay.com/api/stream/byguid");
simple.searchParams.set("deviceId", creds.deviceId || randomUUID());
simple.searchParams.set("deviceName", "web");
simple.searchParams.set("deviceType", "pc");
simple.searchParams.set("userAgent", VIAPLAY_USER_AGENT);
simple.searchParams.set("deviceKey", opts.deviceKey);
simple.searchParams.set("guid", guid);
pushUrl(simple.toString());
pushUrl( pushUrl(
buildHarByguidUrl(creds, guid, { buildHarByguidUrl(creds, guid, {
deviceKey: opts.deviceKey, deviceKey: opts.deviceKey,
@ -1297,8 +1286,29 @@ export async function playByGuid(
durationMs: pageDurationMs, durationMs: pageDurationMs,
title: pageTitle, title: pageTitle,
}); });
// Alleen aanvullen als play al een echte (niet-promo) titel heeft — nooit const expectedMs = pageDurationMs || played.durationMs;
// pageTitle over een lege promo-response plakken (dat verborgt Don't go). let mpdChecked = false;
try {
const mpd = await fetchMpdXml(played.mpdUrl, opts.timeoutMs);
if (mpd.status === 200 && /<MPD[\s>]/i.test(mpd.xml)) {
mpdChecked = true;
if (
isDontGoPlaceholder(mpd.url) ||
mpdLooksLikeDontGo(mpd.xml, expectedMs)
) {
throw new Error(
"Viaplay MPD is Don't go-placeholder (duur/media mismatch)"
);
}
}
} catch (err) {
if (err instanceof Error && /Don't go/i.test(err.message)) throw err;
}
if (!mpdChecked && /vod-dash/i.test(played.mpdUrl) && !played.licenseUrl) {
throw new Error(
"VOD zonder licentie en MPD niet te valideren — Don't go-risico"
);
}
if (!played.title?.trim() && pageTitle && !isPromoVodTitle(pageTitle)) { if (!played.title?.trim() && pageTitle && !isPromoVodTitle(pageTitle)) {
played.title = pageTitle; played.title = pageTitle;
} }

View file

@ -16,6 +16,7 @@ import {
fetchViaplayBootstrapPssh, fetchViaplayBootstrapPssh,
isDontGoPlaceholder, isDontGoPlaceholder,
isLiveDashMpd, isLiveDashMpd,
mpdLooksLikeDontGo,
VIAPLAY_USER_AGENT, VIAPLAY_USER_AGENT,
} from "./client"; } from "./client";
import { import {
@ -51,7 +52,7 @@ function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) {
lastVodPlay = { lastVodPlay = {
...partial, ...partial,
at: new Date().toISOString(), at: new Date().toISOString(),
resolver: "viaplay-byguid-har-v8", resolver: "viaplay-byguid-v9",
}; };
} }
@ -209,9 +210,15 @@ export async function ensureViaplayEventReady(
!!event.mpdUrl && !!event.mpdUrl &&
isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes); isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes);
if (cacheFresh && cachedKeys.length && !isDontGoPlaceholder(event.mpdUrl)) { if (
cacheFresh &&
cachedKeys.length &&
event.mpdUrl &&
isLiveDashMpd(event.mpdUrl) &&
!isDontGoPlaceholder(event.mpdUrl)
) {
return { return {
streamUrl: event.mpdUrl!, streamUrl: event.mpdUrl,
format: "dash", format: "dash",
keys: cachedKeys, keys: cachedKeys,
fromCache: true, fromCache: true,
@ -265,6 +272,7 @@ export async function ensureViaplayEventReady(
if ( if (
cacheFresh && cacheFresh &&
event.mpdUrl && event.mpdUrl &&
isLiveDashMpd(event.mpdUrl) &&
!isDontGoPlaceholder(event.mpdUrl) && !isDontGoPlaceholder(event.mpdUrl) &&
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) !(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)
) { ) {
@ -297,6 +305,7 @@ export async function ensureViaplayEventReady(
session: refreshed.session || undefined, session: refreshed.session || undefined,
accessToken: refreshed.accessToken || undefined, accessToken: refreshed.accessToken || undefined,
persistentLogin: refreshed.persistentLogin || undefined, persistentLogin: refreshed.persistentLogin || undefined,
boid: refreshed.boid || undefined,
}); });
} catch { } catch {
/* continue with in-memory refresh */ /* continue with in-memory refresh */
@ -308,7 +317,6 @@ 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(
@ -507,6 +515,7 @@ async function resolveViaplayGuidPlayInner(
session: refreshed.session || undefined, session: refreshed.session || undefined,
accessToken: refreshed.accessToken || undefined, accessToken: refreshed.accessToken || undefined,
persistentLogin: refreshed.persistentLogin || undefined, persistentLogin: refreshed.persistentLogin || undefined,
boid: refreshed.boid || undefined,
}); });
} catch { } catch {
/* play mag doorgaan met in-memory refresh */ /* play mag doorgaan met in-memory refresh */
@ -530,11 +539,19 @@ async function resolveViaplayGuidPlayInner(
try { try {
const mpd = await fetchMpdXml(play.mpdUrl, tweaks.httpTimeoutMs); const mpd = await fetchMpdXml(play.mpdUrl, tweaks.httpTimeoutMs);
if (mpd.status === 200 && /<MPD[\s>]/i.test(mpd.xml)) { if (mpd.status === 200 && /<MPD[\s>]/i.test(mpd.xml)) {
if (
isDontGoPlaceholder(mpd.url) ||
mpdLooksLikeDontGo(mpd.xml, play.durationMs)
) {
throw new Error(
"Viaplay MPD is Don't go-placeholder (duur/media mismatch)"
);
}
pssh = extractPsshFromMpd(mpd.xml); pssh = extractPsshFromMpd(mpd.xml);
mpdUrl = mpd.url; mpdUrl = mpd.url;
} }
} catch { } catch (err) {
/* speel base MPD */ if (err instanceof Error && /Don't go/i.test(err.message)) throw err;
} }
// Clear VOD (HAR: geen license) — nooit stale ClearKey van eerdere plays plakken. // Clear VOD (HAR: geen license) — nooit stale ClearKey van eerdere plays plakken.

View file

@ -202,6 +202,7 @@ export async function addViaplayAccount(
session?: string; session?: string;
accessToken?: string; accessToken?: string;
profileId?: string; profileId?: string;
boid?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
} }
@ -233,6 +234,7 @@ export async function upsertViaplayAccountByEmail(
session: string; session: string;
accessToken: string; accessToken: string;
profileId?: string; profileId?: string;
boid?: string;
persistentLogin?: string; persistentLogin?: string;
} }
) { ) {
@ -252,6 +254,7 @@ export async function upsertViaplayAccountByEmail(
session: input.session, session: input.session,
accessToken: input.accessToken, accessToken: input.accessToken,
profileId: input.profileId, profileId: input.profileId,
boid: input.boid,
persistentLogin: input.persistentLogin, persistentLogin: input.persistentLogin,
enabled: true, enabled: true,
}); });
@ -262,6 +265,7 @@ export async function upsertViaplayAccountByEmail(
session: input.session, session: input.session,
accessToken: input.accessToken, accessToken: input.accessToken,
profileId: input.profileId, profileId: input.profileId,
boid: input.boid,
persistentLogin: input.persistentLogin, persistentLogin: input.persistentLogin,
}); });
} }
@ -276,6 +280,7 @@ export async function updateViaplayAccount(
session?: string; session?: string;
accessToken?: string; accessToken?: string;
profileId?: string; profileId?: string;
boid?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
clearCookies?: boolean; clearCookies?: boolean;
@ -296,11 +301,13 @@ export async function updateViaplayAccount(
let sessionEnc = current.sessionEnc; let sessionEnc = current.sessionEnc;
let accessTokenEnc = current.accessTokenEnc; let accessTokenEnc = current.accessTokenEnc;
let profileId = current.profileId; let profileId = current.profileId;
let boid = current.boid;
let persistentLoginEnc = current.persistentLoginEnc; let persistentLoginEnc = current.persistentLoginEnc;
if (input.clearCookies) { if (input.clearCookies) {
sessionEnc = undefined; sessionEnc = undefined;
accessTokenEnc = undefined; accessTokenEnc = undefined;
profileId = undefined; profileId = undefined;
boid = undefined;
persistentLoginEnc = undefined; persistentLoginEnc = undefined;
} }
if (input.session?.trim()) { if (input.session?.trim()) {
@ -316,6 +323,9 @@ export async function updateViaplayAccount(
if (input.profileId !== undefined) { if (input.profileId !== undefined) {
profileId = input.profileId.trim() || undefined; profileId = input.profileId.trim() || undefined;
} }
if (input.boid !== undefined) {
boid = input.boid.trim() || undefined;
}
if (input.persistentLogin?.trim()) { if (input.persistentLogin?.trim()) {
persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret); persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret);
} }
@ -339,6 +349,7 @@ export async function updateViaplayAccount(
sessionEnc, sessionEnc,
accessTokenEnc, accessTokenEnc,
profileId, profileId,
boid,
persistentLoginEnc, persistentLoginEnc,
passwordEnc: input.password?.trim() passwordEnc: input.password?.trim()
? encryptSecret(input.password.trim(), sessionSecret) ? encryptSecret(input.password.trim(), sessionSecret)

View file

@ -1096,7 +1096,7 @@ export function registerViewerRoutes(
fallbackFormat: null, fallbackFormat: null,
live: false, live: false,
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */
resolver: "viaplay-byguid-har-v8", resolver: "viaplay-byguid-v9",
mediaPath: (() => { mediaPath: (() => {
try { try {
const u = new URL(played.streamUrl); const u = new URL(played.streamUrl);

View file

@ -53,6 +53,7 @@ async function pushViaplay(
accessToken: string; accessToken: string;
profileId: string | null; profileId: string | null;
persistentLogin: string | null; persistentLogin: string | null;
boid?: string | null;
} }
) { ) {
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/viaplay/tokens`, { const res = await fetch(`${cfg.apiUrl}/api/v1/agent/viaplay/tokens`, {
@ -68,6 +69,7 @@ async function pushViaplay(
accessToken: tokens.accessToken, accessToken: tokens.accessToken,
profileId: tokens.profileId || undefined, profileId: tokens.profileId || undefined,
persistentLogin: tokens.persistentLogin || undefined, persistentLogin: tokens.persistentLogin || undefined,
boid: tokens.boid || undefined,
}), }),
}); });
const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };

View file

@ -15,6 +15,7 @@ export type ViaplayLoginResult = {
accessToken: string; accessToken: string;
profileId: string | null; profileId: string | null;
persistentLogin: string | null; persistentLogin: string | null;
boid: string | null;
}; };
function profileDir(accountId: string): string { function profileDir(accountId: string): string {
@ -96,8 +97,9 @@ async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, ""); .replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "");
const profileId = (byName.get("viaplay_profileId") || "").trim() || null; const profileId = (byName.get("viaplay_profileId") || "").trim() || null;
const persistentLogin = (byName.get("persistentLogin") || "").trim() || null; const persistentLogin = (byName.get("persistentLogin") || "").trim() || null;
const boid = (byName.get("__Secure-Viaplay-Boid") || "").trim() || null;
if (!session || !accessToken) return null; if (!session || !accessToken) return null;
return { session, accessToken, profileId, persistentLogin }; return { session, accessToken, profileId, persistentLogin, boid };
} }
async function launchProfile(userDataDir: string, headless: boolean) { async function launchProfile(userDataDir: string, headless: boolean) {

View file

@ -6,6 +6,7 @@ export type CapturedTokens = {
accessToken: string; accessToken: string;
profileId: string | null; profileId: string | null;
persistentLogin: string | null; persistentLogin: string | null;
boid: string | null;
accessTokenExpMs: number | null; accessTokenExpMs: number | null;
}; };
@ -49,7 +50,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
if (!c.name || !c.value) continue; if (!c.name || !c.value) continue;
if ( if (
/viaplay|mtg-api|login\.viaplay/i.test(c.domain || "") || /viaplay|mtg-api|login\.viaplay/i.test(c.domain || "") ||
["session", "accessToken", "viaplay_profileId", "persistentLogin"].includes(c.name) ["session", "accessToken", "viaplay_profileId", "persistentLogin", "__Secure-Viaplay-Boid"].includes(c.name)
) { ) {
byName.set(c.name, c.value); byName.set(c.name, c.value);
} }
@ -64,6 +65,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, ""); .replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "");
const profileId = byName.get("viaplay_profileId")?.trim() || null; const profileId = byName.get("viaplay_profileId")?.trim() || null;
const persistentLogin = byName.get("persistentLogin")?.trim() || null; const persistentLogin = byName.get("persistentLogin")?.trim() || null;
const boid = byName.get("__Secure-Viaplay-Boid")?.trim() || null;
if (!sessionVal || !accessToken) return null; if (!sessionVal || !accessToken) return null;
return { return {
@ -71,6 +73,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
accessToken, accessToken,
profileId, profileId,
persistentLogin, persistentLogin,
boid,
accessTokenExpMs: jwtExpMs(accessToken), accessTokenExpMs: jwtExpMs(accessToken),
}; };
} }
@ -93,6 +96,7 @@ export async function pushViaplayTokens(
accessToken: tokens.accessToken, accessToken: tokens.accessToken,
profileId: tokens.profileId || undefined, profileId: tokens.profileId || undefined,
persistentLogin: tokens.persistentLogin || undefined, persistentLogin: tokens.persistentLogin || undefined,
boid: tokens.boid || undefined,
}), }),
}); });
const data = (await res.json().catch(() => ({}))) as { const data = (await res.json().catch(() => ({}))) as {