Cache Odido MPDs for ~1h with admin tweak knobs and background refresh.

This commit is contained in:
Jos Vooges | STH 2026-09-21 21:24:15 +02:00
parent 03e5c9c661
commit 57972ca5f0
13 changed files with 849 additions and 74 deletions

View file

@ -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<string, number | boolean>;
type OdidoSettings = {
configured: boolean;
enabled: boolean;
@ -14,6 +24,11 @@ type OdidoSettings = {
wvdUploadedAt: string | null;
lastSyncAt: string | null;
lastSyncSummary: Record<string, unknown> | null;
lastMpdRefreshAt: string | null;
lastMpdRefreshSummary: Record<string, unknown> | null;
tweaks: OdidoTweaks;
tweakDefaults: OdidoTweaks;
tweakMeta: TweakMeta[];
};
function readFileAsBase64(file: File): Promise<string> {
@ -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<OdidoTweaks>({});
const [showTweaks, setShowTweaks] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const wvdInputRef = useRef<HTMLInputElement>(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() {
</p>
)}
<div className="card" style={{ maxWidth: 640 }}>
<div className="card" style={{ maxWidth: 720 }}>
<h2 style={{ marginTop: 0 }}>Odido</h2>
<p className="muted" style={{ marginTop: 0 }}>
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.
</p>
<form onSubmit={saveOdido} className="form-group">
<label className="field">
@ -215,7 +285,105 @@ export default function ScriptsPage() {
/>
Odido-integratie actief
</label>
<button type="submit" className="btn-primary" disabled={busy}>
<div style={{ marginTop: "1.25rem" }}>
<button
type="button"
className="btn-secondary"
onClick={() => setShowTweaks((v) => !v)}
disabled={busy}
>
{showTweaks ? "Geavanceerd verbergen" : "Geavanceerd / tweaks tonen"}
</button>
</div>
{showTweaks && (
<div
style={{
marginTop: "1rem",
padding: "1rem",
border: "1px solid var(--border, #333)",
borderRadius: 8,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<h3 style={{ margin: 0 }}>Tweak-opties</h3>
<button type="button" disabled={busy} onClick={resetTweaks}>
Defaults herstellen
</button>
</div>
<p className="muted" style={{ fontSize: "0.85rem" }}>
Wijzigingen gelden na Opslaan. Achtergrond-refresh leest deze waarden live.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: "0.75rem 1rem",
}}
>
{meta.map((item) => {
const value = tweaks[item.key];
if (item.kind === "boolean") {
return (
<label
key={item.key}
className="field"
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
>
<input
type="checkbox"
checked={value === true}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({ ...prev, [item.key]: e.target.checked }))
}
style={{ marginTop: 4 }}
/>
<span>
<strong style={{ fontWeight: 600 }}>{item.label}</strong>
<br />
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</span>
</label>
);
}
return (
<label key={item.key} className="field">
{item.label}
{item.unit ? ` (${item.unit})` : ""}
<input
type="number"
value={typeof value === "number" ? value : ""}
disabled={busy}
onChange={(e) =>
setTweaks((prev) => ({
...prev,
[item.key]: e.target.value === "" ? 0 : Number(e.target.value),
}))
}
/>
<span className="muted" style={{ fontSize: "0.8rem" }}>
{item.hint}
</span>
</label>
);
})}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: "1rem" }}>
<button type="button" disabled={busy} onClick={() => void refreshMpds()}>
MPD-cache nu vernieuwen
</button>
<button type="button" disabled={busy} onClick={() => void flushMpds()}>
MPD-cache legen
</button>
</div>
</div>
)}
<button type="submit" className="btn-primary" disabled={busy} style={{ marginTop: "1rem" }}>
Opslaan
</button>
</form>
@ -233,12 +401,10 @@ export default function ScriptsPage() {
<>
<br />
Laatste sync: {new Date(odido.lastSyncAt).toLocaleString("nl-NL")}
</>
)}
{summary && (
<>
<br />
Resultaat:{" "}
{" "}
(
{[
summary.added != null ? `+${String(summary.added)}` : null,
summary.updated != null ? `~${String(summary.updated)}` : null,
@ -246,7 +412,34 @@ export default function ScriptsPage() {
summary.skipped != null ? `skip ${String(summary.skipped)}` : null,
]
.filter(Boolean)
.join(" · ") || JSON.stringify(summary)}
.join(" · ") || "ok"}
)
</>
)}
</>
)}
{odido?.lastMpdRefreshAt && (
<>
<br />
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(" · ")}
)
</>
)}
</>
)}
</div>

View file

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

View file

@ -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 */

View file

@ -1619,9 +1619,13 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
pin?: string;
wvdPath?: string;
enabled?: boolean;
tweaks?: Record<string, unknown>;
};
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");

View file

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

View file

@ -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<string, string>;
@ -143,11 +143,18 @@ async function loginWithDevice(
return cookies;
}
export async function login(username: string, password: string): Promise<OdidoCookies> {
export async function login(
username: string,
password: string,
sessionCacheMs = DEFAULT_SESSION_CACHE_MS
): Promise<OdidoCookies> {
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;
}

View file

@ -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<boolean> {
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("<MPD") || text.includes("Period") || text.includes("#EXTM3U");
} catch {
return false;
}
}
async function fetchKeysIfNeeded(
play: OdidoPlayResult,
wvdPath: string,
sessionSecret: string,
existingKid: string | null,
existingKey: string | null,
allowFetch: boolean
): Promise<{ kidEnc: string | null; keyEnc: string | null }> {
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<ResolveOdidoPlayResult> {
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<ResolveOdidoPlayResult> {
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,
});
}

View file

@ -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<T>(
values: T[],
limit: number,
staggerMs: number,
worker: (value: T) => Promise<void>
): Promise<void> {
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<OdidoMpdRefreshSummary> {
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);
}

View file

@ -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<string, unknown>;
wvdUploadedAt?: string;
lastMpdRefreshAt?: string;
lastMpdRefreshSummary?: Record<string, unknown>;
};
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<OdidoTweaks>;
}
) {
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<string, unknown>) {
data: { configJson: JSON.stringify(config) },
});
}
export async function saveOdidoMpdRefreshResult(summary: Record<string, unknown>) {
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<OdidoConfig | null> {
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
if (!row?.enabled) return null;
return parseConfig(row.configJson);
}

View file

@ -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<string>();
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);

View file

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

View file

@ -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<OdidoTweaks> | 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" },
];

View file

@ -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,
const { resolveOdidoPlay } = await import("../odido/play");
const resolved = await resolveOdidoPlay(
{
id: channel.id,
externalId,
mediaId,
mpdUrl: `odido://${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);