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.
@@ -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);