From 2cc46d87ce2ac5cf64d3576f410c430443ca4d66 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 23 Sep 2026 03:37:33 +0200 Subject: [PATCH] Refresh Viaplay session via persistentLogin before VOD play. Probe proved byguid works with a fresh session cookie; expired tray tokens were the real failure mode. Also drop stale ClearKey on clear VOD and add admin vod-probe. --- apps/master-api/scripts/probe-viaplay-vod.cjs | 214 ++++++++++++++++++ apps/master-api/src/admin/routes.ts | 36 +++ apps/master-api/src/app.ts | 2 +- apps/master-api/src/viaplay/client.ts | 82 ++++++- apps/master-api/src/viaplay/play.ts | 59 ++++- apps/master-api/src/viaplay/settings.ts | 5 +- apps/master-api/src/viewer/routes.ts | 2 +- 7 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 apps/master-api/scripts/probe-viaplay-vod.cjs diff --git a/apps/master-api/scripts/probe-viaplay-vod.cjs b/apps/master-api/scripts/probe-viaplay-vod.cjs new file mode 100644 index 0000000..f4c1119 --- /dev/null +++ b/apps/master-api/scripts/probe-viaplay-vod.cjs @@ -0,0 +1,214 @@ +/** + * Refresh Viaplay session via persistentLogin, then probe byguid. + * Writes results to stdout as JSON lines + summary. + */ +const path = require("path"); +const fs = require("fs"); +const { chromium } = require( + path.resolve(__dirname, "../../token-tray/node_modules/playwright") +); + +const UA = + "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"; +const DEVICE_KEY = "pcdash-nl"; +const PROFILES_ROOT = path.join( + process.env.APPDATA || "", + "@media-cluster/token-tray/browser-profiles/viaplay" +); +const GUID = + (process.argv.find((a) => a.startsWith("--guid=")) || "").slice(7) || + "vp-sports-clip-d9a3b8a7-0804-44d"; + +async function refreshFromPersistentLogin(persistentLoginValue) { + const url = + "https://login.viaplay.com/api/persistentLogin/v1?deviceKey=pcdash-nl&returnurl=https%3A%2F%2Fcontent.viaplay.com%2Fpcdash-nl"; + const res = await fetch(url, { + headers: { + Accept: "application/json", + "User-Agent": UA, + Origin: "https://viaplay.com", + Referer: "https://viaplay.com/", + Cookie: `persistentLogin=${persistentLoginValue}`, + }, + }); + const jar = {}; + for (const sc of res.headers.getSetCookie?.() || []) { + const m = sc.match(/^([^=]+)=([^;]*)/); + if (m) jar[m[1]] = m[2]; + } + const body = await res.json().catch(() => ({})); + return { + status: res.status, + success: !!body.success, + session: jar.session ? decodeURIComponent(jar.session) : null, + accessToken: jar.accessToken || body.userData?.accessToken || null, + username: body.userData?.username || null, + raw: body, + }; +} + +function buildHarUrl(guid, deviceId, profileId) { + const u = new URL("https://play.viaplay.com/api/stream/byguid"); + u.searchParams.set("deviceId", deviceId); + u.searchParams.set("deviceName", "web"); + u.searchParams.set("deviceType", "pc"); + u.searchParams.set("userAgent", UA); + u.searchParams.set("deviceKey", DEVICE_KEY); + u.searchParams.set("cse", "true"); + u.searchParams.set("guid", guid); + u.searchParams.set( + "producturl", + `https://content.viaplay.com/${DEVICE_KEY}/sport/${guid}?partial=true` + ); + u.searchParams.set( + "returnurl", + `https://content.viaplay.com/${DEVICE_KEY}/sport` + ); + u.searchParams.set("sectionPath", "/sport"); + u.searchParams.set( + "templatedproducturl", + `https://content.viaplay.com/{deviceKey}/sport/${guid}?partial=true` + ); + u.searchParams.set( + "contextualv2url", + `https://content.viaplay.com/${DEVICE_KEY}/sport/${guid}?contextualV2=true&productType=clip` + ); + u.searchParams.set("defaultAvailabilityContext", "svod"); + u.searchParams.set("win10edge", "true"); + if (profileId) u.searchParams.set("profileId", profileId); + return u.toString(); +} + +async function play(session, accessToken, profileId, guid, label) { + const headers = { + Accept: "*/*", + Origin: "https://viaplay.com", + Referer: "https://viaplay.com/", + "User-Agent": UA, + Cookie: [ + `session=${session}`, + `accessToken=${accessToken}`, + profileId ? `viaplay_profileId=${profileId}` : "", + ] + .filter(Boolean) + .join("; "), + Authorization: `VIAPLAY-AT ${accessToken}`, + }; + const url = buildHarUrl(guid, "probe-device-1", profileId); + const res = await fetch(url, { headers }); + const body = await res.json().catch(() => ({})); + const title = body.product?.content?.title || null; + const media = body._links?.["viaplay:media"]?.href || null; + const respGuid = body.product?.system?.guid || null; + const promo = /don'?t\s*go|viaplay\s*presents|documentary/i.test( + String(title || "") + ); + const out = { + label, + status: res.status, + title, + respGuid, + productType: body.product?.productType || null, + durationMs: body.duration ?? null, + media, + promo, + message: body.message || body.userMessage || null, + ok: res.status === 200 && !!media && !promo && respGuid === guid, + }; + console.log(JSON.stringify(out)); + return out; +} + +async function main() { + const dirs = fs + .readdirSync(PROFILES_ROOT) + .map((name) => ({ + name, + full: path.join(PROFILES_ROOT, name), + mtime: fs.statSync(path.join(PROFILES_ROOT, name)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime); + + let best = null; + for (const d of dirs.slice(0, 5)) { + let ctx; + try { + ctx = await chromium.launchPersistentContext(d.full, { + headless: true, + channel: "msedge", + viewport: { width: 800, height: 600 }, + }); + const cookies = await ctx.cookies(); + const pl = cookies.find((c) => c.name === "persistentLogin"); + const profileId = + cookies.find((c) => c.name === "viaplay_profileId")?.value || null; + if (!pl?.value) { + console.log(JSON.stringify({ skip: d.name, reason: "no persistentLogin" })); + continue; + } + const refreshed = await refreshFromPersistentLogin(pl.value); + console.log( + JSON.stringify({ + profile: d.name, + refreshStatus: refreshed.status, + refreshOk: refreshed.success, + username: refreshed.username, + hasSession: !!refreshed.session, + hasAT: !!refreshed.accessToken, + }) + ); + if (!refreshed.success || !refreshed.session || !refreshed.accessToken) { + continue; + } + const result = await play( + refreshed.session, + refreshed.accessToken, + profileId, + GUID, + `profile:${d.name}` + ); + if (result.ok) { + best = result; + // MPD check + const mpd = await fetch(result.media, { + headers: { "User-Agent": UA, Referer: "https://viaplay.com/" }, + }); + const xml = await mpd.text(); + console.log( + JSON.stringify({ + mpdStatus: mpd.status, + mpdBytes: xml.length, + isMpd: /]/i.test(xml), + protections: (xml.match(/ContentProtection/g) || []).length, + }) + ); + break; + } + } catch (err) { + console.log( + JSON.stringify({ profile: d.name, error: String(err.message || err) }) + ); + } finally { + if (ctx) await ctx.close().catch(() => {}); + } + } + + if (!best) { + console.log(JSON.stringify({ summary: "FAIL", guid: GUID })); + process.exitCode = 2; + } else { + console.log( + JSON.stringify({ + summary: "OK", + guid: GUID, + title: best.title, + media: best.media, + }) + ); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 6859867..41001a9 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1905,6 +1905,42 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) } ); + /** Speel-test: persistentLogin refresh + byguid → titel/media (geen Don't go). */ + app.post( + "/api/v1/admin/scripts/viaplay/vod-probe", + { preHandler: requireAdmin }, + async (request) => { + const body = (request.body as { guid?: string; accountId?: string }) || {}; + const guid = + body.guid?.trim() || "vp-sports-clip-d9a3b8a7-0804-44d"; + const { resolveViaplayGuidPlay } = await import("../viaplay/play"); + try { + const played = await resolveViaplayGuidPlay(config.SESSION_SECRET, guid); + const promo = /don'?t\s*go|viaplay\s*presents|documentary/i.test( + String(played.title || "") + ); + return { + ok: !promo && !!played.streamUrl, + guid, + title: played.title, + mediaGuid: played.mediaGuid, + accountId: played.accountId, + streamUrl: played.streamUrl, + keyCount: played.keys.length, + promo, + resolver: "viaplay-byguid-har-v5", + }; + } catch (err) { + return { + ok: false, + guid, + error: err instanceof Error ? err.message : String(err), + resolver: "viaplay-byguid-har-v5", + }; + } + } + ); + 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/app.ts b/apps/master-api/src/app.ts index a0348b4..514a42e 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -104,7 +104,7 @@ async function main() { app.get("/health", async () => ({ status: "ok", service: "master-api", - version: "1.0.1-vod-har-v4", + version: "1.0.2-vod-refresh-v5", })); await registerAuthRoutes(app, config); diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index 2a44360..bbf2abf 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -39,12 +39,92 @@ export function authHeaders(creds: ViaplayAccountCreds): Record "User-Agent": VIAPLAY_USER_AGENT, Cookie: cookieHeader(creds), }; - // play.viaplay.com + content-API: browser stuurt ook VIAPLAY-AT const token = normalizeAccessToken(creds.accessToken); if (token) headers.Authorization = `VIAPLAY-AT ${token}`; return headers; } +/** + * Vernieuw session/accessToken via persistentLogin (zoals de webapp na cookie-expiry). + * Zonder verse session-cookie geeft play.viaplay.com 403 of rare fallbacks. + */ +export async function refreshViaplaySession( + creds: ViaplayAccountCreds, + opts: { deviceKey: string; timeoutMs: number } +): Promise { + const pl = creds.persistentLogin?.trim(); + if (!pl) return null; + + const url = new URL("https://login.viaplay.com/api/persistentLogin/v1"); + url.searchParams.set("deviceKey", opts.deviceKey); + url.searchParams.set( + "returnurl", + `https://content.viaplay.com/${opts.deviceKey}` + ); + + const res = await fetch(url.toString(), { + headers: { + Accept: "application/json", + "Accept-Language": "nl,en;q=0.9", + Origin: "https://viaplay.com", + Referer: "https://viaplay.com/", + "User-Agent": VIAPLAY_USER_AGENT, + Cookie: `persistentLogin=${pl}`, + }, + signal: AbortSignal.timeout(opts.timeoutMs), + }); + + const jar: Record = {}; + 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]!; + } + + let body: Record | null = null; + try { + body = (await res.json()) as Record; + } catch { + body = null; + } + if (res.status !== 200 || !body?.success) return null; + + const sessionRaw = jar.session || ""; + const accessRaw = + jar.accessToken || + (typeof asRecord(body.userData)?.accessToken === "string" + ? String(asRecord(body.userData)!.accessToken) + : ""); + if (!sessionRaw || !accessRaw) return null; + + let session = sessionRaw; + try { + session = decodeURIComponent(sessionRaw); + } catch { + /* keep raw */ + } + const accessToken = normalizeAccessToken(accessRaw) || accessRaw.trim(); + const nextPl = jar.persistentLogin + ? (() => { + try { + return decodeURIComponent(jar.persistentLogin); + } catch { + return jar.persistentLogin; + } + })() + : pl; + + return { + ...creds, + session, + accessToken, + persistentLogin: nextPl, + }; +} + async function fetchJson( url: string, opts: { diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index cb7ae52..538540f 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -9,6 +9,7 @@ import { extractViaplayKeys } from "./cdm"; import { playByGuid, playByMediaGuid, + refreshViaplaySession, resolveKapMpd, fetchMpdXml, extractPsshFromMpd, @@ -168,7 +169,28 @@ export async function ensureViaplayEventReady( for (const account of ordered) { try { - const play = await playByMediaGuid(account, mediaGuid, { + let creds = account; + if (account.persistentLogin) { + const refreshed = await refreshViaplaySession(account, { + deviceKey: tweaks.deviceKey, + timeoutMs: Math.min(tweaks.httpTimeoutMs, 15_000), + }); + if (refreshed) { + creds = refreshed; + try { + const { updateViaplayAccount } = await import("./settings"); + await updateViaplayAccount(sessionSecret, account.id, { + session: refreshed.session || undefined, + accessToken: refreshed.accessToken || undefined, + persistentLogin: refreshed.persistentLogin || undefined, + }); + } catch { + /* continue with in-memory refresh */ + } + } + } + + const play = await playByMediaGuid(creds, mediaGuid, { deviceKey: tweaks.deviceKey, timeoutMs: tweaks.httpTimeoutMs, }); @@ -292,7 +314,29 @@ export async function resolveViaplayGuidPlay( for (const account of ordered) { try { - const play = await playByGuid(account, guid, { + // Verse session-cookie is verplicht; tray-tokens verlopen snel. + let creds = account; + if (account.persistentLogin) { + const refreshed = await refreshViaplaySession(account, { + deviceKey: tweaks.deviceKey, + timeoutMs: Math.min(tweaks.httpTimeoutMs, 15_000), + }); + if (refreshed) { + creds = refreshed; + try { + const { updateViaplayAccount } = await import("./settings"); + await updateViaplayAccount(sessionSecret, account.id, { + session: refreshed.session || undefined, + accessToken: refreshed.accessToken || undefined, + persistentLogin: refreshed.persistentLogin || undefined, + }); + } catch { + /* play mag doorgaan met in-memory refresh */ + } + } + } + + const play = await playByGuid(creds, guid, { deviceKey: tweaks.deviceKey, timeoutMs: tweaks.httpTimeoutMs, productUrl, @@ -314,8 +358,10 @@ export async function resolveViaplayGuidPlay( /* speel base MPD */ } - // Veel sport-clips zijn clear VOD (geen license in HAR) — geen keys forceren. - if (wantKeys && pssh && play.licenseUrl && !keys.length) { + // Clear VOD (HAR: geen license) — nooit stale ClearKey van eerdere plays plakken. + if (!play.licenseUrl) { + keys = []; + } else if (wantKeys && pssh && !keys.length) { const extracted = await extractViaplayKeys({ wvdPath: settings.wvdPath, pssh, @@ -350,11 +396,6 @@ export async function resolveViaplayGuidPlay( } } - // Geen license + geen keys → clear VOD (zoals officiële web player) - if (!play.licenseUrl && !keys.length) { - keys = []; - } - return { streamUrl: mpdUrl, format: "dash", diff --git a/apps/master-api/src/viaplay/settings.ts b/apps/master-api/src/viaplay/settings.ts index e22d8d1..b12005b 100644 --- a/apps/master-api/src/viaplay/settings.ts +++ b/apps/master-api/src/viaplay/settings.ts @@ -463,7 +463,10 @@ export async function loadViaplaySettings(sessionSecret: string): Promise !!a && !!a.session && !!a.accessToken); + .filter( + (a): a is ViaplayAccountCreds => + !!a && (!!a.persistentLogin || (!!a.session && !!a.accessToken)) + ); if (!accounts.length) { throw new AppError( diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 0c50919..b2d2740 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -1084,7 +1084,7 @@ export function registerViewerRoutes( fallbackStreamUrl: null, fallbackFormat: null, /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ - resolver: "viaplay-byguid-har-v4", + resolver: "viaplay-byguid-har-v5", drm: played.keys.length ? { type: "clearkey",