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.
This commit is contained in:
Jos Vooges | STH 2026-09-26 05:11:10 +02:00
parent 9759672900
commit 7b25b266f8
5 changed files with 116 additions and 44 deletions

View file

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

View file

@ -39,6 +39,9 @@ export type ResolveOdidoPlayResult = {
accountId?: string;
};
/** Gedeelde in-flight PlayChannel per zender (meerdere kijkers tegelijk). */
const inflightByChannel = new Map<string, Promise<ResolveOdidoPlayResult>>();
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<ResolveOdidoPlayResult> {
const settings = await loadOdidoSettings(sessionSecret);
@ -164,12 +180,14 @@ 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(
const cookies =
opts?.cookies && account.id === opts.account?.id
? opts.cookies
: await login(
account.username,
account.pin,
tweaks.sessionCacheMinutes * 60_000,
{ force: true }
{ force: opts?.forceLogin === true }
);
const play = await playChannel(cookies, externalId, mediaId);
const keys = await fetchKeysIfNeeded(
@ -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<typeof refreshChannelMpd>[2]
): Promise<ResolveOdidoPlayResult> {
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, {
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;
}
}

View file

@ -34,6 +34,8 @@ export type OdidoConfig = OdidoTweaks & {
wvdUploadedAt?: string;
lastMpdRefreshAt?: string;
lastMpdRefreshSummary?: Record<string, unknown>;
/** 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<string, unknown> | 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<boolean> {
const row = await prisma.integrationSetting.findUnique({ where: { id: ODIDO_ID } });
if (!row?.configJson) return false;
let parsed: Partial<OdidoConfig> & Record<string, unknown>;
try {
parsed = JSON.parse(row.configJson) as Partial<OdidoConfig> & Record<string, unknown>;
} 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: {

View file

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

View file

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