From 8bf3b434446de8e2f8f3bdac15e2e0176e02520f Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 28 Sep 2026 18:03:07 +0200 Subject: [PATCH] Detect Viaplay Don't go via play-log and send the full cookie jar including DataDome. --- .../admin-ui/src/app/scripts/ViaplayPanel.tsx | 100 +++++++++++++- apps/master-api/src/admin/routes.ts | 12 +- apps/master-api/src/agent/routes.ts | 2 + apps/master-api/src/viaplay/accounts.ts | 47 +++++++ apps/master-api/src/viaplay/client.ts | 44 ++++-- apps/master-api/src/viaplay/cookies.ts | 84 ++++++++++++ apps/master-api/src/viaplay/diag.ts | 60 ++++++++ apps/master-api/src/viaplay/play.ts | 128 +++++++++++++----- apps/master-api/src/viaplay/settings.ts | 19 +++ apps/master-api/src/viewer/routes.ts | 2 +- apps/token-tray/src/jobs.ts | 2 + apps/token-tray/src/login/viaplay.ts | 27 +++- apps/token-tray/src/viaplay.ts | 34 ++++- 13 files changed, 509 insertions(+), 52 deletions(-) create mode 100644 apps/master-api/src/viaplay/cookies.ts create mode 100644 apps/master-api/src/viaplay/diag.ts diff --git a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx index a2662ab..c7a93ec 100644 --- a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx +++ b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx @@ -20,6 +20,10 @@ type ViaplayAccount = { hasAccessToken: boolean; hasProfileId: boolean; hasPassword: boolean; + hasCookieJar?: boolean; + hasDatadome?: boolean; + hasBoid?: boolean; + cookieNames?: string[]; enabled: boolean; eventCount: number; healthStatus: "ok" | "dead" | "unknown"; @@ -30,6 +34,30 @@ type ViaplayAccount = { 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 = { configured: boolean; enabled: boolean; @@ -62,6 +90,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) = const [tweaks, setTweaks] = useState({}); const [showTweaks, setShowTweaks] = useState(false); const [showCookies, setShowCookies] = useState(false); + const [playLog, setPlayLog] = useState([]); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(null); @@ -106,6 +135,21 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) = void load().catch((e) => setErr(String(e.message ?? e))); }, [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) { e.preventDefault(); setBusy(true); @@ -411,10 +455,11 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =

Viaplay

- Sportagenda en live streams voor gekoppelde Viaplay-accounts. Zet e-mail + wachtwoord - (Token Tray logt stil in via de browser-UI). Tokens plakken blijft optioneel. - Agenda: max 3 dagen vooruit (vervangt Viaplay uit de externe events-feed). Manifests en - licenties cache-first (±5 min vóór start). + Sportagenda en live streams voor gekoppelde Viaplay-accounts. Token Tray moet + de hele cookie-jar meesturen (session + datadome + Boid), zoals + het werkende livescript — Viaplay/DataDome geeft anders Don't go i.p.v. 403. + Playwright-login wordt door DataDome als bot gezien; tokens uit echt Chrome + (Netscape-export / tray met Chrome-kanaal) werken beter.

@@ -437,6 +482,50 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
+

Play-diagnose

+

+ Geen cookie-waarden, wel namen + of Viaplay Don't go teruggaf. Speel iets in de + app en kijk hier binnen een paar seconden. +

