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.
This commit is contained in:
parent
ca5b2b30d0
commit
2cc46d87ce
7 changed files with 387 additions and 13 deletions
214
apps/master-api/scripts/probe-viaplay-vod.cjs
Normal file
214
apps/master-api/scripts/probe-viaplay-vod.cjs
Normal file
|
|
@ -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: /<MPD[\s>]/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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -39,12 +39,92 @@ export function authHeaders(creds: ViaplayAccountCreds): Record<string, string>
|
|||
"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<ViaplayAccountCreds | null> {
|
||||
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<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]!;
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> | null = null;
|
||||
try {
|
||||
body = (await res.json()) as Record<string, unknown>;
|
||||
} 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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -463,7 +463,10 @@ export async function loadViaplaySettings(sessionSecret: string): Promise<Loaded
|
|||
return null;
|
||||
}
|
||||
})
|
||||
.filter((a): a is ViaplayAccountCreds => !!a && !!a.session && !!a.accessToken);
|
||||
.filter(
|
||||
(a): a is ViaplayAccountCreds =>
|
||||
!!a && (!!a.persistentLogin || (!!a.session && !!a.accessToken))
|
||||
);
|
||||
|
||||
if (!accounts.length) {
|
||||
throw new AppError(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue