Detect Viaplay Don't go via play-log and send the full cookie jar including DataDome.

This commit is contained in:
Jos Vooges | STH 2026-09-28 18:03:07 +02:00
parent ebb986f6c1
commit 8bf3b43444
13 changed files with 509 additions and 52 deletions

View file

@ -20,6 +20,10 @@ type ViaplayAccount = {
hasAccessToken: boolean; hasAccessToken: boolean;
hasProfileId: boolean; hasProfileId: boolean;
hasPassword: boolean; hasPassword: boolean;
hasCookieJar?: boolean;
hasDatadome?: boolean;
hasBoid?: boolean;
cookieNames?: string[];
enabled: boolean; enabled: boolean;
eventCount: number; eventCount: number;
healthStatus: "ok" | "dead" | "unknown"; healthStatus: "ok" | "dead" | "unknown";
@ -30,6 +34,30 @@ type ViaplayAccount = {
overviewCheckedAt: string | null; overviewCheckedAt: string | null;
}; };
type PlayLogEntry = {
at: string;
kind: "live" | "vod";
guid: string;
ok: boolean;
title: string | null;
accountLabel: string | null;
mediaPath: string | null;
hasLicense: boolean;
dontGo: boolean;
dontGoReason: string | null;
error: string | null;
cookies: {
names: string[];
count: number;
sessionStoredDecoded: boolean;
sessionStoredEncoded: boolean;
hasDatadome: boolean;
hasBoid: boolean;
hasClientId: boolean;
hasPersistentLogin: boolean;
};
};
type ViaplaySettings = { type ViaplaySettings = {
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
@ -62,6 +90,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
const [tweaks, setTweaks] = useState<ViaplayTweaks>({}); const [tweaks, setTweaks] = useState<ViaplayTweaks>({});
const [showTweaks, setShowTweaks] = useState(false); const [showTweaks, setShowTweaks] = useState(false);
const [showCookies, setShowCookies] = useState(false); const [showCookies, setShowCookies] = useState(false);
const [playLog, setPlayLog] = useState<PlayLogEntry[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@ -106,6 +135,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
void load().catch((e) => setErr(String(e.message ?? e))); void load().catch((e) => setErr(String(e.message ?? e)));
}, [load]); }, [load]);
const loadPlayLog = useCallback(async () => {
const r = await fetch("/api/v1/admin/scripts/viaplay/play-log", {
credentials: "include",
});
if (!r.ok) return;
const d = (await r.json()) as { log?: PlayLogEntry[] };
setPlayLog(Array.isArray(d.log) ? d.log : []);
}, []);
useEffect(() => {
void loadPlayLog();
const t = setInterval(() => void loadPlayLog(), 8000);
return () => clearInterval(t);
}, [loadPlayLog]);
async function saveSettings(e: FormEvent) { async function saveSettings(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
@ -411,10 +455,11 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
<div className="card" style={{ maxWidth: 760 }}> <div className="card" style={{ maxWidth: 760 }}>
<h2 style={{ marginTop: 0 }}>Viaplay</h2> <h2 style={{ marginTop: 0 }}>Viaplay</h2>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
Sportagenda en live streams voor gekoppelde Viaplay-accounts. Zet e-mail + wachtwoord Sportagenda en live streams voor gekoppelde Viaplay-accounts. Token Tray moet
(Token Tray logt stil in via de browser-UI). Tokens plakken blijft optioneel. de <strong>hele cookie-jar</strong> meesturen (session + datadome + Boid), zoals
Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en het werkende livescript — Viaplay/DataDome geeft anders Don't go i.p.v. 403.
licenties cache-first (±5 min vóór start). Playwright-login wordt door DataDome als bot gezien; tokens uit echt Chrome
(Netscape-export / tray met Chrome-kanaal) werken beter.
</p> </p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: "1rem" }}>
@ -437,6 +482,50 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
</button> </button>
</div> </div>
<h3 style={{ marginBottom: "0.5rem" }}>Play-diagnose</h3>
<p className="muted" style={{ marginTop: 0, fontSize: "0.85rem" }}>
Geen cookie-waarden, wel namen + of Viaplay Don't go teruggaf. Speel iets in de
app en kijk hier binnen een paar seconden.
</p>
{playLog.length === 0 ? (
<p className="muted">Nog geen plays sinds de laatste API-restart.</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 1.25rem" }}>
{playLog.slice(0, 12).map((e, i) => (
<li
key={`${e.at}-${e.guid}-${i}`}
style={{
padding: "0.55rem 0",
borderBottom: "1px solid var(--border, #333)",
fontSize: "0.88rem",
}}
>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<strong style={{ color: e.ok && !e.dontGo ? "var(--ok, #6c6)" : "var(--danger, #f66)" }}>
{e.ok && !e.dontGo ? "OK" : e.dontGo ? "DON'T GO" : "FAIL"}
</strong>
<span className="muted">{e.kind}</span>
<span>{e.title || e.guid}</span>
</div>
<div className="muted" style={{ marginTop: 4 }}>
{new Date(e.at).toLocaleString("nl-NL")}
{e.accountLabel ? ` · ${e.accountLabel}` : ""}
{e.mediaPath ? ` · ${e.mediaPath}` : ""}
{e.hasLicense ? " · widevine" : " · geen licentie"}
{` · cookies ${e.cookies.count}`}
{e.cookies.hasDatadome ? " · datadome" : " · géén datadome"}
{e.cookies.hasBoid ? " · boid" : " · géén boid"}
{e.cookies.sessionStoredDecoded ? " · session decoded" : ""}
{e.cookies.sessionStoredEncoded ? " · session encoded" : ""}
</div>
{(e.dontGoReason || e.error) && (
<div style={{ marginTop: 4 }}>{e.dontGoReason || e.error}</div>
)}
</li>
))}
</ul>
)}
<h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3> <h3 style={{ marginBottom: "0.5rem" }}>Accounts</h3>
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<p className="muted">Nog geen accounts — voeg e-mail + sessie-tokens toe.</p> <p className="muted">Nog geen accounts — voeg e-mail + sessie-tokens toe.</p>
@ -467,6 +556,9 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{a.email} {a.email}
{a.hasPassword ? " · wachtwoord" : ""} {a.hasPassword ? " · wachtwoord" : ""}
{a.hasSession && a.hasAccessToken ? " · tokens" : ""} {a.hasSession && a.hasAccessToken ? " · tokens" : ""}
{a.hasCookieJar ? " · cookie-jar" : " · geen cookie-jar"}
{a.hasDatadome ? " · datadome" : " · geen datadome"}
{a.hasBoid ? " · boid" : " · geen boid"}
{a.eventCount ? ` · ${a.eventCount} events` : ""} {a.eventCount ? ` · ${a.eventCount} events` : ""}
</span> </span>
</div> </div>

