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:
parent
379fbdab6d
commit
ebb986f6c1
10 changed files with 184 additions and 129 deletions
|
|
@ -1928,14 +1928,14 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
|||
streamUrl: played.streamUrl,
|
||||
keyCount: played.keys.length,
|
||||
promo,
|
||||
resolver: "viaplay-byguid-har-v7",
|
||||
resolver: "viaplay-byguid-v9",
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
guid,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
resolver: "viaplay-byguid-har-v7",
|
||||
resolver: "viaplay-byguid-v9",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
accessToken?: string;
|
||||
profileId?: string;
|
||||
persistentLogin?: string;
|
||||
boid?: string;
|
||||
};
|
||||
const { upsertViaplayAccountByEmail } = await import("../viaplay/settings");
|
||||
return upsertViaplayAccountByEmail(config.SESSION_SECRET, {
|
||||
|
|
@ -172,6 +173,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
|
|||
accessToken: String(body.accessToken ?? ""),
|
||||
profileId: body.profileId,
|
||||
persistentLogin: body.persistentLogin,
|
||||
boid: body.boid,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export type ViaplayAccountStored = {
|
|||
sessionEnc?: string;
|
||||
accessTokenEnc?: string;
|
||||
profileId?: string;
|
||||
/** Chrome __Secure-Viaplay-Boid — livescript stuurt hem mee. */
|
||||
boid?: string;
|
||||
deviceId?: string;
|
||||
/** Optioneel — voor latere tray/login. */
|
||||
passwordEnc?: string;
|
||||
|
|
@ -52,6 +54,7 @@ export type ViaplayAccountCreds = {
|
|||
session: string | null;
|
||||
accessToken: string | null;
|
||||
profileId: string | null;
|
||||
boid: string | null;
|
||||
deviceId: string;
|
||||
password: string | null;
|
||||
persistentLogin: string | null;
|
||||
|
|
@ -106,6 +109,7 @@ export function decryptAccount(
|
|||
? decryptSecret(account.accessTokenEnc, sessionSecret)
|
||||
: null,
|
||||
profileId: account.profileId?.trim() || null,
|
||||
boid: account.boid?.trim() || null,
|
||||
deviceId: account.deviceId?.trim() || randomUUID(),
|
||||
password: account.passwordEnc
|
||||
? decryptSecret(account.passwordEnc, sessionSecret)
|
||||
|
|
@ -127,6 +131,7 @@ export function createAccountStored(
|
|||
session?: string;
|
||||
accessToken?: string;
|
||||
profileId?: string;
|
||||
boid?: string;
|
||||
deviceId?: string;
|
||||
password?: string;
|
||||
persistentLogin?: string;
|
||||
|
|
@ -152,6 +157,7 @@ export function createAccountStored(
|
|||
? encryptSecret(accessToken, sessionSecret)
|
||||
: undefined,
|
||||
profileId: input.profileId?.trim() || undefined,
|
||||
boid: input.boid?.trim() || undefined,
|
||||
deviceId: input.deviceId?.trim() || randomUUID(),
|
||||
passwordEnc: password ? encryptSecret(password, sessionSecret) : undefined,
|
||||
persistentLoginEnc: input.persistentLogin?.trim()
|
||||
|
|
@ -178,6 +184,7 @@ export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] {
|
|||
sessionEnc: String(row.sessionEnc ?? "").trim() || undefined,
|
||||
accessTokenEnc: String(row.accessTokenEnc ?? "").trim() || undefined,
|
||||
profileId: String(row.profileId ?? "").trim() || undefined,
|
||||
boid: String(row.boid ?? "").trim() || undefined,
|
||||
deviceId: String(row.deviceId ?? "").trim() || undefined,
|
||||
passwordEnc: String(row.passwordEnc ?? "").trim() || undefined,
|
||||
persistentLoginEnc: String(row.persistentLoginEnc ?? "").trim() || undefined,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
|
|||
import type { ViaplayAccountCreds } 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 =
|
||||
"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 = {
|
||||
mediaGuid: string;
|
||||
|
|
@ -13,6 +14,8 @@ export type ViaplayPlayResult = {
|
|||
channelName: string | null;
|
||||
stationId: 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 {
|
||||
|
|
@ -21,20 +24,44 @@ function asRecord(v: unknown): Record<string, unknown> | 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 {
|
||||
const v = raw.trim();
|
||||
if (!v) return v;
|
||||
if (/%[0-9A-Fa-f]{2}/.test(v)) return v;
|
||||
if (/[{}"\s]/.test(v)) return encodeURIComponent(v);
|
||||
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
|
||||
return cookieValue(v.slice(1, -1));
|
||||
}
|
||||
if (/[{}"\s,;\\]/.test(v)) return encodeURIComponent(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 {
|
||||
const parts: string[] = [];
|
||||
if (creds.session) parts.push(`session=${cookieValue(creds.session)}`);
|
||||
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken.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) {
|
||||
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> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -107,20 +125,16 @@ export async function refreshViaplaySession(
|
|||
Origin: "https://viaplay.com",
|
||||
Referer: "https://viaplay.com/",
|
||||
"User-Agent": VIAPLAY_USER_AGENT,
|
||||
Cookie: `persistentLogin=${pl}`,
|
||||
Cookie: `persistentLogin=${cookieValue(pl)}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(opts.timeoutMs),
|
||||
});
|
||||
|
||||
const jar: Record<string, string> = {};
|
||||
const rawSet =
|
||||
typeof res.headers.getSetCookie === "function"
|
||||
? res.headers.getSetCookie()
|
||||
: [];
|
||||
for (const sc of rawSet) {
|
||||
const m = sc.match(/^([^=]+)=([^;]*)/);
|
||||
if (m) jar[m[1]!] = m[2]!;
|
||||
}
|
||||
const jar = parseSetCookieJar(rawSet);
|
||||
|
||||
let body: Record<string, unknown> | null = null;
|
||||
try {
|
||||
|
|
@ -147,6 +161,7 @@ export async function refreshViaplaySession(
|
|||
session,
|
||||
accessToken,
|
||||
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.
|
||||
* 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 =
|
||||
/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 {
|
||||
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 {
|
||||
return !!mpdUrl && /live-dash/i.test(mpdUrl);
|
||||
}
|
||||
|
||||
function assertPlayNotSoftFail(
|
||||
played: ViaplayPlayResult,
|
||||
opts?: { requireLiveDash?: boolean }
|
||||
): void {
|
||||
function assertPlayNotSoftFail(played: ViaplayPlayResult): 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 (/vod-dash/i.test(played.mpdUrl) && !played.licenseUrl) {
|
||||
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)) {
|
||||
if (!isLiveDashMpd(played.mpdUrl)) {
|
||||
throw new Error(
|
||||
"Viaplay gaf geen live-dash stream (Don't go/VOD-fallback — sessie of rechten)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (!played.licenseUrl) {
|
||||
throw new Error("Viaplay play-response mist Widevine-licentie (Don't go-fallback)");
|
||||
}
|
||||
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,
|
||||
mediaGuid: string,
|
||||
opts: { deviceKey: string; pageUrl?: string | null; harStyle: boolean }
|
||||
opts: { deviceKey: string }
|
||||
): string {
|
||||
const u = new URL("https://play-live.viaplay.com/api/stream/bymediaguid");
|
||||
u.searchParams.set("deviceId", creds.deviceId || randomUUID());
|
||||
|
|
@ -869,26 +889,6 @@ function buildPlayLiveUrl(
|
|||
u.searchParams.set("deviceKey", opts.deviceKey);
|
||||
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 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();
|
||||
}
|
||||
|
||||
|
|
@ -899,45 +899,27 @@ export async function playByMediaGuid(
|
|||
deviceKey: string;
|
||||
timeoutMs: number;
|
||||
pageUrl?: string | null;
|
||||
/** Terugkijken: echte VOD-MPD toestaan, Don't go niet. */
|
||||
allowVod?: boolean;
|
||||
}
|
||||
): Promise<ViaplayPlayResult> {
|
||||
void opts.pageUrl;
|
||||
void opts.allowVod;
|
||||
const mediaGuid = toMediaGuid(mediaGuidRaw);
|
||||
const urls = [
|
||||
buildPlayLiveUrl(creds, mediaGuid, {
|
||||
deviceKey: opts.deviceKey,
|
||||
harStyle: false,
|
||||
}),
|
||||
buildPlayLiveUrl(creds, mediaGuid, {
|
||||
deviceKey: opts.deviceKey,
|
||||
pageUrl: opts.pageUrl,
|
||||
harStyle: true,
|
||||
}),
|
||||
];
|
||||
|
||||
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));
|
||||
}
|
||||
const playUrl = buildPlayLiveUrl(creds, mediaGuid, { deviceKey: opts.deviceKey });
|
||||
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);
|
||||
}
|
||||
throw lastError || new Error("Viaplay play-live mislukt");
|
||||
const played = parsePlayStreamBody(body, mediaGuid);
|
||||
assertPlayNotSoftFail(played);
|
||||
return played;
|
||||
}
|
||||
|
||||
function parsePlayStreamBody(
|
||||
|
|
@ -952,6 +934,7 @@ function parsePlayStreamBody(
|
|||
if (!mpdUrl) throw new Error("Geen viaplay:media in play-response");
|
||||
const product = asRecord(body.product);
|
||||
const channel = asRecord(body.channel) || asRecord(asRecord(body.streamingSession)?.channel);
|
||||
const durationRaw = Number(body.duration);
|
||||
return {
|
||||
mediaGuid: id,
|
||||
mpdUrl,
|
||||
|
|
@ -963,6 +946,8 @@ function parsePlayStreamBody(
|
|||
typeof asRecord(product?.content)?.title === "string"
|
||||
? String(asRecord(product!.content)!.title)
|
||||
: 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"
|
||||
);
|
||||
}
|
||||
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 productHref = asRecord(links?.["viaplay:product"])?.href;
|
||||
|
|
@ -1254,7 +1236,6 @@ export async function playByGuid(
|
|||
contextualv2FromStreamHref(listingHref) ||
|
||||
contextualv2FromStreamHref(productHref);
|
||||
|
||||
// Eerst exacte HAR-query (betrouwbaar), daarna eventuele stream-hrefs.
|
||||
const playUrls: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushUrl = (url: string) => {
|
||||
|
|
@ -1262,6 +1243,14 @@ export async function playByGuid(
|
|||
seen.add(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(
|
||||
buildHarByguidUrl(creds, guid, {
|
||||
deviceKey: opts.deviceKey,
|
||||
|
|
@ -1297,8 +1286,29 @@ export async function playByGuid(
|
|||
durationMs: pageDurationMs,
|
||||
title: pageTitle,
|
||||
});
|
||||
// Alleen aanvullen als play al een echte (niet-promo) titel heeft — nooit
|
||||
// pageTitle over een lege promo-response plakken (dat verborgt Don't go).
|
||||
const expectedMs = pageDurationMs || played.durationMs;
|
||||
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)) {
|
||||
played.title = pageTitle;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
fetchViaplayBootstrapPssh,
|
||||
isDontGoPlaceholder,
|
||||
isLiveDashMpd,
|
||||
mpdLooksLikeDontGo,
|
||||
VIAPLAY_USER_AGENT,
|
||||
} from "./client";
|
||||
import {
|
||||
|
|
@ -51,7 +52,7 @@ function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) {
|
|||
lastVodPlay = {
|
||||
...partial,
|
||||
at: new Date().toISOString(),
|
||||
resolver: "viaplay-byguid-har-v8",
|
||||
resolver: "viaplay-byguid-v9",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -209,9 +210,15 @@ export async function ensureViaplayEventReady(
|
|||
!!event.mpdUrl &&
|
||||
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 {
|
||||
streamUrl: event.mpdUrl!,
|
||||
streamUrl: event.mpdUrl,
|
||||
format: "dash",
|
||||
keys: cachedKeys,
|
||||
fromCache: true,
|
||||
|
|
@ -265,6 +272,7 @@ export async function ensureViaplayEventReady(
|
|||
if (
|
||||
cacheFresh &&
|
||||
event.mpdUrl &&
|
||||
isLiveDashMpd(event.mpdUrl) &&
|
||||
!isDontGoPlaceholder(event.mpdUrl) &&
|
||||
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)
|
||||
) {
|
||||
|
|
@ -297,6 +305,7 @@ export async function ensureViaplayEventReady(
|
|||
session: refreshed.session || undefined,
|
||||
accessToken: refreshed.accessToken || undefined,
|
||||
persistentLogin: refreshed.persistentLogin || undefined,
|
||||
boid: refreshed.boid || undefined,
|
||||
});
|
||||
} catch {
|
||||
/* continue with in-memory refresh */
|
||||
|
|
@ -308,7 +317,6 @@ export async function ensureViaplayEventReady(
|
|||
deviceKey: tweaks.deviceKey,
|
||||
timeoutMs: tweaks.httpTimeoutMs,
|
||||
pageUrl: event.pageUrl,
|
||||
allowVod: ended,
|
||||
});
|
||||
const kap = isLiveDashMpd(play.mpdUrl)
|
||||
? await resolveKapMpd(
|
||||
|
|
@ -507,6 +515,7 @@ async function resolveViaplayGuidPlayInner(
|
|||
session: refreshed.session || undefined,
|
||||
accessToken: refreshed.accessToken || undefined,
|
||||
persistentLogin: refreshed.persistentLogin || undefined,
|
||||
boid: refreshed.boid || undefined,
|
||||
});
|
||||
} catch {
|
||||
/* play mag doorgaan met in-memory refresh */
|
||||
|
|
@ -530,11 +539,19 @@ async function resolveViaplayGuidPlayInner(
|
|||
try {
|
||||
const mpd = await fetchMpdXml(play.mpdUrl, tweaks.httpTimeoutMs);
|
||||
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);
|
||||
mpdUrl = mpd.url;
|
||||
}
|
||||
} catch {
|
||||
/* speel base MPD */
|
||||
} catch (err) {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ export async function addViaplayAccount(
|
|||
session?: string;
|
||||
accessToken?: string;
|
||||
profileId?: string;
|
||||
boid?: string;
|
||||
password?: string;
|
||||
persistentLogin?: string;
|
||||
}
|
||||
|
|
@ -233,6 +234,7 @@ export async function upsertViaplayAccountByEmail(
|
|||
session: string;
|
||||
accessToken: string;
|
||||
profileId?: string;
|
||||
boid?: string;
|
||||
persistentLogin?: string;
|
||||
}
|
||||
) {
|
||||
|
|
@ -252,6 +254,7 @@ export async function upsertViaplayAccountByEmail(
|
|||
session: input.session,
|
||||
accessToken: input.accessToken,
|
||||
profileId: input.profileId,
|
||||
boid: input.boid,
|
||||
persistentLogin: input.persistentLogin,
|
||||
enabled: true,
|
||||
});
|
||||
|
|
@ -262,6 +265,7 @@ export async function upsertViaplayAccountByEmail(
|
|||
session: input.session,
|
||||
accessToken: input.accessToken,
|
||||
profileId: input.profileId,
|
||||
boid: input.boid,
|
||||
persistentLogin: input.persistentLogin,
|
||||
});
|
||||
}
|
||||
|
|
@ -276,6 +280,7 @@ export async function updateViaplayAccount(
|
|||
session?: string;
|
||||
accessToken?: string;
|
||||
profileId?: string;
|
||||
boid?: string;
|
||||
password?: string;
|
||||
persistentLogin?: string;
|
||||
clearCookies?: boolean;
|
||||
|
|
@ -296,11 +301,13 @@ export async function updateViaplayAccount(
|
|||
let sessionEnc = current.sessionEnc;
|
||||
let accessTokenEnc = current.accessTokenEnc;
|
||||
let profileId = current.profileId;
|
||||
let boid = current.boid;
|
||||
let persistentLoginEnc = current.persistentLoginEnc;
|
||||
if (input.clearCookies) {
|
||||
sessionEnc = undefined;
|
||||
accessTokenEnc = undefined;
|
||||
profileId = undefined;
|
||||
boid = undefined;
|
||||
persistentLoginEnc = undefined;
|
||||
}
|
||||
if (input.session?.trim()) {
|
||||
|
|
@ -316,6 +323,9 @@ export async function updateViaplayAccount(
|
|||
if (input.profileId !== undefined) {
|
||||
profileId = input.profileId.trim() || undefined;
|
||||
}
|
||||
if (input.boid !== undefined) {
|
||||
boid = input.boid.trim() || undefined;
|
||||
}
|
||||
if (input.persistentLogin?.trim()) {
|
||||
persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret);
|
||||
}
|
||||
|
|
@ -339,6 +349,7 @@ export async function updateViaplayAccount(
|
|||
sessionEnc,
|
||||
accessTokenEnc,
|
||||
profileId,
|
||||
boid,
|
||||
persistentLoginEnc,
|
||||
passwordEnc: input.password?.trim()
|
||||
? encryptSecret(input.password.trim(), sessionSecret)
|
||||
|
|
|
|||
|
|
@ -1096,7 +1096,7 @@ export function registerViewerRoutes(
|
|||
fallbackFormat: null,
|
||||
live: false,
|
||||
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */
|
||||
resolver: "viaplay-byguid-har-v8",
|
||||
resolver: "viaplay-byguid-v9",
|
||||
mediaPath: (() => {
|
||||
try {
|
||||
const u = new URL(played.streamUrl);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ async function pushViaplay(
|
|||
accessToken: string;
|
||||
profileId: string | null;
|
||||
persistentLogin: string | null;
|
||||
boid?: string | null;
|
||||
}
|
||||
) {
|
||||
const res = await fetch(`${cfg.apiUrl}/api/v1/agent/viaplay/tokens`, {
|
||||
|
|
@ -68,6 +69,7 @@ async function pushViaplay(
|
|||
accessToken: tokens.accessToken,
|
||||
profileId: tokens.profileId || undefined,
|
||||
persistentLogin: tokens.persistentLogin || undefined,
|
||||
boid: tokens.boid || undefined,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type ViaplayLoginResult = {
|
|||
accessToken: string;
|
||||
profileId: string | null;
|
||||
persistentLogin: string | null;
|
||||
boid: string | null;
|
||||
};
|
||||
|
||||
function profileDir(accountId: string): string {
|
||||
|
|
@ -96,8 +97,9 @@ async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult
|
|||
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "");
|
||||
const profileId = (byName.get("viaplay_profileId") || "").trim() || null;
|
||||
const persistentLogin = (byName.get("persistentLogin") || "").trim() || null;
|
||||
const boid = (byName.get("__Secure-Viaplay-Boid") || "").trim() || null;
|
||||
if (!session || !accessToken) return null;
|
||||
return { session, accessToken, profileId, persistentLogin };
|
||||
return { session, accessToken, profileId, persistentLogin, boid };
|
||||
}
|
||||
|
||||
async function launchProfile(userDataDir: string, headless: boolean) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type CapturedTokens = {
|
|||
accessToken: string;
|
||||
profileId: string | null;
|
||||
persistentLogin: string | null;
|
||||
boid: string | null;
|
||||
accessTokenExpMs: number | null;
|
||||
};
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
|
|||
if (!c.name || !c.value) continue;
|
||||
if (
|
||||
/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);
|
||||
}
|
||||
|
|
@ -64,6 +65,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
|
|||
.replace(/^(VIAPLAY-AT|MTG-AT|Bearer)\s+/i, "");
|
||||
const profileId = byName.get("viaplay_profileId")?.trim() || null;
|
||||
const persistentLogin = byName.get("persistentLogin")?.trim() || null;
|
||||
const boid = byName.get("__Secure-Viaplay-Boid")?.trim() || null;
|
||||
|
||||
if (!sessionVal || !accessToken) return null;
|
||||
return {
|
||||
|
|
@ -71,6 +73,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
|
|||
accessToken,
|
||||
profileId,
|
||||
persistentLogin,
|
||||
boid,
|
||||
accessTokenExpMs: jwtExpMs(accessToken),
|
||||
};
|
||||
}
|
||||
|
|
@ -93,6 +96,7 @@ export async function pushViaplayTokens(
|
|||
accessToken: tokens.accessToken,
|
||||
profileId: tokens.profileId || undefined,
|
||||
persistentLogin: tokens.persistentLogin || undefined,
|
||||
boid: tokens.boid || undefined,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
|
|
|
|||
Loading…
Reference in a new issue