Reject Viaplay Don't go soft-fail and keep stable deviceIds.

Don't go is a placeholder MPD (mediaGuid=unknown), often when the account hit the 5-device limit. Fail closed and stop minting a new deviceId on every decrypt.
This commit is contained in:
Jos Vooges | STH 2026-09-24 00:34:57 +02:00
parent f20b29362e
commit c1378aa1d5
7 changed files with 71 additions and 13 deletions

View file

@ -1928,14 +1928,14 @@ 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-har-v6", resolver: "viaplay-byguid-har-v7",
}; };
} 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-har-v6", resolver: "viaplay-byguid-har-v7",
}; };
} }
} }

View file

@ -227,7 +227,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
mediaPath, mediaPath,
keyCount: played.keys.length, keyCount: played.keys.length,
promo, promo,
resolver: "viaplay-byguid-har-v6", resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(), lastVodPlay: getLastVodPlay(),
}; };
} catch (err) { } catch (err) {
@ -235,7 +235,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
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-har-v6", resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(), lastVodPlay: getLastVodPlay(),
}; };
} }

View file

@ -106,7 +106,7 @@ async function main() {
return { return {
status: "ok", status: "ok",
service: "master-api", service: "master-api",
version: "1.0.3-vod-assert-v6b", version: "1.0.4-dontgo-detect-v7",
lastVodPlay: getLastVodPlay(), lastVodPlay: getLastVodPlay(),
}; };
}); });

View file

