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:
parent
9759672900
commit
7b25b266f8
5 changed files with 116 additions and 44 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue