diff --git a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx index c7a93ec..f895852 100644 --- a/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx +++ b/apps/admin-ui/src/app/scripts/ViaplayPanel.tsx @@ -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) =

Play-diagnose

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.

{playLog.length === 0 ? (

Nog geen plays sinds de laatste API-restart.

@@ -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" : ""} {(e.dontGoReason || e.error) && (
{e.dontGoReason || e.error}
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index f35e831..d32b5bd 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -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 () => { - const { getViaplayPlayLog, getLastVodPlay } = await import("../viaplay/play"); - return { last: getLastVodPlay(), log: getViaplayPlayLog() }; - }); + 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: await getLastVodPlay(), log: await getViaplayPlayLog() }; + } + ); app.post("/api/v1/admin/scripts/viaplay/sync", { preHandler: requireAdmin }, async () => { const { syncViaplayCatalog } = await import("../viaplay/sync"); diff --git a/apps/master-api/src/agent/routes.ts b/apps/master-api/src/agent/routes.ts index 38070c6..cb9463c 100644 --- a/apps/master-api/src/agent/routes.ts +++ b/apps/master-api/src/agent/routes.ts @@ -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(), }; } }); diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index a1e460f..379bbf8 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -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(), }; }); diff --git a/apps/master-api/src/viaplay/client.ts b/apps/master-api/src/viaplay/client.ts index afe34e2..17b7735 100644 --- a/apps/master-api/src/viaplay/client.ts +++ b/apps/master-api/src/viaplay/client.ts @@ -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 | 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)" ); diff --git a/apps/master-api/src/viaplay/diag.ts b/apps/master-api/src/viaplay/diag.ts index 38de71a..d809c46 100644 --- a/apps/master-api/src/viaplay/diag.ts +++ b/apps/master-api/src/viaplay/diag.ts @@ -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 | 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 { + 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 { + 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 { 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 { + await hydrate(); return log.slice(); } -export function getLastViaplayPlay(): ViaplayPlayDiag | null { +export async function getLastViaplayPlay(): Promise { + await hydrate(); return log[0] ?? null; } diff --git a/apps/master-api/src/viaplay/play.ts b/apps/master-api/src/viaplay/play.ts index 9c3624a..bbdd224 100644 --- a/apps/master-api/src/viaplay/play.ts +++ b/apps/master-api/src/viaplay/play.ts @@ -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 { + if (!isReusableLiveMpd(url)) return false; + try { + const mpd = await fetchMpdXml(url, Math.min(timeoutMs, 8_000)); + if (mpd.status !== 200 || !/]/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,34 +270,48 @@ 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) - ) { - return { - streamUrl: event.mpdUrl, - format: "dash", - keys: cachedKeys, - fromCache: true, + 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", + keys: cachedKeys, + fromCache: true, + mediaGuid, + live: !ended, + }; + } + await dropEventMpdCache( mediaGuid, - live: !ended, - }; + "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,45 +682,54 @@ 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) { - if (!pssh) { - pssh = await fetchViaplayBootstrapPssh(tweaks.httpTimeoutMs); - } - if (!pssh) { - throw new Error("Geen PSSH (MPD + bootstrap) — VOD-keys niet op te halen"); - } - const extracted = await extractViaplayKeys({ - wvdPath: settings.wvdPath, - pssh, - licUrl: play.licenseUrl, - licAuth: play.licenseAuth, - userAgent: VIAPLAY_USER_AGENT, - timeoutMs: tweaks.cdmTimeoutMs, - }); - const parsed = extracted - .map(parseKey) - .filter((k): k is { kid: string; key: string } => !!k); - if (parsed.length) { - keys = parsed; - cachedKeys = parsed; - const { upsertDrmKey } = await import("../drm/keys"); - for (const k of parsed) { - try { - await upsertDrmKey(prisma, sessionSecret, { - kid: k.kid, - key: k.key, - mpdUrl, - eventName: play.title || guid, - channelName: play.channelName, - source: "viaplay", - externalId: guid, - mediaGuid: guid, - }); - } catch { - /* ignore */ + } else if (wantKeys) { + keys = cachedKeys; + if (!keys.length) { + if (!pssh) { + pssh = await fetchViaplayBootstrapPssh(tweaks.httpTimeoutMs); + } + if (!pssh) { + throw new Error("Geen PSSH (MPD + bootstrap) — VOD-keys niet op te halen"); + } + const extracted = await extractViaplayKeys({ + wvdPath: settings.wvdPath, + pssh, + licUrl: play.licenseUrl, + licAuth: play.licenseAuth, + userAgent: VIAPLAY_USER_AGENT, + timeoutMs: tweaks.cdmTimeoutMs, + }); + const parsed = extracted + .map(parseKey) + .filter((k): k is { kid: string; key: string } => !!k); + if (parsed.length) { + keys = parsed; + cachedKeys = parsed; + const { upsertDrmKey } = await import("../drm/keys"); + for (const k of parsed) { + try { + await upsertDrmKey(prisma, sessionSecret, { + kid: k.kid, + key: k.key, + mpdUrl, + eventName: play.title || guid, + channelName: play.channelName, + source: "viaplay", + externalId: guid, + mediaGuid: guid, + }); + } catch { + /* ignore */ + } } } } @@ -655,9 +737,6 @@ async function resolveViaplayGuidPlayInner( 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, + }); } } diff --git a/apps/master-api/src/viaplay/tweaks.ts b/apps/master-api/src/viaplay/tweaks.ts index 6bfd42a..462d6d1 100644 --- a/apps/master-api/src/viaplay/tweaks.ts +++ b/apps/master-api/src/viaplay/tweaks.ts @@ -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", },