From 7b25b266f88d7df89b46196220fbfec588804704 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sat, 26 Sep 2026 05:11:10 +0200 Subject: [PATCH] Odido: refresh-on-watch i.p.v. fleet MPD-refresh. Zet periodieke PlayChannel-storm uit; vernieuw bij kijken met gedeelde DB-cache, in-flight coalescing en fallback op laatste URL. --- apps/master-api/src/app.ts | 4 ++ apps/master-api/src/odido/play.ts | 91 +++++++++++++++--------- apps/master-api/src/odido/settings.ts | 33 +++++++++ apps/master-api/src/odido/tweaks.test.ts | 5 ++ apps/master-api/src/odido/tweaks.ts | 27 +++---- 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 98df2da..a1e460f 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -23,6 +23,7 @@ import { toErrorResponse } from "./security/errors"; import { startIptvEpgWarmup } from "./viewer/iptv-client"; import { applyIptvEpgUrlsToRuntime } from "./settings/iptv"; import { startOdidoMpdRefreshWorker } from "./odido/refresh"; +import { migrateOdidoRefreshOnWatch } from "./odido/settings"; import { startViaplayPreLiveWorker } from "./viaplay/refresh"; import { TOKEN_TRAY_MAX_BYTES } from "./install/token-tray-store"; @@ -125,6 +126,9 @@ async function main() { registerAgentRoutes(app, config); startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS); startIptvEpgWarmup(config); + await migrateOdidoRefreshOnWatch().catch((err) => + console.warn("[odido] refresh-on-watch migratie mislukt:", err) + ); startOdidoMpdRefreshWorker(() => config.SESSION_SECRET); startViaplayPreLiveWorker(() => config.SESSION_SECRET); diff --git a/apps/master-api/src/odido/play.ts b/apps/master-api/src/odido/play.ts index b81fad8..d4b3293 100644 --- a/apps/master-api/src/odido/play.ts +++ b/apps/master-api/src/odido/play.ts @@ -39,6 +39,9 @@ export type ResolveOdidoPlayResult = { accountId?: string; }; +/** Gedeelde in-flight PlayChannel per zender (meerdere kijkers tegelijk). */ +const inflightByChannel = new Map>(); + 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; @@ -141,6 +144,17 @@ function accountOrderForChannel( return ordered; } +function cachedPlayResult(channel: OdidoChannelPlayState): ResolveOdidoPlayResult | null { + if (!channel.streamUrlCache || !/^https?:\/\//i.test(channel.streamUrlCache)) return null; + return { + streamUrl: channel.streamUrlCache, + kidEnc: channel.kidEnc, + keyEnc: channel.keyEnc, + fromCache: true, + contentId: channel.contentId, + }; +} + export async function refreshChannelMpd( channel: OdidoChannelPlayState, sessionSecret: string, @@ -149,6 +163,8 @@ export async function refreshChannelMpd( tweaks?: OdidoTweaks; fetchKeys?: boolean; account?: OdidoAccountCreds; + /** Forceer nieuwe login (alleen bij auth-fouten). */ + forceLogin?: boolean; } ): Promise { const settings = await loadOdidoSettings(sessionSecret); @@ -164,13 +180,15 @@ export async function refreshChannelMpd( let lastError: Error | null = null; for (const account of ordered) { try { - // Altijd verse login: multi-account deelt geen betrouwbare sessie-cache. - const cookies = await login( - account.username, - account.pin, - tweaks.sessionCacheMinutes * 60_000, - { force: true } - ); + const cookies = + opts?.cookies && account.id === opts.account?.id + ? opts.cookies + : await login( + account.username, + account.pin, + tweaks.sessionCacheMinutes * 60_000, + { force: opts?.forceLogin === true } + ); const play = await playChannel(cookies, externalId, mediaId); const keys = await fetchKeysIfNeeded( play, @@ -213,6 +231,20 @@ export async function refreshChannelMpd( throw lastError ?? new Error("Odido PlayChannel mislukt op alle accounts"); } +async function refreshChannelMpdCoalesced( + channel: OdidoChannelPlayState, + sessionSecret: string, + opts?: Parameters[2] +): Promise { + const existing = inflightByChannel.get(channel.id); + if (existing) return existing; + const pending = refreshChannelMpd(channel, sessionSecret, opts).finally(() => { + inflightByChannel.delete(channel.id); + }); + inflightByChannel.set(channel.id, pending); + return pending; +} + export async function resolveOdidoPlay( channel: OdidoChannelPlayState, sessionSecret: string @@ -221,6 +253,7 @@ export async function resolveOdidoPlay( const tweaks = settings.config; const haveCache = !!channel.streamUrlCache && /^https?:\/\//i.test(channel.streamUrlCache); const fresh = isMpdCacheFresh(channel.streamUrlCachedAt, tweaks.mpdTtlSeconds); + const staleCache = cachedPlayResult(channel); const canUseCache = tweaks.mpdPreferCacheOnPlay && @@ -232,40 +265,34 @@ export async function resolveOdidoPlay( 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, - }; + return staleCache!; } 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, - }; + return staleCache!; } } if (!tweaks.mpdRefreshOnPlayIfStale && haveCache && !tweaks.mpdForceFreshOnPlay) { - return { - streamUrl: channel.streamUrlCache!, - kidEnc: channel.kidEnc, - keyEnc: channel.keyEnc, - fromCache: true, - contentId: channel.contentId, - }; + return staleCache!; } - return refreshChannelMpd(channel, sessionSecret, { - tweaks, - fetchKeys: tweaks.refetchKeysIfMissingOnPlay, - }); + try { + return await refreshChannelMpdCoalesced(channel, sessionSecret, { + tweaks, + fetchKeys: tweaks.refetchKeysIfMissingOnPlay, + }); + } catch (err) { + // Geen zwarte TV: als Odido even hapert, geef laatste bekende URL terug. + if (staleCache && !tweaks.mpdForceFreshOnPlay) { + console.warn( + `[odido] PlayChannel mislukt voor ${channel.externalId}, val terug op cache:`, + err instanceof Error ? err.message : err + ); + return staleCache; + } + throw err; + } } diff --git a/apps/master-api/src/odido/settings.ts b/apps/master-api/src/odido/settings.ts index 7194bb6..8ab6adb 100644 --- a/apps/master-api/src/odido/settings.ts +++ b/apps/master-api/src/odido/settings.ts @@ -34,6 +34,8 @@ export type OdidoConfig = OdidoTweaks & { wvdUploadedAt?: string; lastMpdRefreshAt?: string; lastMpdRefreshSummary?: Record; + /** Eenmalige migratie: fleet-refresh → refresh-on-watch. */ + mpdStrategy?: "on_watch" | "fleet"; }; function parseConfig(raw: string | null | undefined): OdidoConfig { @@ -56,9 +58,40 @@ function parseConfig(raw: string | null | undefined): OdidoConfig { wvdUploadedAt: parsed.wvdUploadedAt as string | undefined, lastMpdRefreshAt: parsed.lastMpdRefreshAt as string | undefined, lastMpdRefreshSummary: parsed.lastMpdRefreshSummary as Record | undefined, + mpdStrategy: parsed.mpdStrategy === "fleet" ? "fleet" : parsed.mpdStrategy === "on_watch" ? "on_watch" : undefined, }; } +/** + * Zet opgeslagen fleet-refresh uit (eenmalig), tenzij expliciet op "fleet" gehouden. + * Refresh-on-watch: PlayChannel alleen bij kijken; cache gedeeld in DB. + */ +export async function migrateOdidoRefreshOnWatch(): Promise { + const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } }); + if (!row?.configJson) return false; + let parsed: Partial & Record; + try { + parsed = JSON.parse(row.configJson) as Partial & Record; + } catch { + return false; + } + if (parsed.mpdStrategy === "on_watch" || parsed.mpdStrategy === "fleet") return false; + + parsed.mpdStrategy = "on_watch"; + parsed.mpdRefreshEnabled = false; + parsed.syncRefreshMpdCache = false; + parsed.mpdPreferCacheOnPlay = true; + parsed.mpdRefreshOnPlayIfStale = true; + parsed.mpdForceFreshOnPlay = false; + + await prisma.integrationSetting.update({ + where: { id: ODIDO_ID }, + data: { configJson: JSON.stringify(parsed) }, + }); + console.log("[odido] migratie: MPD-strategie → refresh-on-watch (fleet-refresh uit)"); + return true; +} + async function ensureAccountsMigrated( sessionSecret: string, row: { diff --git a/apps/master-api/src/odido/tweaks.test.ts b/apps/master-api/src/odido/tweaks.test.ts index bce7a39..5bd7b63 100644 --- a/apps/master-api/src/odido/tweaks.test.ts +++ b/apps/master-api/src/odido/tweaks.test.ts @@ -8,6 +8,11 @@ assert.equal(isMpdCacheFresh(new Date(now - 61 * 60_000), 3600, now), false, "61 assert.equal(isMpdCacheFresh(null, 3600, now), false, "null cache"); assert.equal(isMpdCacheFresh(new Date(now - 1000).toISOString(), 3600, now), true, "iso string"); +assert.equal(ODIDO_TWEAK_DEFAULTS.mpdRefreshEnabled, false, "fleet refresh off by default"); +assert.equal(ODIDO_TWEAK_DEFAULTS.syncRefreshMpdCache, false, "sync does not prefill MPD"); +assert.equal(ODIDO_TWEAK_DEFAULTS.mpdRefreshOnPlayIfStale, true, "refresh on watch"); +assert.equal(ODIDO_TWEAK_DEFAULTS.mpdPreferCacheOnPlay, true, "shared cache on play"); + const merged = mergeOdidoTweaks({ mpdTtlSeconds: 7200, mpdForceFreshOnPlay: true }); assert.equal(merged.mpdTtlSeconds, 7200); assert.equal(merged.mpdForceFreshOnPlay, true); diff --git a/apps/master-api/src/odido/tweaks.ts b/apps/master-api/src/odido/tweaks.ts index 4df5c24..00a0875 100644 --- a/apps/master-api/src/odido/tweaks.ts +++ b/apps/master-api/src/odido/tweaks.ts @@ -43,8 +43,10 @@ export type OdidoTweaks = { }; export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = { + /** Cache delen tussen kijkers; na TTL opnieuw PlayChannel bij watch. */ mpdTtlSeconds: 3600, - mpdRefreshEnabled: true, + /** Fleet-refresh uit: alleen vernieuwen als iemand kijkt. */ + mpdRefreshEnabled: false, mpdRefreshIntervalSeconds: 3600, mpdRefreshConcurrency: 4, mpdRefreshStaggerMs: 75, @@ -60,7 +62,8 @@ export const ODIDO_TWEAK_DEFAULTS: OdidoTweaks = { cdmTimeoutMs: 60_000, syncConcurrency: 4, syncFetchKeysIfMissing: true, - syncRefreshMpdCache: true, + /** Geen PlayChannel-storm tijdens sync; cache vult bij watch. */ + syncRefreshMpdCache: false, syncAutoMatchEpg: true, syncOverwriteEpgMatch: false, refetchKeysIfMissingOnPlay: true, @@ -129,24 +132,24 @@ export const ODIDO_TWEAK_META: Array<{ 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: "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 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: "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", 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", kind: "number", unit: "min" }, + { 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: "Sla playURL op tijdens sync", 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" },