Cache IPTV XMLTV globally so Live TV EPG serves from memory.

Share XUI and admin EPG feeds across requests with SWR refresh, and recompute now/next from the schedule so titles stay current without re-downloading.
This commit is contained in:
Jos Vooges | STH 2026-09-15 18:55:13 +02:00
parent 0c15d61291
commit a7038f04fa
2 changed files with 159 additions and 31 deletions

View file

@ -98,17 +98,38 @@ export type XmltvNowNext = {
next: XmltvProgram | null; next: XmltvProgram | null;
}; };
/** Process-globale caches (XtreamClient is per request — instance-cache helpt niet tussen calls). */
const XMLTV_FRESH_MS = 12 * 60_000;
const XMLTV_STALE_MS = 50 * 60_000;
const LIVE_LIST_FRESH_MS = 3 * 60_000;
const LIVE_LIST_STALE_MS = 15 * 60_000;
type GlobalGuidesEntry = { at: number; guides: Map<string, XmltvChannelGuide> };
const globalXmltvGuides = new Map<string, GlobalGuidesEntry>();
const globalXmltvRefreshing = new Set<string>();
type GlobalListEntry = { at: number; data: unknown };
const globalLiveListCache = new Map<string, GlobalListEntry>();
const globalLiveListRefreshing = new Set<string>();
/** Optionele externe rijke feeds (icons/desc), gezet vanuit admin/config. */ /** Optionele externe rijke feeds (icons/desc), gezet vanuit admin/config. */
let externalEpgXmlUrls: string[] = []; let externalEpgXmlUrls: string[] = [];
export function setExternalEpgXmlUrls(urls: string[] | null | undefined) { export function setExternalEpgXmlUrls(urls: string[] | null | undefined) {
const seen = new Set<string>(); const seen = new Set<string>();
externalEpgXmlUrls = []; const next: string[] = [];
for (const raw of urls ?? []) { for (const raw of urls ?? []) {
const u = String(raw ?? "").trim(); const u = String(raw ?? "").trim();
if (!u || seen.has(u)) continue; if (!u || seen.has(u)) continue;
seen.add(u); seen.add(u);
externalEpgXmlUrls.push(u); next.push(u);
}
const changed =
next.length !== externalEpgXmlUrls.length ||
next.some((u, i) => u !== externalEpgXmlUrls[i]);
externalEpgXmlUrls = next;
if (changed) {
globalXmltvGuides.clear();
} }
} }
@ -125,6 +146,39 @@ export function getExternalEpgXmlUrl(): string | null {
return externalEpgXmlUrls[0] ?? null; return externalEpgXmlUrls[0] ?? null;
} }
function xmltvGlobalKey(baseUrl: string, username: string): string {
return `${baseUrl}\0${username}\0ext:${externalEpgXmlUrls.join("|")}`;
}
/** Herbereken Nu/Straks uit schedule — cache blijft uren geldig zonder verkeerde "nu". */
export function recomputeGuideNowNext(guide: XmltvChannelGuide): XmltvChannelGuide {
const now = Date.now();
let nowProg: XmltvProgram | null = null;
let nextProg: XmltvProgram | null = null;
for (const p of guide.schedule) {
const startMs = Date.parse(p.start);
if (!Number.isFinite(startMs)) continue;
const endMs = p.end ? Date.parse(p.end) : startMs + 30 * 60_000;
if (!Number.isFinite(endMs)) continue;
if (startMs <= now && now < endMs) {
nowProg = p;
} else if (startMs > now && !nextProg) {
nextProg = p;
}
}
return { now: nowProg, next: nextProg, schedule: guide.schedule };
}
export function recomputeAllGuides(
map: Map<string, XmltvChannelGuide>
): Map<string, XmltvChannelGuide> {
const out = new Map<string, XmltvChannelGuide>();
for (const [id, g] of map) {
out.set(id, recomputeGuideNowNext(g));
}
return out;
}
type CacheEntry<T> = { at: number; data: T }; type CacheEntry<T> = { at: number; data: T };
const LIST_ACTIONS = new Set([ const LIST_ACTIONS = new Set([
@ -289,17 +343,47 @@ export class XtreamClient {
} }
getLiveCategories() { getLiveCategories() {
// Korte TTL: na IPTV-wijzigingen snel weer alle planken zichtbaar return this.getLiveListCached<XtreamCategory[]>(
return this.getJson<XtreamCategory[]>({ action: "get_live_categories" }, 60_000); "get_live_categories",
{ action: "get_live_categories" },
""
);
} }
getLiveStreams(categoryId?: string) { getLiveStreams(categoryId?: string) {
return this.getJson<XtreamLiveStream[]>( return this.getLiveListCached<XtreamLiveStream[]>(
"get_live_streams",
{ action: "get_live_streams", category_id: categoryId }, { action: "get_live_streams", category_id: categoryId },
3 * 60_000 categoryId ? String(categoryId) : ""
); );
} }
/** Process-globale SWR voor live categories/streams (gedeeld over requests). */
private async getLiveListCached<T>(
action: string,
params: Record<string, string | number | undefined>,
suffix: string
): Promise<T> {
const key = `${this.baseUrl}\0${this.username}\0${action}\0${suffix}`;
const hit = globalLiveListCache.get(key);
const age = hit ? Date.now() - hit.at : Number.POSITIVE_INFINITY;
if (hit && age < LIVE_LIST_STALE_MS) {
if (age >= LIVE_LIST_FRESH_MS && !globalLiveListRefreshing.has(key)) {
globalLiveListRefreshing.add(key);
void this.getJson<T>(params, 0)
.then((data) => {
globalLiveListCache.set(key, { at: Date.now(), data });
})
.catch(() => undefined)
.finally(() => globalLiveListRefreshing.delete(key));
}
return hit.data as T;
}
const data = await this.getJson<T>(params, LIVE_LIST_FRESH_MS);
globalLiveListCache.set(key, { at: Date.now(), data });
return data;
}
getVodCategories() { getVodCategories() {
return this.getJson<XtreamCategory[]>({ action: "get_vod_categories" }, 5 * 60_000); return this.getJson<XtreamCategory[]>({ action: "get_vod_categories" }, 5 * 60_000);
} }
@ -376,13 +460,53 @@ export class XtreamClient {
/** /**
* Rijke gids per XMLTV-channel id: now/next + 24u schedule. * Rijke gids per XMLTV-channel id: now/next + 24u schedule.
* Externe EPG-URL's (admin) winnen op image/desc; XUI vult ontbrekende zenders. * Process-globale SWR-cache: serveert direct uit RAM; refresh op achtergrond.
* ttlMs === 0 → force refresh (warmup).
*/ */
async getXmltvGuidesByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvChannelGuide>> { async getXmltvGuidesByChannelId(ttlMs = XMLTV_FRESH_MS): Promise<Map<string, XmltvChannelGuide>> {
const cacheKey = "xmltv:guides"; const force = ttlMs === 0;
const cached = this.cacheGet<Map<string, XmltvChannelGuide>>(cacheKey, ttlMs); const gKey = xmltvGlobalKey(this.baseUrl, this.username);
if (cached) return cached; const hit = globalXmltvGuides.get(gKey);
const age = hit ? Date.now() - hit.at : Number.POSITIVE_INFINITY;
if (!force && hit && age < XMLTV_STALE_MS) {
if (age >= XMLTV_FRESH_MS) {
void this.refreshGlobalXmltvInBackground(gKey);
}
return recomputeAllGuides(hit.guides);
}
// Force refresh terwijl er al één loopt → stale serveren i.p.v. dubbele download
if (force && globalXmltvRefreshing.has(gKey) && hit) {
return recomputeAllGuides(hit.guides);
}
if (force) globalXmltvRefreshing.add(gKey);
try {
const map = await this.fetchMergedXmltvGuides();
globalXmltvGuides.set(gKey, { at: Date.now(), guides: map });
this.cacheSet("xmltv:guides", map);
const thin = new Map<string, XmltvNowNext>();
for (const [id, g] of map) thin.set(id, { now: g.now, next: g.next });
this.cacheSet("xmltv:now-next", thin);
return recomputeAllGuides(map);
} finally {
if (force) globalXmltvRefreshing.delete(gKey);
}
}
private refreshGlobalXmltvInBackground(gKey: string) {
if (globalXmltvRefreshing.has(gKey)) return;
globalXmltvRefreshing.add(gKey);
void this.fetchMergedXmltvGuides()
.then((map) => {
globalXmltvGuides.set(gKey, { at: Date.now(), guides: map });
})
.catch((err) => console.warn("[iptv-epg] xmltv bg refresh:", err))
.finally(() => globalXmltvRefreshing.delete(gKey));
}
private async fetchMergedXmltvGuides(): Promise<Map<string, XmltvChannelGuide>> {
const user = encodeURIComponent(this.username); const user = encodeURIComponent(this.username);
const pass = encodeURIComponent(this.password); const pass = encodeURIComponent(this.password);
const xuiUrls = [ const xuiUrls = [
@ -417,12 +541,6 @@ export class XtreamClient {
if (map.size === 0) { if (map.size === 0) {
throw new Error("XMLTV niet beschikbaar"); throw new Error("XMLTV niet beschikbaar");
} }
this.cacheSet(cacheKey, map);
// Houd thin now/next-cache synchroon
const thin = new Map<string, XmltvNowNext>();
for (const [id, g] of map) thin.set(id, { now: g.now, next: g.next });
this.cacheSet("xmltv:now-next", thin);
return map; return map;
} }

View file

@ -5,6 +5,7 @@ import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
import { import {
decodeEpgText, decodeEpgText,
epgToIso, epgToIso,
recomputeGuideNowNext,
type XtreamClient, type XtreamClient,
type XtreamEpgListing, type XtreamEpgListing,
type XtreamLiveStream, type XtreamLiveStream,
@ -363,10 +364,10 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI
}; };
} }
/** Fris tot 10 min; stale tot 30 min (SWR). */ /** Fris tot 10 min; stale tot 45 min (SWR) — XMLTV zelf langer via globale cache. */
const EPG_FRESH_MS = 10 * 60_000; const EPG_FRESH_MS = 10 * 60_000;
const EPG_STALE_MS = 30 * 60_000; const EPG_STALE_MS = 45 * 60_000;
const EPG_DETAIL_TTL_MS = 20 * 60_000; const EPG_DETAIL_TTL_MS = 45 * 60_000;
type CategoryEpgCacheEntry = { type CategoryEpgCacheEntry = {
at: number; at: number;
@ -398,6 +399,14 @@ function getCachedStreamEpg(tokenId: string, streamId: number): StreamEpgCacheEn
const hit = epgStreamCache.get(streamEpgKey(tokenId, streamId)); const hit = epgStreamCache.get(streamEpgKey(tokenId, streamId));
if (!hit) return null; if (!hit) return null;
if (Date.now() - hit.at > EPG_DETAIL_TTL_MS) return null; if (Date.now() - hit.at > EPG_DETAIL_TTL_MS) return null;
if (hit.schedule.length > 0) {
const fresh = recomputeGuideNowNext({
now: hit.now,
next: hit.next,
schedule: hit.schedule,
});
return { at: hit.at, now: fresh.now, next: fresh.next, schedule: fresh.schedule };
}
return hit; return hit;
} }
@ -737,7 +746,7 @@ async function fetchNowNextTitlesParallel(
* Warm XMLTV + categorie-caches op zodat TV-lijsten meteen Nu/Straks hebben. * Warm XMLTV + categorie-caches op zodat TV-lijsten meteen Nu/Straks hebben.
*/ */
export function startIptvEpgWarmup(config: Config) { export function startIptvEpgWarmup(config: Config) {
const INTERVAL_MS = 15 * 60_000; const INTERVAL_MS = 10 * 60_000;
void applyIptvEpgUrlsToRuntime(config); void applyIptvEpgUrlsToRuntime(config);
const tick = async () => { const tick = async () => {
@ -765,13 +774,22 @@ export function startIptvEpgWarmup(config: Config) {
const cacheKey = xt?.cacheKey ?? line.id; const cacheKey = xt?.cacheKey ?? line.id;
if (!xt?.line.enableLive) continue; if (!xt?.line.enableLive) continue;
// Primair: hele rijke XMLTV warmen (ttl=0 forceert refresh) // Force XMLTV refresh in globale cache (XUI + admin-feeds)
try { try {
await xt.client.getXmltvGuidesByChannelId(0); await xt.client.getXmltvGuidesByChannelId(0);
} catch (err) { } catch (err) {
console.warn("[iptv-epg-warmup] xmltv", err); console.warn("[iptv-epg-warmup] xmltv", err);
} }
// Warm live-lijsten (globale SWR)
await xt.client.getLiveCategories().catch(() => []);
const all = sortChannels(await xt.client.getLiveStreams().catch(() => []));
if (all.length > 0) {
await resolveCategoryEpgTitles(cacheKey, "__all__", all, xt.client, {
waitForXmltv: true,
});
}
const cats = await xt.client.getLiveCategories().catch(() => []); const cats = await xt.client.getLiveCategories().catch(() => []);
const sorted = [...cats].sort((a, b) => const sorted = [...cats].sort((a, b) =>
friendlyCategoryName(a.category_name).localeCompare( friendlyCategoryName(a.category_name).localeCompare(
@ -780,7 +798,6 @@ export function startIptvEpgWarmup(config: Config) {
) )
); );
// Top categorieën + alle zenders via XMLTV-mapping
for (const cat of sorted.slice(0, 20)) { for (const cat of sorted.slice(0, 20)) {
const catId = String(cat.category_id); const catId = String(cat.category_id);
let streams = await xt.client.getLiveStreams(catId).catch(() => []); let streams = await xt.client.getLiveStreams(catId).catch(() => []);
@ -790,13 +807,6 @@ export function startIptvEpgWarmup(config: Config) {
waitForXmltv: true, waitForXmltv: true,
}); });
} }
const all = sortChannels(await xt.client.getLiveStreams().catch(() => []));
if (all.length > 0) {
await resolveCategoryEpgTitles(cacheKey, "__all__", all, xt.client, {
waitForXmltv: true,
});
}
} finally { } finally {
xmltvLineRefreshing.delete(line.id); xmltvLineRefreshing.delete(line.id);
} }
@ -806,6 +816,6 @@ export function startIptvEpgWarmup(config: Config) {
} }
}; };
setTimeout(() => void tick(), 12_000); setTimeout(() => void tick(), 5_000);
setInterval(() => void tick(), INTERVAL_MS); setInterval(() => void tick(), INTERVAL_MS);
} }