From 57972ca5f0f2fc0367b70f663a465bdda062a12a Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Mon, 21 Sep 2026 21:24:15 +0200 Subject: [PATCH] Cache Odido MPDs for ~1h with admin tweak knobs and background refresh. --- apps/admin-ui/src/app/scripts/page.tsx | 237 ++++++++++++++++-- .../migration.sql | 3 + apps/master-api/prisma/schema.prisma | 3 + apps/master-api/src/admin/routes.ts | 16 +- apps/master-api/src/app.ts | 2 + apps/master-api/src/odido/client.ts | 13 +- apps/master-api/src/odido/play.ts | 198 +++++++++++++++ apps/master-api/src/odido/refresh.ts | 146 +++++++++++ apps/master-api/src/odido/settings.ts | 63 ++++- apps/master-api/src/odido/sync.ts | 22 +- apps/master-api/src/odido/tweaks.test.ts | 20 ++ apps/master-api/src/odido/tweaks.ts | 145 +++++++++++ apps/master-api/src/viewer/live-channels.ts | 55 ++-- 13 files changed, 849 insertions(+), 74 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260921210000_odido_mpd_cache/migration.sql create mode 100644 apps/master-api/src/odido/play.ts create mode 100644 apps/master-api/src/odido/refresh.ts create mode 100644 apps/master-api/src/odido/tweaks.test.ts create mode 100644 apps/master-api/src/odido/tweaks.ts diff --git a/apps/admin-ui/src/app/scripts/page.tsx b/apps/admin-ui/src/app/scripts/page.tsx index 42fda9b..deeb3b1 100644 --- a/apps/admin-ui/src/app/scripts/page.tsx +++ b/apps/admin-ui/src/app/scripts/page.tsx @@ -4,6 +4,16 @@ import { FormEvent, useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { Nav, useAuth } from "@/components/Nav"; +type TweakMeta = { + key: string; + label: string; + hint: string; + kind: "number" | "boolean"; + unit?: string; +}; + +type OdidoTweaks = Record; + type OdidoSettings = { configured: boolean; enabled: boolean; @@ -14,6 +24,11 @@ type OdidoSettings = { wvdUploadedAt: string | null; lastSyncAt: string | null; lastSyncSummary: Record | null; + lastMpdRefreshAt: string | null; + lastMpdRefreshSummary: Record | null; + tweaks: OdidoTweaks; + tweakDefaults: OdidoTweaks; + tweakMeta: TweakMeta[]; }; function readFileAsBase64(file: File): Promise { @@ -36,22 +51,29 @@ export default function ScriptsPage() { const [pin, setPin] = useState(""); const [wvdPath, setWvdPath] = useState(""); const [enabled, setEnabled] = useState(true); + const [tweaks, setTweaks] = useState({}); + const [showTweaks, setShowTweaks] = useState(false); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [err, setErr] = useState(null); const wvdInputRef = useRef(null); - const load = useCallback(async () => { - const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" }); - const d = await r.json(); - if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt"); + const applySettings = useCallback((d: OdidoSettings) => { setOdido(d); setUsername(d.username ?? ""); setWvdPath(d.wvdPath ?? ""); setEnabled(d.enabled !== false); + setTweaks(d.tweaks ?? d.tweakDefaults ?? {}); setPin(""); }, []); + const load = useCallback(async () => { + const r = await fetch("/api/v1/admin/scripts/odido", { credentials: "include" }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Odido-instellingen laden mislukt"); + applySettings(d); + }, [applySettings]); + useEffect(() => { void load().catch((e) => setErr(String(e.message ?? e))); }, [load]); @@ -66,6 +88,7 @@ export default function ScriptsPage() { username: username.trim(), wvdPath: wvdPath.trim(), enabled, + tweaks, }; if (pin.trim()) body.pin = pin.trim(); const r = await fetch("/api/v1/admin/scripts/odido", { @@ -76,8 +99,7 @@ export default function ScriptsPage() { }); const d = await r.json(); if (!r.ok) throw new Error(d.error?.message ?? "Opslaan mislukt"); - setOdido(d); - setPin(""); + applySettings(d); setMsg("Odido-instellingen opgeslagen"); } catch (e) { setErr(String((e as Error).message ?? e)); @@ -104,8 +126,7 @@ export default function ScriptsPage() { }); const d = await r.json(); if (!r.ok) throw new Error(d.error?.message ?? "WVD-upload mislukt"); - setOdido(d); - setWvdPath(d.wvdPath ?? ""); + applySettings(d); setMsg(`WVD.wvd geüpload (${d.bytes ?? file.size} bytes)`); } catch (e) { setErr(String((e as Error).message ?? e)); @@ -115,7 +136,56 @@ export default function ScriptsPage() { } } + async function refreshMpds() { + setBusy(true); + setErr(null); + setMsg(null); + try { + const r = await fetch("/api/v1/admin/scripts/odido/refresh-mpd", { + method: "POST", + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "MPD-refresh mislukt"); + setMsg( + `MPD-cache vernieuwd: ${d.refreshed ?? 0} ok · ${d.failed ?? 0} fout · ${d.skipped ?? 0} skip (${d.durationMs ?? "?"} ms)` + ); + await load(); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + async function flushMpds() { + if (!confirm("Alle gecachte Odido-MPD’s wissen? Volgende play haalt dan verse URLs.")) return; + setBusy(true); + setErr(null); + setMsg(null); + try { + const r = await fetch("/api/v1/admin/scripts/odido/flush-mpd", { + method: "POST", + credentials: "include", + }); + const d = await r.json(); + if (!r.ok) throw new Error(d.error?.message ?? "Flush mislukt"); + setMsg(`MPD-cache geleegd (${d.cleared ?? 0} zenders)`); + await load(); + } catch (e) { + setErr(String((e as Error).message ?? e)); + } finally { + setBusy(false); + } + } + + function resetTweaks() { + if (odido?.tweakDefaults) setTweaks({ ...odido.tweakDefaults }); + } + const summary = odido?.lastSyncSummary; + const refreshSummary = odido?.lastMpdRefreshSummary; + const meta = odido?.tweakMeta ?? []; return ( <> @@ -144,11 +214,11 @@ export default function ScriptsPage() {

)} -
+

Odido

- Haalt TV-zenders uit je abonnement (geen radio). Play gebruikt verse MPD + gecachte - ClearKey; CDM alleen bij key-fout. + Play gebruikt een gecachte MPD als die jonger is dan de TTL (default 1 uur). Anders + (of via achtergrond-refresh) verse PlayChannel. ClearKey blijft in de DB.

- +
+ + {showTweaks && ( +
+
+

Tweak-opties

+ +
+

+ Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live. +

+
+ {meta.map((item) => { + const value = tweaks[item.key]; + if (item.kind === "boolean") { + return ( + + ); + } + return ( + + ); + })} +
+
+ + +
+
+ )} + + @@ -233,20 +401,45 @@ export default function ScriptsPage() { <>
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")} + {summary && ( + <> + {" "} + ( + {[ + summary.added != null ? `+${String(summary.added)}` : null, + summary.updated != null ? `~${String(summary.updated)}` : null, + summary.disabled != null ? `uit ${String(summary.disabled)}` : null, + summary.skipped != null ? `skip ${String(summary.skipped)}` : null, + ] + .filter(Boolean) + .join(" · ") || "ok"} + ) + + )} )} - {summary && ( + {odido?.lastMpdRefreshAt && ( <>
- Resultaat:{" "} - {[ - summary.added != null ? `+${String(summary.added)}` : null, - summary.updated != null ? `~${String(summary.updated)}` : null, - summary.disabled != null ? `uit ${String(summary.disabled)}` : null, - summary.skipped != null ? `skip ${String(summary.skipped)}` : null, - ] - .filter(Boolean) - .join(" · ") || JSON.stringify(summary)} + Laatste MPD-refresh: {new Date(odido.lastMpdRefreshAt).toLocaleString("nl-NL")} + {refreshSummary && ( + <> + {" "} + ( + {[ + refreshSummary.refreshed != null + ? `${String(refreshSummary.refreshed)} ok` + : null, + refreshSummary.failed != null ? `${String(refreshSummary.failed)} fout` : null, + refreshSummary.triggeredBy != null + ? String(refreshSummary.triggeredBy) + : null, + ] + .filter(Boolean) + .join(" · ")} + ) + + )} )}
diff --git a/apps/master-api/prisma/migrations/20260921210000_odido_mpd_cache/migration.sql b/apps/master-api/prisma/migrations/20260921210000_odido_mpd_cache/migration.sql new file mode 100644 index 0000000..1c1bee9 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260921210000_odido_mpd_cache/migration.sql @@ -0,0 +1,3 @@ +-- Cached Odido PlayChannel stream URLs (TTL-based instant play) +ALTER TABLE "live_channels" ADD COLUMN "stream_url_cache" TEXT; +ALTER TABLE "live_channels" ADD COLUMN "stream_url_cached_at" TIMESTAMP(3); diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 0665c88..c706ee7 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -334,6 +334,9 @@ model LiveChannel { name String logoUrl String? @map("logo_url") mpdUrl String @map("mpd_url") @db.Text + /** Laatst opgehaalde speelbare MPD/HLS-URL (Odido PlayChannel); marker blijft in mpdUrl */ + streamUrlCache String? @map("stream_url_cache") @db.Text + streamUrlCachedAt DateTime? @map("stream_url_cached_at") /** AES-GCM ciphertext (base64) — ClearKey KID; leeg = geen DRM */ kidEnc String? @map("kid_enc") @db.Text /** AES-GCM ciphertext (base64) — ClearKey KEY; leeg = geen DRM */ diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index 5669691..7657302 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -1619,9 +1619,13 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) pin?: string; wvdPath?: string; enabled?: boolean; + tweaks?: Record; }; const { upsertOdidoSettings } = await import("../odido/settings"); - return upsertOdidoSettings(config.SESSION_SECRET, body); + return upsertOdidoSettings(config.SESSION_SECRET, { + ...body, + tweaks: body.tweaks as import("../odido/tweaks").OdidoTweaks | undefined, + }); }); app.post("/api/v1/admin/scripts/odido/wvd", { preHandler: requireAdmin }, async (request) => { @@ -1633,6 +1637,16 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return saveOdidoWvdUpload(body.contentBase64); }); + app.post("/api/v1/admin/scripts/odido/refresh-mpd", { preHandler: requireAdmin }, async () => { + const { refreshOdidoMpdCache } = await import("../odido/refresh"); + return refreshOdidoMpdCache(config.SESSION_SECRET, "manual"); + }); + + app.post("/api/v1/admin/scripts/odido/flush-mpd", { preHandler: requireAdmin }, async () => { + const { flushOdidoMpdCache } = await import("../odido/refresh"); + return flushOdidoMpdCache(); + }); + // --- Custom ClearKey/DASH live channel lists --- app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => { const { listLiveChannelListsAdmin } = await import("../viewer/live-channels"); diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index c5c8c45..b3c862a 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -21,6 +21,7 @@ import { createMetadataService } from "./settings/metadata-factory"; import { toErrorResponse } from "./security/errors"; import { startIptvEpgWarmup } from "./viewer/iptv-client"; import { applyIptvEpgUrlsToRuntime } from "./settings/iptv"; +import { startOdidoMpdRefreshWorker } from "./odido/refresh"; async function main() { const config = loadConfig(); @@ -106,6 +107,7 @@ async function main() { registerSettingsRoutes(app, config, googlePlay); startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS); startIptvEpgWarmup(config); + startOdidoMpdRefreshWorker(() => config.SESSION_SECRET); app.get("/api/v1/node/connect", { websocket: true }, (socket) => { void nodeConnectionManager.handleConnection(socket); diff --git a/apps/master-api/src/odido/client.ts b/apps/master-api/src/odido/client.ts index 3feefd9..62388ed 100644 --- a/apps/master-api/src/odido/client.ts +++ b/apps/master-api/src/odido/client.ts @@ -3,7 +3,7 @@ export const ODIDO_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; const DEFAULT_DEVICE_ID = "MzkzNjIwMTU1NjAxMDNydA=="; -const CACHE_MS = 20 * 60_000; +const DEFAULT_SESSION_CACHE_MS = 20 * 60_000; export type OdidoCookies = Record; @@ -143,11 +143,18 @@ async function loginWithDevice( return cookies; } -export async function login(username: string, password: string): Promise { +export async function login( + username: string, + password: string, + sessionCacheMs = DEFAULT_SESSION_CACHE_MS +): Promise { const cached = sessions.get(username); if (cached && cached.expiresAt > Date.now()) return cached.cookies; const cookies = await loginWithDevice(username, password, DEFAULT_DEVICE_ID); - sessions.set(username, { cookies, expiresAt: Date.now() + CACHE_MS }); + const ttl = Number.isFinite(sessionCacheMs) && sessionCacheMs > 0 + ? sessionCacheMs + : DEFAULT_SESSION_CACHE_MS; + sessions.set(username, { cookies, expiresAt: Date.now() + ttl }); return cookies; } diff --git a/apps/master-api/src/odido/play.ts b/apps/master-api/src/odido/play.ts new file mode 100644 index 0000000..3407392 --- /dev/null +++ b/apps/master-api/src/odido/play.ts @@ -0,0 +1,198 @@ +import { prisma } from "../database/client"; +import { encryptSecret } from "../security/crypto"; +import { extractOdidoKeys } from "./cdm"; +import { + getPssh, + login, + ODIDO_USER_AGENT, + playChannel, + type OdidoCookies, + type OdidoPlayResult, +} from "./client"; +import { loadOdidoSettings } from "./settings"; +import { isMpdCacheFresh, type OdidoTweaks } from "./tweaks"; + +export type OdidoChannelPlayState = { + id: string; + externalId: string | null; + mediaId: string | null; + contentId: string | null; + mpdUrl: string; + streamUrlCache: string | null; + streamUrlCachedAt: Date | null; + kidEnc: string | null; + keyEnc: string | null; +}; + +export type ResolveOdidoPlayResult = { + streamUrl: string; + kidEnc: string | null; + keyEnc: string | null; + fromCache: boolean; + contentId: string | null; +}; + +function parseKey(value: string): { kid: string; key: string } | null { + const [kid, key] = value.trim().toLowerCase().split(":", 2); + if (!/^[0-9a-f]{32}$/.test(kid ?? "") || !/^[0-9a-f]{32}$/.test(key ?? "")) return null; + return { kid, key }; +} + +async function probeMpd(url: string, timeoutMs: number, cookies?: OdidoCookies): Promise { + try { + const response = await fetch(url, { + method: "GET", + headers: { + "User-Agent": ODIDO_USER_AGENT, + ...(cookies && Object.keys(cookies).length + ? { + Cookie: Object.entries(cookies) + .map(([k, v]) => `${k}=${v}`) + .join("; "), + } + : {}), + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) return false; + const text = await response.text(); + return text.includes(" { + if (existingKid && existingKey) return { kidEnc: existingKid, keyEnc: existingKey }; + if (!allowFetch || !play.licenseUrl || !play.licenseToken) { + return { kidEnc: existingKid, keyEnc: existingKey }; + } + const pssh = await getPssh(play.playUrl, play.cookies); + if (!pssh) throw new Error("Widevine PSSH ontbreekt"); + const keys = await extractOdidoKeys({ + wvdPath, + pssh, + licUrl: play.licenseUrl, + licToken: play.licenseToken, + cookies: play.cookies, + userAgent: play.userAgent, + }); + const key = keys.map(parseKey).find((item) => item !== null); + if (!key) throw new Error("Geen bruikbare ClearKey ontvangen"); + return { + kidEnc: encryptSecret(key.kid, sessionSecret), + keyEnc: encryptSecret(key.key, sessionSecret), + }; +} + +export async function refreshChannelMpd( + channel: OdidoChannelPlayState, + sessionSecret: string, + opts?: { cookies?: OdidoCookies; tweaks?: OdidoTweaks; fetchKeys?: boolean } +): Promise { + const settings = await loadOdidoSettings(sessionSecret); + const tweaks = opts?.tweaks ?? settings.config; + const externalId = channel.externalId; + const mediaId = channel.mediaId; + if (!externalId || !mediaId) throw new Error("Odido-zenderreferentie is onvolledig"); + + const cookies = + opts?.cookies ?? + (await login(settings.username, settings.pin, tweaks.sessionCacheMinutes * 60_000)); + const play = await playChannel(cookies, externalId, mediaId); + const keys = await fetchKeysIfNeeded( + play, + settings.config.wvdPath, + sessionSecret, + channel.kidEnc, + channel.keyEnc, + opts?.fetchKeys ?? tweaks.refetchKeysIfMissingOnPlay + ); + + const now = new Date(); + await prisma.liveChannel.update({ + where: { id: channel.id }, + data: { + streamUrlCache: play.playUrl, + streamUrlCachedAt: now, + contentId: play.contentId ?? channel.contentId, + externalId, + mediaId, + mpdUrl: `odido://${externalId}/${mediaId}`, + ...(keys.kidEnc ? { kidEnc: keys.kidEnc } : {}), + ...(keys.keyEnc ? { keyEnc: keys.keyEnc } : {}), + }, + }); + + return { + streamUrl: play.playUrl, + kidEnc: keys.kidEnc, + keyEnc: keys.keyEnc, + fromCache: false, + contentId: play.contentId ?? channel.contentId, + }; +} + +export async function resolveOdidoPlay( + channel: OdidoChannelPlayState, + sessionSecret: string +): Promise { + const settings = await loadOdidoSettings(sessionSecret); + const tweaks = settings.config; + const haveCache = !!channel.streamUrlCache && /^https?:\/\//i.test(channel.streamUrlCache); + const fresh = isMpdCacheFresh(channel.streamUrlCachedAt, tweaks.mpdTtlSeconds); + + const canUseCache = + tweaks.mpdPreferCacheOnPlay && + !tweaks.mpdForceFreshOnPlay && + haveCache && + fresh; + + if (canUseCache) { + if (tweaks.mpdProbeCachedBeforeServe) { + const ok = await probeMpd(channel.streamUrlCache!, tweaks.mpdProbeTimeoutMs); + if (ok) { + return { + streamUrl: channel.streamUrlCache!, + kidEnc: channel.kidEnc, + keyEnc: channel.keyEnc, + fromCache: true, + contentId: channel.contentId, + }; + } + if (!tweaks.mpdFallbackFreshOnProbeFail) { + throw new Error("Gecachte MPD niet bereikbaar"); + } + } else { + return { + streamUrl: channel.streamUrlCache!, + kidEnc: channel.kidEnc, + keyEnc: channel.keyEnc, + fromCache: true, + contentId: channel.contentId, + }; + } + } + + if (!tweaks.mpdRefreshOnPlayIfStale && haveCache && !tweaks.mpdForceFreshOnPlay) { + return { + streamUrl: channel.streamUrlCache!, + kidEnc: channel.kidEnc, + keyEnc: channel.keyEnc, + fromCache: true, + contentId: channel.contentId, + }; + } + + return refreshChannelMpd(channel, sessionSecret, { + tweaks, + fetchKeys: tweaks.refetchKeysIfMissingOnPlay, + }); +} diff --git a/apps/master-api/src/odido/refresh.ts b/apps/master-api/src/odido/refresh.ts new file mode 100644 index 0000000..265f03d --- /dev/null +++ b/apps/master-api/src/odido/refresh.ts @@ -0,0 +1,146 @@ +import { prisma } from "../database/client"; +import { login } from "./client"; +import { refreshChannelMpd } from "./play"; +import { + loadOdidoConfigOnly, + loadOdidoSettings, + saveOdidoMpdRefreshResult, +} from "./settings"; + +export type OdidoMpdRefreshSummary = { + refreshed: number; + failed: number; + skipped: number; + durationMs: number; + triggeredBy: "schedule" | "manual"; +}; + +async function mapLimit( + values: T[], + limit: number, + staggerMs: number, + worker: (value: T) => Promise +): Promise { + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(limit, values.length) }, async (_, workerIndex) => { + if (staggerMs > 0 && workerIndex > 0) { + await new Promise((r) => setTimeout(r, staggerMs * workerIndex)); + } + for (;;) { + const index = cursor++; + if (index >= values.length) return; + await worker(values[index]); + } + }) + ); +} + +export async function refreshOdidoMpdCache( + sessionSecret: string, + triggeredBy: "schedule" | "manual" = "manual" +): Promise { + const started = Date.now(); + const settings = await loadOdidoSettings(sessionSecret); + const tweaks = settings.config; + const channels = await prisma.liveChannel.findMany({ + where: { + list: { provider: "odido", enabled: true }, + ...(tweaks.mpdRefreshOnlyEnabled ? { enabled: true } : {}), + externalId: { not: null }, + mediaId: { not: null }, + }, + select: { + id: true, + externalId: true, + mediaId: true, + contentId: true, + mpdUrl: true, + streamUrlCache: true, + streamUrlCachedAt: true, + kidEnc: true, + keyEnc: true, + }, + }); + + const counts = { refreshed: 0, failed: 0, skipped: 0 }; + const cookies = await login( + settings.username, + settings.pin, + tweaks.sessionCacheMinutes * 60_000 + ); + + await mapLimit( + channels, + tweaks.mpdRefreshConcurrency, + tweaks.mpdRefreshStaggerMs, + async (channel) => { + if (!channel.externalId || !channel.mediaId) { + counts.skipped += 1; + return; + } + try { + await refreshChannelMpd(channel, sessionSecret, { + cookies, + tweaks, + fetchKeys: false, + }); + counts.refreshed += 1; + } catch { + counts.failed += 1; + } + } + ); + + const summary: OdidoMpdRefreshSummary = { + ...counts, + durationMs: Date.now() - started, + triggeredBy, + }; + await saveOdidoMpdRefreshResult(summary); + return summary; +} + +export async function flushOdidoMpdCache(): Promise<{ cleared: number }> { + const result = await prisma.liveChannel.updateMany({ + where: { + list: { provider: "odido" }, + OR: [{ streamUrlCache: { not: null } }, { streamUrlCachedAt: { not: null } }], + }, + data: { streamUrlCache: null, streamUrlCachedAt: null }, + }); + return { cleared: result.count }; +} + +/** + * Poller: elke minuut kijken of mpdRefreshIntervalSeconds verstreken is. + * Interval/enable wordt live uit IntegrationSetting gelezen. + */ +export function startOdidoMpdRefreshWorker(getSessionSecret: () => string): () => void { + let running = false; + const tick = async () => { + if (running) return; + const row = await prisma.integrationSetting.findUnique({ where: { id: "odido" } }); + if (!row?.enabled || !row.username || !row.passwordEnc) return; + const config = await loadOdidoConfigOnly(); + if (!config?.mpdRefreshEnabled) return; + const last = config.lastMpdRefreshAt ? Date.parse(config.lastMpdRefreshAt) : 0; + const due = !Number.isFinite(last) || Date.now() - last >= config.mpdRefreshIntervalSeconds * 1000; + if (!due) return; + running = true; + try { + const summary = await refreshOdidoMpdCache(getSessionSecret(), "schedule"); + console.log( + `[odido] MPD-cache refresh: ${summary.refreshed} ok, ${summary.failed} fail (${summary.durationMs}ms)` + ); + } catch (err) { + console.warn("[odido] MPD-cache refresh mislukt:", err); + } finally { + running = false; + } + }; + + const handle = setInterval(() => void tick(), 60_000); + setTimeout(() => void tick(), 20_000); + return () => clearInterval(handle); +} diff --git a/apps/master-api/src/odido/settings.ts b/apps/master-api/src/odido/settings.ts index 157d036..439c1c4 100644 --- a/apps/master-api/src/odido/settings.ts +++ b/apps/master-api/src/odido/settings.ts @@ -4,15 +4,23 @@ import { dirname, resolve } from "node:path"; import { prisma } from "../database/client"; import { decryptSecret, encryptSecret } from "../security/crypto"; import { AppError } from "../security/errors"; +import { + mergeOdidoTweaks, + ODIDO_TWEAK_DEFAULTS, + ODIDO_TWEAK_META, + type OdidoTweaks, +} from "./tweaks"; const ODIDO_ID = "odido"; const DEFAULT_WVD_PATH = "/data/odido/WVD.wvd"; -export type OdidoConfig = { +export type OdidoConfig = OdidoTweaks & { wvdPath: string; lastSyncAt?: string; lastSyncSummary?: Record; wvdUploadedAt?: string; + lastMpdRefreshAt?: string; + lastMpdRefreshSummary?: Record; }; function parseConfig(raw: string | null | undefined): OdidoConfig { @@ -24,9 +32,15 @@ function parseConfig(raw: string | null | undefined): OdidoConfig { parsed = {}; } } + const tweaks = mergeOdidoTweaks(parsed); return { - ...parsed, + ...tweaks, wvdPath: parsed.wvdPath?.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH, + lastSyncAt: parsed.lastSyncAt, + lastSyncSummary: parsed.lastSyncSummary, + wvdUploadedAt: parsed.wvdUploadedAt, + lastMpdRefreshAt: parsed.lastMpdRefreshAt, + lastMpdRefreshSummary: parsed.lastMpdRefreshSummary, }; } @@ -34,16 +48,30 @@ export async function getOdidoSettingsPublic() { const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); const config = parseConfig(row?.configJson); const hasWvd = existsSync(config.wvdPath); + const { + lastSyncAt, + lastSyncSummary, + wvdUploadedAt, + lastMpdRefreshAt, + lastMpdRefreshSummary, + wvdPath, + ...tweaks + } = config; return { configured: !!row?.username && !!row.passwordEnc && hasWvd, enabled: row?.enabled ?? true, username: row?.username ?? "", hasPin: !!row?.passwordEnc, hasWvd, - wvdPath: config.wvdPath, - wvdUploadedAt: config.wvdUploadedAt ?? null, - lastSyncAt: config.lastSyncAt ?? null, - lastSyncSummary: config.lastSyncSummary ?? null, + wvdPath, + wvdUploadedAt: wvdUploadedAt ?? null, + lastSyncAt: lastSyncAt ?? null, + lastSyncSummary: lastSyncSummary ?? null, + lastMpdRefreshAt: lastMpdRefreshAt ?? null, + lastMpdRefreshSummary: lastMpdRefreshSummary ?? null, + tweaks, + tweakDefaults: ODIDO_TWEAK_DEFAULTS, + tweakMeta: ODIDO_TWEAK_META, }; } @@ -54,6 +82,7 @@ export async function upsertOdidoSettings( pin?: string; wvdPath?: string; enabled?: boolean; + tweaks?: Partial; } ) { const existing = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); @@ -61,6 +90,10 @@ export async function upsertOdidoSettings( if (input.wvdPath !== undefined) { config.wvdPath = input.wvdPath.trim() || process.env.ODIDO_WVD_PATH || DEFAULT_WVD_PATH; } + if (input.tweaks) { + const merged = mergeOdidoTweaks({ ...config, ...input.tweaks }); + Object.assign(config, merged); + } const username = input.username !== undefined ? input.username.trim() || null : existing?.username ?? null; const passwordEnc = @@ -165,3 +198,21 @@ export async function saveOdidoSyncResult(summary: Record) { data: { configJson: JSON.stringify(config) }, }); } + +export async function saveOdidoMpdRefreshResult(summary: Record) { + const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + if (!row) return; + const config = parseConfig(row.configJson); + config.lastMpdRefreshAt = new Date().toISOString(); + config.lastMpdRefreshSummary = summary; + await prisma.integrationSetting.update({ + where: { id: ODIDO_ID }, + data: { configJson: JSON.stringify(config) }, + }); +} + +export async function loadOdidoConfigOnly(): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + if (!row?.enabled) return null; + return parseConfig(row.configJson); +} diff --git a/apps/master-api/src/odido/sync.ts b/apps/master-api/src/odido/sync.ts index 456802f..1e1aa91 100644 --- a/apps/master-api/src/odido/sync.ts +++ b/apps/master-api/src/odido/sync.ts @@ -80,7 +80,12 @@ export async function syncOdidoList( } const settings = await loadOdidoSettings(sessionSecret); - const cookies = await login(settings.username, settings.pin); + const tweaks = settings.config; + const cookies = await login( + settings.username, + settings.pin, + tweaks.sessionCacheMinutes * 60_000 + ); const allChannels = await queryAllChannels(cookies); const videos = allChannels .map((channel, apiIndex) => ({ channel, apiIndex })) @@ -93,7 +98,7 @@ export async function syncOdidoList( const successful = new Set(); const counts = { added: 0, updated: 0, skipped: 0, failed: 0 }; - await mapLimit(videos, 4, async ({ channel, apiIndex }) => { + await mapLimit(videos, tweaks.syncConcurrency, async ({ channel, apiIndex }) => { const externalId = channel.ID?.trim(); const physical = channel.physicalChannels?.[0]; const mediaId = physical?.ID?.trim(); @@ -113,7 +118,12 @@ export async function syncOdidoList( const previous = existing.get(externalId); let kidEnc = previous?.kidEnc ?? null; let keyEnc = previous?.keyEnc ?? null; - if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) { + if ( + tweaks.syncFetchKeysIfMissing && + play.licenseUrl && + play.licenseToken && + (!kidEnc || !keyEnc) + ) { try { const pssh = await getPssh(play.playUrl, play.cookies); if (!pssh) throw new Error("Widevine PSSH ontbreekt"); @@ -137,6 +147,7 @@ export async function syncOdidoList( const name = channel.name?.trim() || channel.channelName?.trim() || physical?.mediaName?.trim() || externalId; + const now = new Date(); const data = { name, logoUrl: logoUrl(channel), @@ -147,6 +158,9 @@ export async function syncOdidoList( keyEnc, sortOrder: channelSortOrder(channel, apiIndex), enabled: true, + ...(tweaks.syncRefreshMpdCache + ? { streamUrlCache: play.playUrl, streamUrlCachedAt: now } + : {}), }; if (previous) { await prisma.liveChannel.update({ where: { id: previous.id }, data }); @@ -160,8 +174,6 @@ export async function syncOdidoList( successful.add(externalId); }); - // A full sync is authoritative: channels outside the current package (including - // PlayChannel failures) remain stored for stable IDs, but are disabled. const disableIds = list.channels .filter((channel) => channel.externalId && !successful.has(channel.externalId)) .map((channel) => channel.id); diff --git a/apps/master-api/src/odido/tweaks.test.ts b/apps/master-api/src/odido/tweaks.test.ts new file mode 100644 index 0000000..bce7a39 --- /dev/null +++ b/apps/master-api/src/odido/tweaks.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { isMpdCacheFresh, mergeOdidoTweaks, ODIDO_TWEAK_DEFAULTS } from "./tweaks"; + +const now = Date.parse("2026-09-21T21:00:00.000Z"); + +assert.equal(isMpdCacheFresh(new Date(now - 30 * 60_000), 3600, now), true, "30m < 1h"); +assert.equal(isMpdCacheFresh(new Date(now - 61 * 60_000), 3600, now), false, "61m > 1h"); +assert.equal(isMpdCacheFresh(null, 3600, now), false, "null cache"); +assert.equal(isMpdCacheFresh(new Date(now - 1000).toISOString(), 3600, now), true, "iso string"); + +const merged = mergeOdidoTweaks({ mpdTtlSeconds: 7200, mpdForceFreshOnPlay: true }); +assert.equal(merged.mpdTtlSeconds, 7200); +assert.equal(merged.mpdForceFreshOnPlay, true); +assert.equal(merged.mpdPreferCacheOnPlay, ODIDO_TWEAK_DEFAULTS.mpdPreferCacheOnPlay); + +const clamped = mergeOdidoTweaks({ mpdRefreshConcurrency: 99, sessionCacheMinutes: 0 }); +assert.equal(clamped.mpdRefreshConcurrency, 16); +assert.equal(clamped.sessionCacheMinutes, 1); + +console.log("odido tweaks/cache tests ok"); diff --git a/apps/master-api/src/odido/tweaks.ts b/apps/master-api/src/odido/tweaks.ts new file mode 100644 index 0000000..ce4d5ad --- /dev/null +++ b/apps/master-api/src/odido/tweaks.ts @@ -0,0 +1,145 @@ +/** Alle Odido-gedrag dat via Scripts → Geavanceerd te tweaken is. */ +export type OdidoTweaks = { + /** Max leeftijd gecachte MPD bij play (seconden). Default 1 uur. */ + mpdTtlSeconds: number; + /** Periodieke achtergrond-refresh van alle Odido-MPD’s. */ + mpdRefreshEnabled: boolean; + /** Hoe vaak de worker mag refresh’en (seconden). */ + mpdRefreshIntervalSeconds: number; + /** Parallelle PlayChannel-calls tijdens refresh/sync. */ + mpdRefreshConcurrency: number; + /** Pauzes tussen starts binnen een batch (ms). */ + mpdRefreshStaggerMs: number; + /** Alleen enabled zenders refresh’en. */ + mpdRefreshOnlyEnabled: boolean; + /** Bij play: verse cache gebruiken als jonger dan TTL. */ + mpdPreferCacheOnPlay: boolean; + /** Bij play: als cache te oud is, meteen vernieuwen. */ + mpdRefreshOnPlayIfStale: boolean; + /** Debug: altijd verse PlayChannel (negeert cache). */ + mpdForceFreshOnPlay: boolean; + /** Optioneel: gecachte MPD even proberen vóór serveren. */ + mpdProbeCachedBeforeServe: boolean; + mpdProbeTimeoutMs: number; + /** Bij mislukte probe: verse MPD ophalen. */ + mpdFallbackFreshOnProbeFail: boolean; + /** Login-cookie cache (minuten). */ + sessionCacheMinutes: number; + playTimeoutMs: number; + cdmTimeoutMs: number; + syncConcurrency: number; + /** Tijdens volledige sync ontbrekende ClearKeys via CDM ophalen. */ + syncFetchKeysIfMissing: boolean; + /** Tijdens sync de MPD-cache vullen. */ + syncRefreshMpdCache: boolean; + /** Bij play ontbrekende keys alsnog via CDM. */ + refetchKeysIfMissingOnPlay: boolean; + heartbeatEnabled: boolean; + heartbeatIntervalSeconds: number; +}; + +export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = { + mpdTtlSeconds: 3600, + mpdRefreshEnabled: true, + mpdRefreshIntervalSeconds: 3600, + mpdRefreshConcurrency: 4, + mpdRefreshStaggerMs: 75, + mpdRefreshOnlyEnabled: true, + mpdPreferCacheOnPlay: true, + mpdRefreshOnPlayIfStale: true, + mpdForceFreshOnPlay: false, + mpdProbeCachedBeforeServe: false, + mpdProbeTimeoutMs: 5000, + mpdFallbackFreshOnProbeFail: true, + sessionCacheMinutes: 20, + playTimeoutMs: 30_000, + cdmTimeoutMs: 60_000, + syncConcurrency: 4, + syncFetchKeysIfMissing: true, + syncRefreshMpdCache: true, + refetchKeysIfMissingOnPlay: true, + heartbeatEnabled: false, + heartbeatIntervalSeconds: 300, +}; + +function clampInt(value: unknown, min: number, max: number, fallback: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(n))); +} + +function asBool(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (value === "true" || value === 1 || value === "1") return true; + if (value === "false" || value === 0 || value === "0") return false; + return fallback; +} + +export function mergeOdidoTweaks(raw: Partial | null | undefined): OdidoTweaks { + const d = ODIDO_TWEAK_DEFAULTS; + const r = raw ?? {}; + return { + mpdTtlSeconds: clampInt(r.mpdTtlSeconds, 60, 24 * 3600, d.mpdTtlSeconds), + mpdRefreshEnabled: asBool(r.mpdRefreshEnabled, d.mpdRefreshEnabled), + mpdRefreshIntervalSeconds: clampInt(r.mpdRefreshIntervalSeconds, 60, 24 * 3600, d.mpdRefreshIntervalSeconds), + mpdRefreshConcurrency: clampInt(r.mpdRefreshConcurrency, 1, 16, d.mpdRefreshConcurrency), + mpdRefreshStaggerMs: clampInt(r.mpdRefreshStaggerMs, 0, 5000, d.mpdRefreshStaggerMs), + mpdRefreshOnlyEnabled: asBool(r.mpdRefreshOnlyEnabled, d.mpdRefreshOnlyEnabled), + mpdPreferCacheOnPlay: asBool(r.mpdPreferCacheOnPlay, d.mpdPreferCacheOnPlay), + mpdRefreshOnPlayIfStale: asBool(r.mpdRefreshOnPlayIfStale, d.mpdRefreshOnPlayIfStale), + mpdForceFreshOnPlay: asBool(r.mpdForceFreshOnPlay, d.mpdForceFreshOnPlay), + mpdProbeCachedBeforeServe: asBool(r.mpdProbeCachedBeforeServe, d.mpdProbeCachedBeforeServe), + mpdProbeTimeoutMs: clampInt(r.mpdProbeTimeoutMs, 500, 30_000, d.mpdProbeTimeoutMs), + mpdFallbackFreshOnProbeFail: asBool(r.mpdFallbackFreshOnProbeFail, d.mpdFallbackFreshOnProbeFail), + sessionCacheMinutes: clampInt(r.sessionCacheMinutes, 1, 180, d.sessionCacheMinutes), + playTimeoutMs: clampInt(r.playTimeoutMs, 5000, 120_000, d.playTimeoutMs), + cdmTimeoutMs: clampInt(r.cdmTimeoutMs, 5000, 180_000, d.cdmTimeoutMs), + syncConcurrency: clampInt(r.syncConcurrency, 1, 16, d.syncConcurrency), + syncFetchKeysIfMissing: asBool(r.syncFetchKeysIfMissing, d.syncFetchKeysIfMissing), + syncRefreshMpdCache: asBool(r.syncRefreshMpdCache, d.syncRefreshMpdCache), + refetchKeysIfMissingOnPlay: asBool(r.refetchKeysIfMissingOnPlay, d.refetchKeysIfMissingOnPlay), + heartbeatEnabled: asBool(r.heartbeatEnabled, d.heartbeatEnabled), + heartbeatIntervalSeconds: clampInt(r.heartbeatIntervalSeconds, 30, 3600, d.heartbeatIntervalSeconds), + }; +} + +export function isMpdCacheFresh( + cachedAt: Date | string | null | undefined, + ttlSeconds: number, + nowMs = Date.now() +): boolean { + if (!cachedAt) return false; + const at = typeof cachedAt === "string" ? Date.parse(cachedAt) : cachedAt.getTime(); + if (!Number.isFinite(at)) return false; + return nowMs - at < ttlSeconds * 1000; +} + +export const ODIDO_TWEAK_META: Array<{ + key: keyof OdidoTweaks; + label: string; + hint: string; + kind: "number" | "boolean"; + unit?: string; +}> = [ + { key: "mpdTtlSeconds", label: "MPD cache-TTL", hint: "Play gebruikt cache als jonger dan dit", kind: "number", unit: "sec" }, + { key: "mpdRefreshEnabled", label: "Achtergrond MPD-refresh", hint: "Periodiek alle MPD’s vernieuwen", kind: "boolean" }, + { key: "mpdRefreshIntervalSeconds", label: "Refresh-interval", hint: "Hoe vaak de worker draait", kind: "number", unit: "sec" }, + { key: "mpdRefreshConcurrency", label: "Refresh parallel", hint: "Gelijktijdige PlayChannel-calls", kind: "number" }, + { key: "mpdRefreshStaggerMs", label: "Refresh stagger", hint: "Pauze tussen starts in een batch", kind: "number", unit: "ms" }, + { key: "mpdRefreshOnlyEnabled", label: "Alleen enabled zenders", hint: "Disabled kanalen overslaan bij refresh", kind: "boolean" }, + { key: "mpdPreferCacheOnPlay", label: "Cache bij play", hint: "Instant play als cache vers is", kind: "boolean" }, + { key: "mpdRefreshOnPlayIfStale", label: "Vernieuw bij stale play", hint: "Ouder dan TTL → verse PlayChannel", kind: "boolean" }, + { key: "mpdForceFreshOnPlay", label: "Altijd verse MPD", hint: "Debug: negeert cache volledig", kind: "boolean" }, + { key: "mpdProbeCachedBeforeServe", label: "Probe cache vóór play", hint: "Korte HTTP-check op gecachte MPD", kind: "boolean" }, + { key: "mpdProbeTimeoutMs", label: "Probe-timeout", hint: "Max wachttijd voor MPD-probe", kind: "number", unit: "ms" }, + { key: "mpdFallbackFreshOnProbeFail", label: "Verse MPD na mislukte probe", hint: "Bij 403/timeout opnieuw PlayChannel", kind: "boolean" }, + { key: "sessionCacheMinutes", label: "Login-sessie cache", hint: "Hergebruik Odido-cookies", kind: "number", unit: "min" }, + { key: "playTimeoutMs", label: "PlayChannel-timeout", hint: "HTTP-timeout voor PlayChannel", kind: "number", unit: "ms" }, + { key: "cdmTimeoutMs", label: "CDM-timeout", hint: "Widevine license-extractie", kind: "number", unit: "ms" }, + { key: "syncConcurrency", label: "Sync parallel", hint: "Gelijktijdige zenders bij volledig ophalen", kind: "number" }, + { key: "syncFetchKeysIfMissing", label: "Sync: keys ophalen", hint: "CDM tijdens sync als keys ontbreken", kind: "boolean" }, + { key: "syncRefreshMpdCache", label: "Sync: MPD-cache vullen", hint: "Sla playURL op tijdens sync", kind: "boolean" }, + { key: "refetchKeysIfMissingOnPlay", label: "Play: keys als ontbrekend", hint: "CDM bij play zonder opgeslagen keys", kind: "boolean" }, + { key: "heartbeatEnabled", label: "Odido heartbeat", hint: "Sessie warm houden via OnLineHeartbeat", kind: "boolean" }, + { key: "heartbeatIntervalSeconds", label: "Heartbeat-interval", hint: "Alleen als heartbeat aan staat", kind: "number", unit: "sec" }, +]; diff --git a/apps/master-api/src/viewer/live-channels.ts b/apps/master-api/src/viewer/live-channels.ts index e0ce277..edfe125 100644 --- a/apps/master-api/src/viewer/live-channels.ts +++ b/apps/master-api/src/viewer/live-channels.ts @@ -442,43 +442,24 @@ export async function getCustomPlayUrl( throw new AppError("ODIDO_ERROR", "Odido-zenderreferentie is onvolledig", 500); } try { - const { loadOdidoSettings } = await import("../odido/settings"); - const { login, playChannel, getPssh } = await import("../odido/client"); - const settings = await loadOdidoSettings(sessionSecret); - const cookies = await login(settings.username, settings.pin); - const play = await playChannel(cookies, externalId, mediaId); - streamUrl = play.playUrl; - if (play.licenseUrl && play.licenseToken && (!kidEnc || !keyEnc)) { - const pssh = await getPssh(play.playUrl, play.cookies); - if (!pssh) throw new Error("Widevine PSSH ontbreekt"); - const { extractOdidoKeys } = await import("../odido/cdm"); - const keys = await extractOdidoKeys({ - wvdPath: settings.config.wvdPath, - pssh, - licUrl: play.licenseUrl, - licToken: play.licenseToken, - cookies: play.cookies, - userAgent: play.userAgent, - }); - const parsed = keys - .map((value) => value.trim().toLowerCase().split(":", 2)) - .find(([kid, key]) => /^[0-9a-f]{32}$/.test(kid ?? "") && - /^[0-9a-f]{32}$/.test(key ?? "")); - if (!parsed) throw new Error("Geen bruikbare ClearKey ontvangen"); - kidEnc = encryptSecret(parsed[0], sessionSecret); - keyEnc = encryptSecret(parsed[1], sessionSecret); - await prisma.liveChannel.update({ - where: { id: channel.id }, - data: { - kidEnc, - keyEnc, - contentId: play.contentId ?? channel.contentId, - externalId, - mediaId, - mpdUrl: `odido://${externalId}/${mediaId}`, - }, - }); - } + const { resolveOdidoPlay } = await import("../odido/play"); + const resolved = await resolveOdidoPlay( + { + id: channel.id, + externalId, + mediaId, + contentId: channel.contentId, + mpdUrl: channel.mpdUrl, + streamUrlCache: channel.streamUrlCache, + streamUrlCachedAt: channel.streamUrlCachedAt, + kidEnc: channel.kidEnc, + keyEnc: channel.keyEnc, + }, + sessionSecret + ); + streamUrl = resolved.streamUrl; + kidEnc = resolved.kidEnc; + keyEnc = resolved.keyEnc; } catch (error) { const message = error instanceof Error ? error.message : "onbekende fout"; throw new AppError("ODIDO_ERROR", `Odido-zender starten mislukt: ${message}`, 502);