/** 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; /** Tijdens sync automatisch EPG-id koppelen (alle XMLTV-bronnen). */ syncAutoMatchEpg: boolean; /** Bestaande handmatige EPG-koppeling overschrijven bij sync. */ syncOverwriteEpgMatch: boolean; /** Bij play ontbrekende keys alsnog via CDM. */ refetchKeysIfMissingOnPlay: boolean; heartbeatEnabled: boolean; heartbeatIntervalSeconds: number; }; export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = { /** Cache delen tussen kijkers; na TTL opnieuw PlayChannel bij watch. */ mpdTtlSeconds: 3600, /** Fleet-refresh uit: alleen vernieuwen als iemand kijkt. */ mpdRefreshEnabled: false, 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, /** Geen PlayChannel-storm tijdens sync; cache vult bij watch. */ syncRefreshMpdCache: false, syncAutoMatchEpg: true, syncOverwriteEpgMatch: false, 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), syncAutoMatchEpg: asBool(r.syncAutoMatchEpg, d.syncAutoMatchEpg), syncOverwriteEpgMatch: asBool(r.syncOverwriteEpgMatch, d.syncOverwriteEpgMatch), 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: "Gedeelde cache: jonger dan dit → direct play zonder PlayChannel", kind: "number", unit: "sec" }, { key: "mpdRefreshEnabled", label: "Achtergrond MPD-refresh", hint: "Uit = refresh-on-watch (aanbevolen). Aan = periodiek alle zenders", kind: "boolean" }, { key: "mpdRefreshIntervalSeconds", label: "Refresh-interval", hint: "Alleen relevant als achtergrond-refresh aan staat", kind: "number", unit: "sec" }, { key: "mpdRefreshConcurrency", label: "Refresh parallel", hint: "Gelijktijdige PlayChannel-calls (fleet/manual)", 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 fleet-refresh", kind: "boolean" }, { key: "mpdPreferCacheOnPlay", label: "Cache bij play", hint: "Instant play als cache vers is (gedeeld tussen kijkers)", kind: "boolean" }, { key: "mpdRefreshOnPlayIfStale", label: "Vernieuw bij stale play", hint: "Ouder dan TTL → verse PlayChannel bij watch", 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 (extra latency)", 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 tussen kijkers", 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: "Uit = cache vult bij watch i.p.v. alle zenders tijdens sync", kind: "boolean" }, { key: "syncAutoMatchEpg", label: "Sync: EPG auto-match", hint: "Koppel XMLTV-id via alle EPG-bronnen", kind: "boolean" }, { key: "syncOverwriteEpgMatch", label: "Sync: EPG overschrijven", hint: "Ook bestaande handmatige EPG-koppeling vervangen", 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" }, ];