@ -907,10 +907,42 @@ function playBodyGuid(body: Record<string, unknown>): string | null {
return typeof system?.guid === "string" ? system.guid.trim() : null; return typeof system?.guid === "string" ? system.guid.trim() : null;
} }
/** Bekende Don't go soft-fail CDN-asset (titel/guid kloppen, media niet). */
const VIAPLAY_DONT_GO_MEDIA_RE = /\/7c4cd64\/88000100-1778484912807\.ism\b/i;
function cseMediaGuid(body: Record<string, unknown>): string | null {
try {
const cse = asRecord(body.cseReporting);
const link = asRecord(asRecord(cse?.link)?.reportingUrl);
const href = typeof link?.href === "string" ? link.href : "";
const m = href.match(/\/viaplay\/([^/$]+)/);
if (!m?.[1]) return null;
const payload = JSON.parse(
Buffer.from(m[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString()
) as { mediaGuid?: string };
return typeof payload.mediaGuid === "string" ? payload.mediaGuid : null;
} catch {
return null;
}
}
function mediaHrefFromPlayBody(body: Record<string, unknown>): string | null {
const links = asRecord(body._links);
for (const key of [
"viaplay:media",
"viaplay:playlist",
"viaplay:encryptedPlaylist",
] as const) {
const href = asRecord(links?.[key])?.href;
if (typeof href === "string" && href.trim()) return href.trim();
}
return null;
}
/** /**
* HAR-success: guid + titel + product-link + productType. * HAR-success: guid + titel + product-link + echte media (geen Don't go soft-fail).
* Soft-fail “Don't go” komt vaak als 200 met lege titel / ontbrekende guid — * Soft-fail: 200 met juiste product-metadata maar placeholder-MPD “Don't go”
* dat mag nooit door (anders plakt pageTitle over de promo-MPD). * (vaak bij max devices / geen stream-recht).
*/ */
function assertVodPlayMatchesGuid( function assertVodPlayMatchesGuid(
body: Record<string, unknown>, body: Record<string, unknown>,
@ -931,6 +963,20 @@ function assertVodPlayMatchesGuid(
if (isPromoVodTitle(played.title)) { if (isPromoVodTitle(played.title)) {
throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`); throw new Error(`Viaplay gaf promo-stream (“${played.title}”)`);
} }
const mediaHref = mediaHrefFromPlayBody(body) || played.mpdUrl;
if (VIAPLAY_DONT_GO_MEDIA_RE.test(mediaHref)) {
throw new Error(
"Viaplay soft-fail: Don't go-placeholder i.p.v. stream (vaak te veel apparaten — log uit op viaplay.com → Apparaten beheren)"
);
}
const streamMediaGuid = cseMediaGuid(body);
if (streamMediaGuid === "unknown") {
throw new Error(
"Viaplay soft-fail: mediaGuid=unknown (geen echte stream — check apparaatlimiet of abonnement)"
);
}
const links = asRecord(body._links); const links = asRecord(body._links);
const productHref = asRecord(links?.["viaplay:product"])?.href; const productHref = asRecord(links?.["viaplay:product"])?.href;
if (typeof productHref !== "string" || !productHref.trim()) { if (typeof productHref !== "string" || !productHref.trim()) {
@ -958,13 +1004,11 @@ function assertVodPlayMatchesGuid(
} }
const durationMs = Number(body.duration); const durationMs = Number(body.duration);
if (Number.isFinite(durationMs) && durationMs > 0) { if (Number.isFinite(durationMs) && durationMs > 0) {
// Ultrashorte bumper/slate i.p.v. echte clip (highlights mogen ~30–60s zijn)
if (wantsClip && durationMs < 15_000) { if (wantsClip && durationMs < 15_000) {
throw new Error( throw new Error(
`Viaplay gaf te korte stream (${Math.round(durationMs / 1000)}s) — promo-slate?` `Viaplay gaf te korte stream (${Math.round(durationMs / 1000)}s) — promo-slate?`
); );
} }
// Feature-lengte i.p.v. clip
if (wantsClip && durationMs > 2.5 * 60 * 60 * 1000) { if (wantsClip && durationMs > 2.5 * 60 * 60 * 1000) {
throw new Error( throw new Error(
`Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)` `Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)`

View file

@ -44,7 +44,7 @@ function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) {
lastVodPlay = { lastVodPlay = {
...partial, ...partial,
at: new Date().toISOString(), at: new Date().toISOString(),
resolver: "viaplay-byguid-har-v6", resolver: "viaplay-byguid-har-v7",
}; };
} }

View file

@ -454,11 +454,18 @@ export async function loadViaplaySettings(sessionSecret: string): Promise<Loaded
throw new AppError("INVALID_REQUEST", "Viaplay-integratie is uitgeschakeld", 400); throw new AppError("INVALID_REQUEST", "Viaplay-integratie is uitgeschakeld", 400);
} }
const config = parseConfig(row.configJson); const config = parseConfig(row.configJson);
let deviceIdsAssigned = false;
const accounts = config.accounts const accounts = config.accounts
.filter((a) => a.enabled !== false) .filter((a) => a.enabled !== false)
.map((a) => { .map((a) => {
try { try {
return decryptAccount(a, sessionSecret); const creds = decryptAccount(a, sessionSecret);
// Stabiel deviceId bewaren — random per decrypt verbrandt Viaplay's 5-device limiet.
if (!a.deviceId && creds.deviceId) {
a.deviceId = creds.deviceId;
deviceIdsAssigned = true;
}
return creds;
} catch { } catch {
return null; return null;
} }
@ -475,6 +482,13 @@ export async function loadViaplaySettings(sessionSecret: string): Promise<Loaded
400 400
); );
} }
if (deviceIdsAssigned) {
try {
await persist(config, true);
} catch {
/* play mag door */
}
}
return { accounts, config, wvdPath: await resolveWvdPath(config) }; return { accounts, config, wvdPath: await resolveWvdPath(config) };
} }

View file

@ -1084,7 +1084,7 @@ export function registerViewerRoutes(
fallbackStreamUrl: null, fallbackStreamUrl: null,
fallbackFormat: null, fallbackFormat: null,
/** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */ /** Deploy/debug: bevestigt VOD-resolver revisie na Dokploy. */
resolver: "viaplay-byguid-har-v6", resolver: "viaplay-byguid-har-v7",
mediaPath: (() => { mediaPath: (() => {
try { try {
const u = new URL(played.streamUrl); const u = new URL(played.streamUrl);