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,
keyCount: played.keys.length,
promo,
resolver: "viaplay-byguid-har-v6",
resolver: "viaplay-byguid-har-v7",
};
} catch (err) {
return {
ok: false,
guid,
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,
keyCount: played.keys.length,
promo,
resolver: "viaplay-byguid-har-v6",
resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(),
};
} catch (err) {
@ -235,7 +235,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
ok: false,
guid,
error: err instanceof Error ? err.message : String(err),
resolver: "viaplay-byguid-har-v6",
resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(),
};
}

View file

@ -106,7 +106,7 @@ async function main() {
return {
status: "ok",
service: "master-api",
version: "1.0.3-vod-assert-v6b",
version: "1.0.4-dontgo-detect-v7",
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;
}
/** 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.
* Soft-fail “Don't go” komt vaak als 200 met lege titel / ontbrekende guid —
* dat mag nooit door (anders plakt pageTitle over de promo-MPD).
* HAR-success: guid + titel + product-link + echte media (geen Don't go soft-fail).
* Soft-fail: 200 met juiste product-metadata maar placeholder-MPD “Don't go”
* (vaak bij max devices / geen stream-recht).
*/
function assertVodPlayMatchesGuid(
body: Record<string, unknown>,
@ -931,6 +963,20 @@ function assertVodPlayMatchesGuid(
if (isPromoVodTitle(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 productHref = asRecord(links?.["viaplay:product"])?.href;
if (typeof productHref !== "string" || !productHref.trim()) {
@ -958,13 +1004,11 @@ function assertVodPlayMatchesGuid(
}
const durationMs = Number(body.duration);
if (Number.isFinite(durationMs) && durationMs > 0) {
// Ultrashorte bumper/slate i.p.v. echte clip (highlights mogen ~30–60s zijn)
if (wantsClip && durationMs < 15_000) {
throw new Error(
`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) {
throw new Error(
`Viaplay gaf te lange stream (${Math.round(durationMs / 60000)} min)`

View file

@ -44,7 +44,7 @@ function recordVodPlay( partial: Omit<LastVodPlayDiag, "at" | "resolver"> ) {
lastVodPlay = {
...partial,
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);
}
const config = parseConfig(row.configJson);
let deviceIdsAssigned = false;
const accounts = config.accounts
.filter((a) => a.enabled !== false)
.map((a) => {
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 {
return null;
}
@ -475,6 +482,13 @@ export async function loadViaplaySettings(sessionSecret: string): Promise<Loaded
400
);
}
if (deviceIdsAssigned) {
try {
await persist(config, true);
} catch {
/* play mag door */
}
}
return { accounts, config, wvdPath: await resolveWvdPath(config) };
}

View file

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