View file

@ -1928,19 +1928,27 @@ 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-v9", resolver: "viaplay-byguid-v10",
lastPlay: (await import("../viaplay/play")).getLastVodPlay(),
}; };
} 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-v9", resolver: "viaplay-byguid-v10",
lastPlay: (await import("../viaplay/play")).getLastVodPlay(),
}; };
} }
} }
); );
/** Diagnose: laatste live/VOD plays (geen secrets, wel cookie-namen + Don't go-reden). */
app.get("/api/v1/admin/scripts/viaplay/play-log", { preHandler: requireAdmin }, async () => {
const { getViaplayPlayLog, getLastVodPlay } = await import("../viaplay/play");
return { last: getLastVodPlay(), log: getViaplayPlayLog() };
});
app.post("/api/v1/admin/scripts/viaplay/sync", { preHandler: requireAdmin }, async () => { app.post("/api/v1/admin/scripts/viaplay/sync", { preHandler: requireAdmin }, async () => {
const { syncViaplayCatalog } = await import("../viaplay/sync"); const { syncViaplayCatalog } = await import("../viaplay/sync");
return syncViaplayCatalog(config.SESSION_SECRET); return syncViaplayCatalog(config.SESSION_SECRET);

View file

@ -164,6 +164,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
profileId?: string; profileId?: string;
persistentLogin?: string; persistentLogin?: string;
boid?: string; boid?: string;
cookieJar?: Record<string, string>;
}; };
const { upsertViaplayAccountByEmail } = await import("../viaplay/settings"); const { upsertViaplayAccountByEmail } = await import("../viaplay/settings");
return upsertViaplayAccountByEmail(config.SESSION_SECRET, { return upsertViaplayAccountByEmail(config.SESSION_SECRET, {
@ -174,6 +175,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
profileId: body.profileId, profileId: body.profileId,
persistentLogin: body.persistentLogin, persistentLogin: body.persistentLogin,
boid: body.boid, boid: body.boid,
cookieJar: body.cookieJar,
}); });
}); });

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { decryptSecret, encryptSecret } from "../security/crypto"; import { decryptSecret, encryptSecret } from "../security/crypto";
import { jwtExpiryIso } from "../agent/token-expiry"; import { jwtExpiryIso } from "../agent/token-expiry";
import { pickPlayCookies } from "./cookies";
export type ViaplayAccountHealth = "ok" | "dead" | "unknown"; export type ViaplayAccountHealth = "ok" | "dead" | "unknown";
@ -17,6 +18,10 @@ export type ViaplayAccountStored = {
/** Optioneel — voor latere tray/login. */ /** Optioneel — voor latere tray/login. */
passwordEnc?: string; passwordEnc?: string;
persistentLoginEnc?: string; persistentLoginEnc?: string;
/** Encrypted JSON van de play-cookie-jar (datadome, boid, clientId, …). */
cookieJarEnc?: string;
/** Cookie-namen in de jar — geen waarden, voor admin-diagnose. */
cookieNames?: string[];
enabled: boolean; enabled: boolean;
healthStatus?: ViaplayAccountHealth; healthStatus?: ViaplayAccountHealth;
healthCheckedAt?: string; healthCheckedAt?: string;
@ -36,6 +41,10 @@ export type ViaplayAccountPublic = {
hasAccessToken: boolean; hasAccessToken: boolean;
hasProfileId: boolean; hasProfileId: boolean;
hasPassword: boolean; hasPassword: boolean;
hasCookieJar: boolean;
hasDatadome: boolean;
hasBoid: boolean;
cookieNames: string[];
enabled: boolean; enabled: boolean;
eventCount: number; eventCount: number;
healthStatus: ViaplayAccountHealth; healthStatus: ViaplayAccountHealth;
@ -58,6 +67,7 @@ export type ViaplayAccountCreds = {
deviceId: string; deviceId: string;
password: string | null; password: string | null;
persistentLogin: string | null; persistentLogin: string | null;
cookieJar: Record<string, string> | null;
enabled: boolean; enabled: boolean;
}; };
@ -82,6 +92,12 @@ export function accountToPublic(
hasAccessToken: !!account.accessTokenEnc, hasAccessToken: !!account.accessTokenEnc,
hasProfileId: !!account.profileId?.trim(), hasProfileId: !!account.profileId?.trim(),
hasPassword: !!account.passwordEnc, hasPassword: !!account.passwordEnc,
hasCookieJar: !!account.cookieJarEnc,
hasDatadome: (account.cookieNames || []).includes("datadome"),
hasBoid:
(account.cookieNames || []).includes("__Secure-Viaplay-Boid") ||
!!account.boid?.trim(),
cookieNames: account.cookieNames || [],
enabled: account.enabled !== false, enabled: account.enabled !== false,
eventCount, eventCount,
healthStatus: account.healthStatus ?? "unknown", healthStatus: account.healthStatus ?? "unknown",
@ -117,10 +133,29 @@ export function decryptAccount(
persistentLogin: account.persistentLoginEnc persistentLogin: account.persistentLoginEnc
? decryptSecret(account.persistentLoginEnc, sessionSecret) ? decryptSecret(account.persistentLoginEnc, sessionSecret)
: null, : null,
cookieJar: parseCookieJar(account.cookieJarEnc, sessionSecret),
enabled: account.enabled !== false, enabled: account.enabled !== false,
}; };
} }
function parseCookieJar(
enc: string | undefined,
sessionSecret: string
): Record<string, string> | null {
if (!enc) return null;
try {
const raw = JSON.parse(decryptSecret(enc, sessionSecret)) as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
if (typeof v === "string" && v.trim()) out[k] = v.trim();
}
return Object.keys(out).length ? out : null;
} catch {
return null;
}
}
export function createAccountStored( export function createAccountStored(
sessionSecret: string, sessionSecret: string,
input: { input: {
@ -135,6 +170,7 @@ export function createAccountStored(
deviceId?: string; deviceId?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
cookieJar?: Record<string, string>;
} }
): ViaplayAccountStored { ): ViaplayAccountStored {
const email = input.email.trim().toLowerCase(); const email = input.email.trim().toLowerCase();
@ -163,6 +199,13 @@ export function createAccountStored(
persistentLoginEnc: input.persistentLogin?.trim() persistentLoginEnc: input.persistentLogin?.trim()
? encryptSecret(input.persistentLogin.trim(), sessionSecret) ? encryptSecret(input.persistentLogin.trim(), sessionSecret)
: undefined, : undefined,
cookieJarEnc: (() => {
const jar = pickPlayCookies(input.cookieJar);
return Object.keys(jar).length
? encryptSecret(JSON.stringify(jar), sessionSecret)
: undefined;
})(),
cookieNames: Object.keys(pickPlayCookies(input.cookieJar)).sort(),
enabled: input.enabled !== false, enabled: input.enabled !== false,
tokenExpiresAt: accessToken ? jwtExpiryIso(accessToken) ?? undefined : undefined, tokenExpiresAt: accessToken ? jwtExpiryIso(accessToken) ?? undefined : undefined,
}; };
@ -188,6 +231,10 @@ export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] {
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,
cookieJarEnc: String(row.cookieJarEnc ?? "").trim() || undefined,
cookieNames: Array.isArray(row.cookieNames)
? row.cookieNames.map((n) => String(n).trim()).filter(Boolean)
: undefined,
enabled: row.enabled !== false, enabled: row.enabled !== false,
healthStatus: healthStatus:
row.healthStatus === "ok" || row.healthStatus === "ok" ||

View file

@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { ViaplayAccountCreds } from "./accounts"; import type { ViaplayAccountCreds } from "./accounts";
import { toMediaGuid } from "./accounts"; import { toMediaGuid } from "./accounts";
import { inventoryFromNames, pickPlayCookies, type CookieInventory } from "./cookies";
/** Zelfde UA als het werkende livescript (viaplay3.py). Edge/153 + sec-ch-ua gaf Don't go. */ /** 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 =
@ -55,17 +56,24 @@ function parseSetCookieJar(setCookies: string[]): Record<string, string> {
} }
function cookieHeader(creds: ViaplayAccountCreds): string { function cookieHeader(creds: ViaplayAccountCreds): string {
const parts: string[] = []; const merged: Record<string, string> = { ...(creds.cookieJar || {}) };
if (creds.session) parts.push(`session=${cookieValue(creds.session)}`); if (creds.session) merged.session = creds.session;
if (creds.accessToken) parts.push(`accessToken=${creds.accessToken.trim()}`); if (creds.accessToken) merged.accessToken = creds.accessToken.trim();
if (creds.profileId) parts.push(`viaplay_profileId=${creds.profileId.trim()}`); if (creds.profileId) merged.viaplay_profileId = creds.profileId.trim();
if (creds.boid) { if (creds.boid) merged["__Secure-Viaplay-Boid"] = creds.boid.trim();
parts.push(`__Secure-Viaplay-Boid=${creds.boid.trim()}`); if (creds.persistentLogin) merged.persistentLogin = creds.persistentLogin;
const picked = pickPlayCookies(merged);
return Object.entries(picked)
.map(([k, v]) => `${k}=${cookieValue(v)}`)
.join("; ");
} }
if (creds.persistentLogin) {
parts.push(`persistentLogin=${cookieValue(creds.persistentLogin)}`); export function cookieInventory(creds: ViaplayAccountCreds): CookieInventory {
} const names = cookieHeader(creds)
return parts.join("; "); .split(/;\s*/)
.map((p) => p.split("=")[0] || "")
.filter(Boolean);
return inventoryFromNames(names, creds.session);
} }
export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> { export function authHeaders(creds: ViaplayAccountCreds): Record<string, string> {
@ -155,6 +163,19 @@ export async function refreshViaplaySession(
const session = sessionRaw; const session = sessionRaw;
const accessToken = normalizeAccessToken(accessRaw) || accessRaw.trim(); const accessToken = normalizeAccessToken(accessRaw) || accessRaw.trim();
const nextPl = jar.persistentLogin || pl; const nextPl = jar.persistentLogin || pl;
const nextJar = pickPlayCookies({
...(creds.cookieJar || {}),
session,
accessToken,
persistentLogin: nextPl,
...(jar.datadome ? { datadome: jar.datadome } : {}),
...(jar["__Secure-Viaplay-Boid"]
? { "__Secure-Viaplay-Boid": jar["__Secure-Viaplay-Boid"] }
: {}),
...(jar["Viaplay-ClientId"]
? { "Viaplay-ClientId": jar["Viaplay-ClientId"] }
: {}),
});
return { return {
...creds, ...creds,
@ -162,6 +183,7 @@ export async function refreshViaplaySession(
accessToken, accessToken,
persistentLogin: nextPl, persistentLogin: nextPl,
boid: jar["__Secure-Viaplay-Boid"] || creds.boid, boid: jar["__Secure-Viaplay-Boid"] || creds.boid,
cookieJar: Object.keys(nextJar).length ? nextJar : creds.cookieJar,
}; };
} }

View file

@ -0,0 +1,84 @@
/** Cookies die viaplay3.py / Netscape-jar meestuurt en DataDome nodig heeft. */
export const VIAPLAY_PLAY_COOKIE_KEEP = new Set([
"session",
"accessToken",
"persistentLogin",
"hasPersistentLogin",
"isPersistentLogin",
"viaplay_profileId",
"__Secure-Viaplay-Boid",
"__Secure-Viaplay-Boid-exists",
"datadome",
"Viaplay-ClientId",
"splitKey",
"countryCode",
"eupubconsent-v2",
"OptanonConsent",
"cookie_agreement",
]);
export function pickPlayCookies(
raw: Record<string, string> | null | undefined
): Record<string, string> {
const out: Record<string, string> = {};
if (!raw) return out;
for (const [key, value] of Object.entries(raw)) {
const name = String(key || "").trim();
const v = String(value || "").trim();
if (!name || !v) continue;
if (
VIAPLAY_PLAY_COOKIE_KEEP.has(name) ||
/^__Secure-Viaplay/i.test(name) ||
/^datadome$/i.test(name)
) {
out[name] = v;
}
}
return out;
}
export type CookieInventory = {
names: string[];
count: number;
sessionStoredDecoded: boolean;
sessionStoredEncoded: boolean;
hasDatadome: boolean;
hasBoid: boolean;
hasClientId: boolean;
hasPersistentLogin: boolean;
hasAccessToken: boolean;
hasProfileId: boolean;
hasSession: boolean;
};
export function inspectStoredSession(session: string | null | undefined): {
storedDecoded: boolean;
storedEncoded: boolean;
} {
const s = String(session || "").trim();
if (!s) return { storedDecoded: false, storedEncoded: false };
const storedDecoded = /s:j:\{/.test(s) || /[{}"\s]/.test(s);
const storedEncoded = /%[0-9A-Fa-f]{2}/.test(s) && !/[{}]/.test(s);
return { storedDecoded, storedEncoded };
}
export function inventoryFromNames(
names: string[],
session?: string | null
): CookieInventory {
const set = new Set(names);
const sess = inspectStoredSession(session);
return {
names: [...set].sort(),
count: set.size,
sessionStoredDecoded: sess.storedDecoded,
sessionStoredEncoded: sess.storedEncoded,
hasDatadome: set.has("datadome"),
hasBoid: set.has("__Secure-Viaplay-Boid"),
hasClientId: set.has("Viaplay-ClientId"),
hasPersistentLogin: set.has("persistentLogin"),
hasAccessToken: set.has("accessToken"),
hasProfileId: set.has("viaplay_profileId"),
hasSession: set.has("session"),
};
}

View file

@ -0,0 +1,60 @@
import type { CookieInventory } from "./cookies";
export type ViaplayPlayDiag = {
at: string;
kind: "live" | "vod";
guid: string;
ok: boolean;
title: string | null;
accountId: string | null;
accountLabel: string | null;
httpStatus: number | null;
playHost: string | null;
mediaPath: string | null;
hasLicense: boolean;
dontGo: boolean;
dontGoReason: string | null;
productDurationMs: number | null;
mpdDurationMs: number | null;
cookies: CookieInventory;
refreshOk: boolean | null;
error: string | null;
resolver: string;
triedAccounts?: number;
triedLabels?: string[];
};
const MAX = 25;
const log: ViaplayPlayDiag[] = [];
export function recordViaplayPlay(entry: Omit<ViaplayPlayDiag, "at">): ViaplayPlayDiag {
const full: ViaplayPlayDiag = { ...entry, at: new Date().toISOString() };
log.unshift(full);
if (log.length > MAX) log.length = MAX;
return full;
}
export function getViaplayPlayLog(): ViaplayPlayDiag[] {
return log.slice();
}
export function getLastViaplayPlay(): ViaplayPlayDiag | null {
return log[0] ?? null;
}
export function mediaPathOf(url: string | null | undefined): string | null {
if (!url) return null;
try {
const u = new URL(url);
const parts = u.pathname.split("/").filter(Boolean);
return parts.slice(-3).join("/") || u.pathname;
} catch {
return url.slice(0, 120);
}
}
export function isDontGoError(message: string | null | undefined): boolean {
return /don'?t\s*go|placeholder|promo-stream|geen live-dash/i.test(
String(message || "")
);
}

View file

@ -14,11 +14,21 @@ import {
fetchMpdXml, fetchMpdXml,
extractPsshFromMpd, extractPsshFromMpd,
fetchViaplayBootstrapPssh, fetchViaplayBootstrapPssh,
cookieInventory,
isDontGoPlaceholder, isDontGoPlaceholder,
isLiveDashMpd, isLiveDashMpd,
mpdLooksLikeDontGo, mpdLooksLikeDontGo,
VIAPLAY_USER_AGENT, VIAPLAY_USER_AGENT,
} from "./client"; } from "./client";
import { inventoryFromNames, type CookieInventory } from "./cookies";
import {
isDontGoError,
mediaPathOf,
recordViaplayPlay,
getLastViaplayPlay,
getViaplayPlayLog,
type ViaplayPlayDiag,
} from "./diag";
import { import {
loadViaplaySettings, loadViaplaySettings,
patchViaplayEventCache, patchViaplayEventCache,
@ -27,44 +37,70 @@ import { findViaplayEvent } from "./sync";
import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks";
import { VIAPLAY_CATCHUP_MS } from "./events"; import { VIAPLAY_CATCHUP_MS } from "./events";
export type LastVodPlayDiag = { export type LastVodPlayDiag = ViaplayPlayDiag;
at: string;
guid: string;
ok: boolean;
title: string | null;
accountId: string | null;
mediaPath: string | null;
error: string | null;
resolver: string;
/** Hoeveel accounts deze play heeft geprobeerd (eigen cookies per account). */
triedAccounts?: number;
/** Korte labels van geprobeerde accounts (geen secrets). */
triedLabels?: string[];
};
let lastVodPlay: LastVodPlayDiag | null = null; export function getLastVodPlay() {
return getLastViaplayPlay();
export function getLastVodPlay(): LastVodPlayDiag | null {
return lastVodPlay;
} }
function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) { export { getViaplayPlayLog };
lastVodPlay = {
function emptyCookies(): CookieInventory {
return inventoryFromNames([], null);
}
function recordVodPlay(
partial: Partial<ViaplayPlayDiag> & { guid: string; ok: boolean }
) {
const error = partial.error ?? null;
recordViaplayPlay({
kind: "vod",
title: null,
accountId: null,
accountLabel: null,
httpStatus: null,
playHost: "play.viaplay.com",
mediaPath: null,
hasLicense: false,
dontGo: isDontGoError(error) || !!partial.dontGo,
dontGoReason: partial.dontGoReason || (isDontGoError(error) ? error : null),
productDurationMs: null,
mpdDurationMs: null,
cookies: emptyCookies(),
refreshOk: null,
error,
resolver: "viaplay-byguid-v10",
...partial, ...partial,
at: new Date().toISOString(), kind: "vod",
resolver: "viaplay-byguid-v9", resolver: "viaplay-byguid-v10",
}; });
} }
function mediaPathOf(url: string | null | undefined): string | null { function recordLivePlay(
if (!url) return null; partial: Partial<ViaplayPlayDiag> & { guid: string; ok: boolean }
try { ) {
const u = new URL(url); const error = partial.error ?? null;
const parts = u.pathname.split("/").filter(Boolean); recordViaplayPlay({
return parts.slice(-3).join("/") || u.pathname; kind: "live",
} catch { title: null,
return url.slice(0, 120); accountId: null,
} accountLabel: null,
httpStatus: null,
playHost: "play-live.viaplay.com",
mediaPath: null,
hasLicense: false,
dontGo: isDontGoError(error) || !!partial.dontGo,
dontGoReason: partial.dontGoReason || (isDontGoError(error) ? error : null),
productDurationMs: null,
mpdDurationMs: null,
cookies: emptyCookies(),
refreshOk: null,
error,
resolver: "viaplay-play-live-v10",
...partial,
kind: "live",
resolver: "viaplay-play-live-v10",
});
} }
function parseKey(value: string): { kid: string; key: string } | null { function parseKey(value: string): { kid: string; key: string } | null {
@ -306,6 +342,7 @@ export async function ensureViaplayEventReady(
accessToken: refreshed.accessToken || undefined, accessToken: refreshed.accessToken || undefined,
persistentLogin: refreshed.persistentLogin || undefined, persistentLogin: refreshed.persistentLogin || undefined,
boid: refreshed.boid || undefined, boid: refreshed.boid || undefined,
cookieJar: refreshed.cookieJar || undefined,
}); });
} catch { } catch {
/* continue with in-memory refresh */ /* continue with in-memory refresh */
@ -396,6 +433,19 @@ export async function ensureViaplayEventReady(
accountIds: event.accountIds.length ? event.accountIds : [account.id], accountIds: event.accountIds.length ? event.accountIds : [account.id],
}); });
recordLivePlay({
guid: mediaGuid,
ok: true,
title: play.title,
accountId: account.id,
accountLabel: account.label,
mediaPath: mediaPathOf(kap.mpdUrl),
hasLicense: !!play.licenseUrl,
dontGo: isDontGoPlaceholder(kap.mpdUrl),
cookies: cookieInventory(creds),
error: null,
});
return { return {
streamUrl: kap.mpdUrl, streamUrl: kap.mpdUrl,
format: "dash", format: "dash",
@ -407,6 +457,14 @@ export async function ensureViaplayEventReady(
}; };
} catch (err) { } catch (err) {
lastError = err instanceof Error ? err : new Error(String(err)); lastError = err instanceof Error ? err : new Error(String(err));
recordLivePlay({
guid: mediaGuid,
ok: false,
accountId: account.id,
accountLabel: account.label,
cookies: cookieInventory(account),
error: lastError.message,
});
} }
} }
@ -516,6 +574,7 @@ async function resolveViaplayGuidPlayInner(
accessToken: refreshed.accessToken || undefined, accessToken: refreshed.accessToken || undefined,
persistentLogin: refreshed.persistentLogin || undefined, persistentLogin: refreshed.persistentLogin || undefined,
boid: refreshed.boid || undefined, boid: refreshed.boid || undefined,
cookieJar: refreshed.cookieJar || undefined,
}); });
} catch { } catch {
/* play mag doorgaan met in-memory refresh */ /* play mag doorgaan met in-memory refresh */
@ -609,7 +668,12 @@ async function resolveViaplayGuidPlayInner(
ok: true, ok: true,
title: play.title, title: play.title,
accountId: account.id, accountId: account.id,
accountLabel: label,
mediaPath: mediaPathOf(mpdUrl), mediaPath: mediaPathOf(mpdUrl),
hasLicense: !!play.licenseUrl,
dontGo: isDontGoPlaceholder(mpdUrl),
productDurationMs: play.durationMs,
cookies: cookieInventory(creds),
error: null, error: null,
triedAccounts: triedLabels.length, triedAccounts: triedLabels.length,
triedLabels, triedLabels,

View file

@ -13,6 +13,7 @@ import {
type ViaplayAccountStored, type ViaplayAccountStored,
type ViaplayEventOwners, type ViaplayEventOwners,
} from "./accounts"; } from "./accounts";
import { pickPlayCookies } from "./cookies";
import { fetchAccountOverview } from "./client"; import { fetchAccountOverview } from "./client";
import { import {
mergeViaplayTweaks, mergeViaplayTweaks,
@ -205,6 +206,7 @@ export async function addViaplayAccount(
boid?: string; boid?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
cookieJar?: Record<string, string>;
} }
) { ) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
@ -236,6 +238,7 @@ export async function upsertViaplayAccountByEmail(
profileId?: string; profileId?: string;
boid?: string; boid?: string;
persistentLogin?: string; persistentLogin?: string;
cookieJar?: Record<string, string>;
} }
) { ) {
const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } });
@ -256,6 +259,7 @@ export async function upsertViaplayAccountByEmail(
profileId: input.profileId, profileId: input.profileId,
boid: input.boid, boid: input.boid,
persistentLogin: input.persistentLogin, persistentLogin: input.persistentLogin,
cookieJar: input.cookieJar,
enabled: true, enabled: true,
}); });
} }
@ -267,6 +271,7 @@ export async function upsertViaplayAccountByEmail(
profileId: input.profileId, profileId: input.profileId,
boid: input.boid, boid: input.boid,
persistentLogin: input.persistentLogin, persistentLogin: input.persistentLogin,
cookieJar: input.cookieJar,
}); });
} }
@ -283,6 +288,7 @@ export async function updateViaplayAccount(
boid?: string; boid?: string;
password?: string; password?: string;
persistentLogin?: string; persistentLogin?: string;
cookieJar?: Record<string, string>;
clearCookies?: boolean; clearCookies?: boolean;
} }
) { ) {
@ -303,12 +309,16 @@ export async function updateViaplayAccount(
let profileId = current.profileId; let profileId = current.profileId;
let boid = current.boid; let boid = current.boid;
let persistentLoginEnc = current.persistentLoginEnc; let persistentLoginEnc = current.persistentLoginEnc;
let cookieJarEnc = current.cookieJarEnc;
let cookieNames = current.cookieNames;
if (input.clearCookies) { if (input.clearCookies) {
sessionEnc = undefined; sessionEnc = undefined;
accessTokenEnc = undefined; accessTokenEnc = undefined;
profileId = undefined; profileId = undefined;
boid = undefined; boid = undefined;
persistentLoginEnc = undefined; persistentLoginEnc = undefined;
cookieJarEnc = undefined;
cookieNames = undefined;
} }
if (input.session?.trim()) { if (input.session?.trim()) {
sessionEnc = encryptSecret(input.session.trim(), sessionSecret); sessionEnc = encryptSecret(input.session.trim(), sessionSecret);
@ -329,6 +339,13 @@ export async function updateViaplayAccount(
if (input.persistentLogin?.trim()) { if (input.persistentLogin?.trim()) {
persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret); persistentLoginEnc = encryptSecret(input.persistentLogin.trim(), sessionSecret);
} }
if (input.cookieJar && typeof input.cookieJar === "object") {
const jar = pickPlayCookies(input.cookieJar);
if (Object.keys(jar).length) {
cookieJarEnc = encryptSecret(JSON.stringify(jar), sessionSecret);
cookieNames = Object.keys(jar).sort();
}
}
let tokenExpiresAt = current.tokenExpiresAt; let tokenExpiresAt = current.tokenExpiresAt;
if (input.clearCookies) { if (input.clearCookies) {
@ -351,6 +368,8 @@ export async function updateViaplayAccount(
profileId, profileId,
boid, boid,
persistentLoginEnc, persistentLoginEnc,
cookieJarEnc,
cookieNames,
passwordEnc: input.password?.trim() passwordEnc: input.password?.trim()
? encryptSecret(input.password.trim(), sessionSecret) ? encryptSecret(input.password.trim(), sessionSecret)
: current.passwordEnc, : current.passwordEnc,

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-v9", resolver: "viaplay-byguid-v10",
mediaPath: (() => { mediaPath: (() => {
try { try {
const u = new URL(played.streamUrl); const u = new URL(played.streamUrl);

View file

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

View file

@ -16,6 +16,7 @@ export type ViaplayLoginResult = {
profileId: string | null; profileId: string | null;
persistentLogin: string | null; persistentLogin: string | null;
boid: string | null; boid: string | null;
cookieJar: Record<string, string>;
}; };
function profileDir(accountId: string): string { function profileDir(accountId: string): string {
@ -98,8 +99,32 @@ async function readCookies(context: BrowserContext): Promise<ViaplayLoginResult
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; const boid = (byName.get("__Secure-Viaplay-Boid") || "").trim() || null;
const cookieJar: Record<string, string> = {};
const keep = new Set([
"session",
"accessToken",
"persistentLogin",
"hasPersistentLogin",
"isPersistentLogin",
"viaplay_profileId",
"__Secure-Viaplay-Boid",
"__Secure-Viaplay-Boid-exists",
"datadome",
"Viaplay-ClientId",
"splitKey",
"countryCode",
"eupubconsent-v2",
"OptanonConsent",
"cookie_agreement",
]);
for (const c of cookies) {
if (!c.name || !c.value) continue;
if (keep.has(c.name) || /^__Secure-Viaplay/i.test(c.name)) {
cookieJar[c.name] = c.value;
}
}
if (!session || !accessToken) return null; if (!session || !accessToken) return null;
return { session, accessToken, profileId, persistentLogin, boid }; return { session, accessToken, profileId, persistentLogin, boid, cookieJar };
} }
async function launchProfile(userDataDir: string, headless: boolean) { async function launchProfile(userDataDir: string, headless: boolean) {

View file

@ -7,9 +7,39 @@ export type CapturedTokens = {
profileId: string | null; profileId: string | null;
persistentLogin: string | null; persistentLogin: string | null;
boid: string | null; boid: string | null;
cookieJar: Record<string, string>;
accessTokenExpMs: number | null; accessTokenExpMs: number | null;
}; };
const PLAY_COOKIE_KEEP = new Set([
"session",
"accessToken",
"persistentLogin",
"hasPersistentLogin",
"isPersistentLogin",
"viaplay_profileId",
"__Secure-Viaplay-Boid",
"__Secure-Viaplay-Boid-exists",
"datadome",
"Viaplay-ClientId",
"splitKey",
"countryCode",
"eupubconsent-v2",
"OptanonConsent",
"cookie_agreement",
]);
function pickPlayCookies(byName: Map<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value] of byName) {
if (!name || !value?.trim()) continue;
if (PLAY_COOKIE_KEEP.has(name) || /^__Secure-Viaplay/i.test(name)) {
out[name] = value.trim();
}
}
return out;
}
const COOKIE_DOMAINS = [ const COOKIE_DOMAINS = [
"viaplay.com", "viaplay.com",
".viaplay.com", ".viaplay.com",
@ -50,7 +80,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", "__Secure-Viaplay-Boid"].includes(c.name) ["session", "accessToken", "viaplay_profileId", "persistentLogin", "__Secure-Viaplay-Boid", "datadome", "Viaplay-ClientId"].includes(c.name)
) { ) {
byName.set(c.name, c.value); byName.set(c.name, c.value);
} }
@ -74,6 +104,7 @@ export async function captureViaplayTokens(partition: string): Promise<CapturedT
profileId, profileId,
persistentLogin, persistentLogin,
boid, boid,
cookieJar: pickPlayCookies(byName),
accessTokenExpMs: jwtExpMs(accessToken), accessTokenExpMs: jwtExpMs(accessToken),
}; };
} }
@ -97,6 +128,7 @@ export async function pushViaplayTokens(
profileId: tokens.profileId || undefined, profileId: tokens.profileId || undefined,
persistentLogin: tokens.persistentLogin || undefined, persistentLogin: tokens.persistentLogin || undefined,
boid: tokens.boid || undefined, boid: tokens.boid || undefined,
cookieJar: tokens.cookieJar,
}), }),
}); });
const data = (await res.json().catch(() => ({}))) as { const data = (await res.json().catch(() => ({}))) as {