Stop serving cached Viaplay Don't go MPDs and log every play including cache hits.

This commit is contained in:
Jos Vooges | STH 2026-09-28 20:15:33 +02:00
parent 8d54e2d8c6
commit 133f51284d
8 changed files with 264 additions and 99 deletions

View file

@ -56,6 +56,7 @@ type PlayLogEntry = {
hasClientId: boolean;
hasPersistentLogin: boolean;
};
fromCache?: boolean;
};
type ViaplaySettings = {
@ -138,6 +139,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
const loadPlayLog = useCallback(async () => {
const r = await fetch("/api/v1/admin/scripts/viaplay/play-log", {
credentials: "include",
cache: "no-store",
});
if (!r.ok) return;
const d = (await r.json()) as { log?: PlayLogEntry[] };
@ -485,7 +487,7 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
<h3 style={{ marginBottom: "0.5rem" }}>Play-diagnose</h3>
<p className="muted" style={{ marginTop: 0, fontSize: "0.85rem" }}>
Geen cookie-waarden, wel namen + of Viaplay Don't go teruggaf. Speel iets in de
app en kijk hier binnen een paar seconden.
app — ook een cache-hit verschijnt hier binnen een paar seconden.
</p>
{playLog.length === 0 ? (
<p className="muted">Nog geen plays sinds de laatste API-restart.</p>
@ -512,11 +514,12 @@ export function ViaplayPanel({ onChanged }: { onChanged?: (o: ViaplayOverview) =
{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" : ""}
{` · cookies ${e.cookies?.count ?? 0}`}
{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.fromCache ? " · cache" : ""}
</div>
{(e.dontGoReason || e.error) && (
<div style={{ marginTop: 4 }}>{e.dontGoReason || e.error}</div>

View file

@ -1929,7 +1929,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
keyCount: played.keys.length,
promo,
resolver: "viaplay-byguid-v10",
lastPlay: (await import("../viaplay/play")).getLastVodPlay(),
lastPlay: await (await import("../viaplay/play")).getLastVodPlay(),
};
} catch (err) {
return {
@ -1937,17 +1937,22 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
guid,
error: err instanceof Error ? err.message : String(err),
resolver: "viaplay-byguid-v10",
lastPlay: (await import("../viaplay/play")).getLastVodPlay(),
lastPlay: await (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 () => {
app.get(
"/api/v1/admin/scripts/viaplay/play-log",
{ preHandler: requireAdmin },
async (_request, reply) => {
reply.header("Cache-Control", "no-store, no-cache, must-revalidate");
const { getViaplayPlayLog, getLastVodPlay } = await import("../viaplay/play");
return { last: getLastVodPlay(), log: getViaplayPlayLog() };
});
return { last: await getLastVodPlay(), log: await getViaplayPlayLog() };
}
);
app.post("/api/v1/admin/scripts/viaplay/sync", { preHandler: requireAdmin }, async () => {
const { syncViaplayCatalog } = await import("../viaplay/sync");

View file

@ -232,7 +232,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
keyCount: played.keys.length,
promo,
resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(),
lastVodPlay: await getLastVodPlay(),
};
} catch (err) {
return {
@ -240,7 +240,7 @@ export function registerAgentRoutes(app: FastifyInstance, config: Config) {
guid,
error: err instanceof Error ? err.message : String(err),
resolver: "viaplay-byguid-har-v7",
lastVodPlay: getLastVodPlay(),
lastVodPlay: await getLastVodPlay(),
};
}
});

View file

@ -103,12 +103,12 @@ async function main() {
});
app.get("/health", async () => {
const { getLastVodPlay } = await import("./viaplay/play");
const { peekLastViaplayPlay } = await import("./viaplay/play");
return {
status: "ok",
service: "master-api",
version: "1.0.7-ziggo-gracenote",
lastVodPlay: getLastVodPlay(),
lastVodPlay: peekLastViaplayPlay(),
};
});

View file

@ -584,6 +584,11 @@ function cachedStreamHref(guid: string): string | null {
return hit.href;
}
export function forgetStreamHref(guid: string): void {
const g = String(guid || "").trim();
if (g) streamHrefCache.delete(g);
}
function streamHref(product: Record<string, unknown>): string | null {
const links = asRecord(product._links);
const stream = asRecord(links?.["viaplay:stream"]);
@ -1318,6 +1323,7 @@ export async function playByGuid(
isDontGoPlaceholder(mpd.url) ||
mpdLooksLikeDontGo(mpd.xml, expectedMs)
) {
forgetStreamHref(guid);
throw new Error(
"Viaplay MPD is Don't go-placeholder (duur/media mismatch)"
);

View file

@ -1,3 +1,4 @@
import { prisma } from "../database/client";
import type { CookieInventory } from "./cookies";
export type ViaplayPlayDiag = {
@ -20,25 +21,85 @@ export type ViaplayPlayDiag = {
refreshOk: boolean | null;
error: string | null;
resolver: string;
fromCache?: boolean;
triedAccounts?: number;
triedLabels?: string[];
};
const MAX = 25;
const PLAY_LOG_ID = "viaplay-play-log";
const log: ViaplayPlayDiag[] = [];
let hydrating: Promise<void> | null = null;
function entryKey(e: ViaplayPlayDiag): string {
return `${e.at}|${e.kind}|${e.guid}|${e.mediaPath || ""}`;
}
function mergeStored(stored: ViaplayPlayDiag[]) {
const seen = new Set(log.map(entryKey));
for (const e of stored) {
if (!e || typeof e.at !== "string" || typeof e.guid !== "string") continue;
if (seen.has(entryKey(e))) continue;
seen.add(entryKey(e));
log.push(e);
}
log.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
if (log.length > MAX) log.length = MAX;
}
async function hydrate(): Promise<void> {
if (!hydrating) {
hydrating = (async () => {
try {
const row = await prisma.integrationSetting.findUnique({
where: { id: PLAY_LOG_ID },
});
if (!row?.configJson) return;
const parsed = JSON.parse(row.configJson) as unknown;
if (Array.isArray(parsed)) mergeStored(parsed as ViaplayPlayDiag[]);
} catch {
/* lege log is ok */
}
})();
}
await hydrating;
}
async function persistLog(): Promise<void> {
try {
const snapshot = JSON.stringify(log.slice(0, MAX));
await prisma.integrationSetting.upsert({
where: { id: PLAY_LOG_ID },
create: { id: PLAY_LOG_ID, configJson: snapshot, enabled: true },
update: { configJson: snapshot },
});
} catch {
/* diagnose mag play niet breken */
}
}
export function recordViaplayPlay(entry: Omit<ViaplayPlayDiag, "at">): ViaplayPlayDiag {
const full: ViaplayPlayDiag = { ...entry, at: new Date().toISOString() };
log.unshift(full);
if (log.length > MAX) log.length = MAX;
void hydrate()
.then(() => persistLog())
.catch(() => undefined);
return full;
}
export function getViaplayPlayLog(): ViaplayPlayDiag[] {
export function peekLastViaplayPlay(): ViaplayPlayDiag | null {
return log[0] ?? null;
}
export async function getViaplayPlayLog(): Promise<ViaplayPlayDiag[]> {
await hydrate();
return log.slice();
}
export function getLastViaplayPlay(): ViaplayPlayDiag | null {
export async function getLastViaplayPlay(): Promise<ViaplayPlayDiag | null> {
await hydrate();
return log[0] ?? null;
}

View file

@ -15,9 +15,11 @@ import {
extractPsshFromMpd,
fetchViaplayBootstrapPssh,
cookieInventory,
forgetStreamHref,
isDontGoPlaceholder,
isLiveDashMpd,
mpdLooksLikeDontGo,
stripKapWindow,
VIAPLAY_USER_AGENT,
} from "./client";
import { inventoryFromNames, type CookieInventory } from "./cookies";
@ -27,6 +29,7 @@ import {
recordViaplayPlay,
getLastViaplayPlay,
getViaplayPlayLog,
peekLastViaplayPlay,
type ViaplayPlayDiag,
} from "./diag";
import {
@ -39,11 +42,11 @@ import { VIAPLAY_CATCHUP_MS } from "./events";
export type LastVodPlayDiag = ViaplayPlayDiag;
export function getLastVodPlay() {
export async function getLastVodPlay() {
return getLastViaplayPlay();
}
export { getViaplayPlayLog };
export { getViaplayPlayLog, peekLastViaplayPlay };
function emptyCookies(): CookieInventory {
return inventoryFromNames([], null);
@ -198,23 +201,51 @@ function accountOrder(
return ordered;
}
function isReusableLiveMpd(url: string | null | undefined): url is string {
const u = url?.trim();
return !!u && isLiveDashMpd(u) && !isDontGoPlaceholder(u);
}
function channelMpdFromEvents(
events: ViaplayEventSnapshot[],
event: ViaplayEventSnapshot
): string | null {
if (event.mpdUrl?.trim()) return event.mpdUrl.trim();
if (isReusableLiveMpd(event.mpdUrl)) return event.mpdUrl.trim();
const channel = event.channelName?.trim().toLowerCase();
const station = event.stationId?.trim();
if (!channel && !station) return null;
for (const other of events) {
const url = other.mpdUrl?.trim();
if (!url) continue;
if (station && other.stationId?.trim() === station) return url;
if (channel && other.channelName?.trim().toLowerCase() === channel) return url;
if (!isReusableLiveMpd(url)) continue;
if (station && other.stationId?.trim() === station) return stripKapWindow(url);
if (channel && other.channelName?.trim().toLowerCase() === channel) {
return stripKapWindow(url);
}
}
return null;
}
async function liveCachedMpdPlayable(url: string, timeoutMs: number): Promise<boolean> {
if (!isReusableLiveMpd(url)) return false;
try {
const mpd = await fetchMpdXml(url, Math.min(timeoutMs, 8_000));
if (mpd.status !== 200 || !/<MPD[\s>]/i.test(mpd.xml)) return false;
if (isDontGoPlaceholder(mpd.url) || mpdLooksLikeDontGo(mpd.xml)) return false;
return true;
} catch {
return false;
}
}
async function dropEventMpdCache(mediaGuid: string, reason: string) {
await patchViaplayEventCache(mediaGuid, {
mpdUrl: null,
mpdCachedAt: null,
lastPlayError: reason,
lastPlayAt: new Date().toISOString(),
});
}
export async function ensureViaplayEventReady(
sessionSecret: string,
event: ViaplayEventSnapshot,
@ -239,16 +270,21 @@ export async function ensureViaplayEventReady(
const cacheFresh =
!opts?.forceRefresh &&
!ended &&
!!event.mpdUrl &&
isReusableLiveMpd(event.mpdUrl) &&
isMpdCacheFresh(event.mpdCachedAt, tweaks.mpdCacheTtlMinutes);
if (
cacheFresh &&
cachedKeys.length &&
event.mpdUrl &&
isLiveDashMpd(event.mpdUrl) &&
!isDontGoPlaceholder(event.mpdUrl)
) {
if (cacheFresh && cachedKeys.length && event.mpdUrl) {
if (await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs)) {
recordLivePlay({
guid: mediaGuid,
ok: true,
title: event.title,
mediaPath: mediaPathOf(event.mpdUrl),
hasLicense: true,
fromCache: true,
cookies: emptyCookies(),
error: null,
});
return {
streamUrl: event.mpdUrl,
format: "dash",
@ -258,15 +294,24 @@ export async function ensureViaplayEventReady(
live: !ended,
};
}
await dropEventMpdCache(
mediaGuid,
"Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live"
);
recordLivePlay({
guid: mediaGuid,
ok: false,
title: event.title,
mediaPath: mediaPathOf(event.mpdUrl),
fromCache: true,
dontGo: true,
cookies: emptyCookies(),
error: "Gecachte MPD ongeldig (Don't go of verlopen) — verse play-live",
});
}
// Terugkijken: KAP alleen op echte live-dash zender-MPD (niet op VOD/Don't go).
if (
ended &&
cachedMpd &&
!opts?.forceRefresh &&
isLiveDashMpd(cachedMpd) &&
!isDontGoPlaceholder(cachedMpd)
) {
if (ended && cachedMpd && !opts?.forceRefresh && isReusableLiveMpd(cachedMpd)) {
try {
const kap = await resolveKapMpd(
cachedMpd,
@ -274,6 +319,9 @@ export async function ensureViaplayEventReady(
event.endMs,
Math.min(tweaks.httpTimeoutMs, 12_000)
);
if (isDontGoPlaceholder(kap.mpdUrl) || !(await liveCachedMpdPlayable(kap.mpdUrl, tweaks.httpTimeoutMs))) {
throw new Error("KAP-MPD is Don't go of ongeldig");
}
if (!cachedKeys.length) {
cachedKeys = await keysForChannelPath(sessionSecret, kap.mpdUrl);
}
@ -286,6 +334,16 @@ export async function ensureViaplayEventReady(
lastPlayError: null,
lastPlayAt: new Date().toISOString(),
});
recordLivePlay({
guid: mediaGuid,
ok: true,
title: event.title,
mediaPath: mediaPathOf(kap.mpdUrl),
hasLicense: true,
fromCache: true,
cookies: emptyCookies(),
error: null,
});
return {
streamUrl: kap.mpdUrl,
format: "dash",
@ -304,10 +362,20 @@ export async function ensureViaplayEventReady(
if (
cacheFresh &&
event.mpdUrl &&
isLiveDashMpd(event.mpdUrl) &&
!isDontGoPlaceholder(event.mpdUrl) &&
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing)
isReusableLiveMpd(event.mpdUrl) &&
!(opts?.fetchKeys ?? tweaks.fetchKeysIfMissing) &&
(await liveCachedMpdPlayable(event.mpdUrl, tweaks.httpTimeoutMs))
) {
recordLivePlay({
guid: mediaGuid,
ok: true,
title: event.title,
mediaPath: mediaPathOf(event.mpdUrl),
hasLicense: cachedKeys.length > 0,
fromCache: true,
cookies: emptyCookies(),
error: null,
});
return {
streamUrl: event.mpdUrl,
format: "dash",
@ -365,6 +433,16 @@ export async function ensureViaplayEventReady(
pssh: null as string | null,
};
if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream");
}
if (!ended && !isLiveDashMpd(play.mpdUrl)) {
throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)");
}
if (kap.xml && mpdLooksLikeDontGo(kap.xml)) {
throw new Error("Viaplay MPD is Don't go-placeholder (duur/media mismatch)");
}
let keys = cachedKeys;
if (!keys.length) {
keys = await keysForChannelPath(sessionSecret, play.mpdUrl);
@ -410,12 +488,6 @@ export async function ensureViaplayEventReady(
if (play.licenseUrl && !keys.length) {
throw new Error("Viaplay-licentie aanwezig maar geen keys");
}
if (isDontGoPlaceholder(play.mpdUrl) || isDontGoPlaceholder(kap.mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de wedstrijdstream");
}
if (!ended && !isLiveDashMpd(play.mpdUrl)) {
throw new Error("Viaplay gaf geen live-dash stream (Don't go/VOD-fallback)");
}
await patchViaplayEventCache(mediaGuid, {
mpdUrl: kap.mpdUrl,
@ -437,9 +509,10 @@ export async function ensureViaplayEventReady(
accountLabel: account.label,
mediaPath: mediaPathOf(kap.mpdUrl),
hasLicense: !!play.licenseUrl,
dontGo: isDontGoPlaceholder(kap.mpdUrl),
dontGo: false,
cookies: cookieInventory(creds),
error: null,
fromCache: false,
});
return {
@ -587,7 +660,6 @@ async function resolveViaplayGuidPlayInner(
sectionPath: "/sport",
});
let keys = cachedKeys;
const wantKeys = tweaks.fetchKeysIfMissing;
let pssh: string | null = null;
let mpdUrl = play.mpdUrl;
@ -598,6 +670,7 @@ async function resolveViaplayGuidPlayInner(
isDontGoPlaceholder(mpd.url) ||
mpdLooksLikeDontGo(mpd.xml, play.durationMs)
) {
forgetStreamHref(guid);
throw new Error(
"Viaplay MPD is Don't go-placeholder (duur/media mismatch)"
);
@ -609,10 +682,18 @@ async function resolveViaplayGuidPlayInner(
if (err instanceof Error && /Don't go/i.test(err.message)) throw err;
}
// Clear VOD (HAR: geen license) — nooit stale ClearKey van eerdere plays plakken.
if (isDontGoPlaceholder(mpdUrl)) {
forgetStreamHref(guid);
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de gevraagde stream");
}
// Nooit stale ClearKey van een eerdere (Don't go-)play op een verse MPD plakken.
let keys: Array<{ kid: string; key: string }> = [];
if (!play.licenseUrl) {
keys = [];
} else if (wantKeys && !keys.length) {
} else if (wantKeys) {
keys = cachedKeys;
if (!keys.length) {
if (!pssh) {
pssh = await fetchViaplayBootstrapPssh(tweaks.httpTimeoutMs);
}
@ -651,13 +732,11 @@ async function resolveViaplayGuidPlayInner(
}
}
}
}
if (!keys.length) {
throw new Error("Viaplay-licentie aanwezig maar geen VOD-keys");
}
}
if (isDontGoPlaceholder(mpdUrl)) {
throw new Error("Viaplay gaf Don't go-placeholder i.p.v. de gevraagde stream");
}
recordVodPlay({
guid,
@ -667,7 +746,7 @@ async function resolveViaplayGuidPlayInner(
accountLabel: label,
mediaPath: mediaPathOf(mpdUrl),
hasLicense: !!play.licenseUrl,
dontGo: isDontGoPlaceholder(mpdUrl),
dontGo: false,
productDurationMs: play.durationMs,
cookies: cookieInventory(creds),
error: null,
@ -688,6 +767,17 @@ async function resolveViaplayGuidPlayInner(
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
attemptErrors.push(`${label}: ${lastError.message}`);
if (isDontGoError(lastError.message)) forgetStreamHref(guid);
recordVodPlay({
guid,
ok: false,
accountId: account.id,
accountLabel: label,
cookies: cookieInventory(account),
error: lastError.message,
triedAccounts: triedLabels.length,
triedLabels,
});
}
}

View file

@ -108,7 +108,7 @@ export const VIAPLAY_TWEAK_META: Array<{
{
key: "mpdCacheTtlMinutes",
label: "Manifest-cache TTL",
hint: "Hergebruik gecached manifest tot TTL verloopt.",
hint: "Hergebruik live-manifest tot TTL. Elke play her-checkt de MPD (Don't go wordt nooit uit cache gespeeld).",
kind: "number",
unit: "min",
},