+ {playLog.length === 0 ? ( +

Nog geen plays sinds de laatste API-restart.

+ ) : ( +
    + {playLog.slice(0, 12).map((e, i) => ( +
  • +
    + + {e.ok && !e.dontGo ? "OK" : e.dontGo ? "DON'T GO" : "FAIL"} + + {e.kind} + {e.title || e.guid} +
    +
    + {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" : ""} +
    + {(e.dontGoReason || e.error) && ( +
    {e.dontGoReason || e.error}
    + )} +
  • + ))} +
+ )} +

Accounts

{accounts.length === 0 ? (

Nog geen accounts — voeg e-mail + sessie-tokens toe.

@@ -467,6 +556,9 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) = {a.email} {a.hasPassword ? " · wachtwoord" : ""} {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` : ""}
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index ebb9a86..f35e831 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1928,19 +1928,27 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) streamUrl: played.streamUrl, keyCount: played.keys.length, promo, - resolver: "viaplay-byguid-v9", + resolver: "viaplay-byguid-v10", + lastPlay: (await import("../viaplay/play")).getLastVodPlay(), }; } catch (err) { return { ok: false, guid, 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 () => { const { syncViaplayCatalog } = await import("../viaplay/sync"); return syncViaplayCatalog(config.SESSION_SECRET); diff --git a/apps/master-api/src/agent/routes.ts b/apps/master-api/src/agent/routes.ts index 1eeb3ee..38070c6 100644 --- a/apps/master-api/src/agent/routes.ts +++ b/apps/master-api/src/agent/routes.ts @@ -164,6 +164,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) { profileId?: string; persistentLogin?: string; boid?: string; + cookieJar?: Record; }; const { upsertViaplayAccountByEmail } = await import("../viaplay/settings"); return upsertViaplayAccountByEmail(config.SESSION_SECRET, { @@ -174,6 +175,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) { profileId: body.profileId, persistentLogin: body.persistentLogin, boid: body.boid, + cookieJar: body.cookieJar, }); }); diff --git a/apps/master-api/src/viaplay/accounts.ts b/apps/master-api/src/viaplay/accounts.ts index 564508c..f704fd5 100644 --- a/apps/master-api/src/viaplay/accounts.ts +++ b/apps/master-api/src/viaplay/accounts.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { decryptSecret, encryptSecret } from "../security/crypto"; import { jwtExpiryIso } from "../agent/token-expiry"; +import { pickPlayCookies } from "./cookies"; export type ViaplayAccountHealth = "ok" | "dead" | "unknown"; @@ -17,6 +18,10 @@ export type ViaplayAccountStored = { /** Optioneel — voor latere tray/login. */ passwordEnc?: 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; healthStatus?: ViaplayAccountHealth; healthCheckedAt?: string; @@ -36,6 +41,10 @@ export type ViaplayAccountPublic = { hasAccessToken: boolean; hasProfileId: boolean; hasPassword: boolean; + hasCookieJar: boolean; + hasDatadome: boolean; + hasBoid: boolean; + cookieNames: string[]; enabled: boolean; eventCount: number; healthStatus: ViaplayAccountHealth; @@ -58,6 +67,7 @@ export type ViaplayAccountCreds = { deviceId: string; password: string | null; persistentLogin: string | null; + cookieJar: Record | null; enabled: boolean; }; @@ -82,6 +92,12 @@ export function accountToPublic( hasAccessToken: !!account.accessTokenEnc, hasProfileId: !!account.profileId?.trim(), 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, eventCount, healthStatus: account.healthStatus ?? "unknown", @@ -117,10 +133,29 @@ export function decryptAccount( persistentLogin: account.persistentLoginEnc ? decryptSecret(account.persistentLoginEnc, sessionSecret) : null, + cookieJar: parseCookieJar(account.cookieJarEnc, sessionSecret), enabled: account.enabled !== false, }; } +function parseCookieJar( + enc: string | undefined, + sessionSecret: string +): Record | 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 = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "string" && v.trim()) out[k] = v.trim(); + } + return Object.keys(out).length ? out : null; + } catch { + return null; + } +} + export function createAccountStored( sessionSecret: string, input: { @@ -135,6 +170,7 @@ export function createAccountStored( deviceId?: string; password?: string; persistentLogin?: string; + cookieJar?: Record; } ): ViaplayAccountStored { const email = input.email.trim().toLowerCase(); @@ -163,6 +199,13 @@ export function createAccountStored( persistentLoginEnc: input.persistentLogin?.trim() ? encryptSecret(input.persistentLogin.trim(), sessionSecret) : 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, tokenExpiresAt: accessToken ? jwtExpiryIso(accessToken) ?? undefined : undefined, }; @@ -188,6 +231,10 @@ export function parseAccountsJson(raw: unknown): ViaplayAccountStored[] { deviceId: String(row.deviceId ?? "").trim() || undefined, passwordEnc: String(row.passwordEnc ?? "").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, healthStatus: row.healthStatus === "ok" || diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 1239f59..afe34e2 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import type { ViaplayAccountCreds } 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. */ export const VIAPLAY_USER_AGENT = @@ -55,17 +56,24 @@ function parseSetCookieJar(setCookies: string[]): Record { } 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)}`); - } - return parts.join("; "); + const merged: Record = { ...(creds.cookieJar || {}) }; + if (creds.session) merged.session = creds.session; + if (creds.accessToken) merged.accessToken = creds.accessToken.trim(); + if (creds.profileId) merged.viaplay_profileId = creds.profileId.trim(); + if (creds.boid) merged["__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("; "); +} + +export function cookieInventory(creds: ViaplayAccountCreds): CookieInventory { + const names = cookieHeader(creds) + .split(/;\s*/) + .map((p) => p.split("=")[0] || "") + .filter(Boolean); + return inventoryFromNames(names, creds.session); } export function authHeaders(creds: ViaplayAccountCreds): Record { @@ -155,6 +163,19 @@ export async function refreshViaplaySession( const session = sessionRaw; const accessToken = normalizeAccessToken(accessRaw) || accessRaw.trim(); 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 { ...creds, @@ -162,6 +183,7 @@ export async function refreshViaplaySession( accessToken, persistentLogin: nextPl, boid: jar["__Secure-Viaplay-Boid"] || creds.boid, + cookieJar: Object.keys(nextJar).length ? nextJar : creds.cookieJar, }; } diff --git a/apps/master-api/src/viaplay/cookies.ts b/apps/master-api/src/viaplay/cookies.ts new file mode 100644 index 0000000..02972a2 --- /dev/null +++ b/apps/master-api/src/viaplay/cookies.ts @@ -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 | null | undefined +): Record { + const out: Record = {}; + 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"), + }; +} diff --git a/apps/master-api/src/viaplay/diag.ts b/apps/master-api/src/viaplay/diag.ts new file mode 100644 index 0000000..38de71a --- /dev/null +++ b/apps/master-api/src/viaplay/diag.ts @@ -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 { + 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 || "") + ); +} diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 2751809..bf1b9e1 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -14,11 +14,21 @@ import { fetchMpdXml, extractPsshFromMpd, fetchViaplayBootstrapPssh, + cookieInventory, isDontGoPlaceholder, isLiveDashMpd, mpdLooksLikeDontGo, VIAPLAY_USER_AGENT, } from "./client"; +import { inventoryFromNames, type CookieInventory } from "./cookies"; +import { + isDontGoError, + mediaPathOf, + recordViaplayPlay, + getLastViaplayPlay, + getViaplayPlayLog, + type ViaplayPlayDiag, +} from "./diag"; import { loadViaplaySettings, patchViaplayEventCache, @@ -27,44 +37,70 @@ import { findViaplayEvent } from "./sync"; import { isMpdCacheFresh, type ViaplayEventSnapshot } from "./tweaks"; import { VIAPLAY_CATCHUP_MS } from "./events"; -export type LastVodPlayDiag = { - 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[]; -}; +export type LastVodPlayDiag = ViaplayPlayDiag; -let lastVodPlay: LastVodPlayDiag | null = null; - -export function getLastVodPlay(): LastVodPlayDiag | null { - return lastVodPlay; +export function getLastVodPlay() { + return getLastViaplayPlay(); } -function recordVodPlay( partial: Omit ) { - lastVodPlay = { +export { getViaplayPlayLog }; + +function emptyCookies(): CookieInventory { + return inventoryFromNames([], null); +} + +function recordVodPlay( + partial: Partial & { 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, - at: new Date().toISOString(), - resolver: "viaplay-byguid-v9", - }; + kind: "vod", + resolver: "viaplay-byguid-v10", + }); } -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); - } +function recordLivePlay( + partial: Partial & { guid: string; ok: boolean } +) { + const error = partial.error ?? null; + recordViaplayPlay({ + kind: "live", + title: null, + 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 { @@ -306,6 +342,7 @@ export async function ensureViaplayEventReady( accessToken: refreshed.accessToken || undefined, persistentLogin: refreshed.persistentLogin || undefined, boid: refreshed.boid || undefined, + cookieJar: refreshed.cookieJar || undefined, }); } catch { /* continue with in-memory refresh */ @@ -396,6 +433,19 @@ export async function ensureViaplayEventReady( 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 { streamUrl: kap.mpdUrl, format: "dash", @@ -407,6 +457,14 @@ export async function ensureViaplayEventReady( }; } catch (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, persistentLogin: refreshed.persistentLogin || undefined, boid: refreshed.boid || undefined, + cookieJar: refreshed.cookieJar || undefined, }); } catch { /* play mag doorgaan met in-memory refresh */ @@ -609,7 +668,12 @@ async function resolveViaplayGuidPlayInner( ok: true, title: play.title, accountId: account.id, + accountLabel: label, mediaPath: mediaPathOf(mpdUrl), + hasLicense: !!play.licenseUrl, + dontGo: isDontGoPlaceholder(mpdUrl), + productDurationMs: play.durationMs, + cookies: cookieInventory(creds), error: null, triedAccounts: triedLabels.length, triedLabels, diff --git a/apps/master-api/src/viaplay/settings.ts b/apps/master-api/src/viaplay/settings.ts index ca02a8d..0bfe57d 100644 --- a/apps/master-api/src/viaplay/settings.ts +++ b/apps/master-api/src/viaplay/settings.ts @@ -13,6 +13,7 @@ import { type ViaplayAccountStored, type ViaplayEventOwners, } from "./accounts"; +import { pickPlayCookies } from "./cookies"; import { fetchAccountOverview } from "./client"; import { mergeViaplayTweaks, @@ -205,6 +206,7 @@ export async function addViaplayAccount( boid?: string; password?: string; persistentLogin?: string; + cookieJar?: Record; } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); @@ -236,6 +238,7 @@ export async function upsertViaplayAccountByEmail( profileId?: string; boid?: string; persistentLogin?: string; + cookieJar?: Record; } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: VIAPLAY_ID } }); @@ -256,6 +259,7 @@ export async function upsertViaplayAccountByEmail( profileId: input.profileId, boid: input.boid, persistentLogin: input.persistentLogin, + cookieJar: input.cookieJar, enabled: true, }); } @@ -267,6 +271,7 @@ export async function upsertViaplayAccountByEmail( profileId: input.profileId, boid: input.boid, persistentLogin: input.persistentLogin, + cookieJar: input.cookieJar, }); } @@ -283,6 +288,7 @@ export async function updateViaplayAccount( boid?: string; password?: string; persistentLogin?: string; + cookieJar?: Record; clearCookies?: boolean; } ) { @@ -303,12 +309,16 @@ export async function updateViaplayAccount( let profileId = current.profileId; let boid = current.boid; let persistentLoginEnc = current.persistentLoginEnc; + let cookieJarEnc = current.cookieJarEnc; + let cookieNames = current.cookieNames; if (input.clearCookies) { sessionEnc = undefined; accessTokenEnc = undefined; profileId = undefined; boid = undefined; persistentLoginEnc = undefined; + cookieJarEnc = undefined; + cookieNames = undefined; } if (input.session?.trim()) { sessionEnc = encryptSecret(input.session.trim(), sessionSecret); @@ -329,6 +339,13 @@ export async function updateViaplayAccount( if (input.persistentLogin?.trim()) { 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; if (input.clearCookies) { @@ -351,6 +368,8 @@ export async function updateViaplayAccount( profileId, boid, persistentLoginEnc, + cookieJarEnc, + cookieNames, passwordEnc: input.password?.trim() ? encryptSecret(input.password.trim(), sessionSecret) : current.passwordEnc, diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 51d9254..8e61c5a 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1096,7 +1096,7 @@ export function registerViewerRoutes( fallbackFormat: null, live: false, /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ - resolver: "viaplay-byguid-v9", + resolver: "viaplay-byguid-v10", mediaPath: (() => { try { const u = new URL(played.streamUrl); diff --git a/apps/token-tray/src/jobs.ts b/apps/token-tray/src/jobs.ts index b7e9947..66f934b 100644 --- a/apps/token-tray/src/jobs.ts +++ b/apps/token-tray/src/jobs.ts @@ -54,6 +54,7 @@ async function pushViaplay( profileId: string | null; persistentLogin: string | null; boid?: string | null; + cookieJar?: Record; } ) { const res = await fetch(`${cfg.apiUrl}/api/v1/agent/viaplay/tokens`, { @@ -70,6 +71,7 @@ async function pushViaplay( profileId: tokens.profileId || undefined, persistentLogin: tokens.persistentLogin || undefined, boid: tokens.boid || undefined, + cookieJar: tokens.cookieJar, }), }); const data = (await res.json().catch(() => ({}))) as { error?: { message?: string } }; diff --git a/apps/token-tray/src/login/viaplay.ts b/apps/token-tray/src/login/viaplay.ts index c497e07..528069b 100644 --- a/apps/token-tray/src/login/viaplay.ts +++ b/apps/token-tray/src/login/viaplay.ts @@ -16,6 +16,7 @@ export type ViaplayLoginResult = { profileId: string | null; persistentLogin: string | null; boid: string | null; + cookieJar: Record; }; function profileDir(accountId: string): string { @@ -98,8 +99,32 @@ async function readCookies(context: BrowserContext): Promise = {}; + 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; - return { session, accessToken, profileId, persistentLogin, boid }; + return { session, accessToken, profileId, persistentLogin, boid, cookieJar }; } async function launchProfile(userDataDir: string, headless: boolean) { diff --git a/apps/token-tray/src/viaplay.ts b/apps/token-tray/src/viaplay.ts index b5775f1..dea266d 100644 --- a/apps/token-tray/src/viaplay.ts +++ b/apps/token-tray/src/viaplay.ts @@ -7,9 +7,39 @@ export type CapturedTokens = { profileId: string | null; persistentLogin: string | null; boid: string | null; + cookieJar: Record; 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): Record { + const out: Record = {}; + 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 = [ "viaplay.com", ".viaplay.com", @@ -50,7 +80,7 @@ export async function captureViaplayTokens(partition: string): Promise ({}))) as {