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:
parent
0c15d61291
commit
a7038f04fa
2 changed files with 159 additions and 31 deletions
|
|
@ -98,17 +98,38 @@ export type XmltvNowNext = {
|
|||
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. */
|
||||
let externalEpgXmlUrls: string[] = [];
|
||||
|
||||
export function setExternalEpgXmlUrls(urls: string[] | null | undefined) {
|
||||
const seen = new Set<string>();
|
||||
externalEpgXmlUrls = [];
|
||||
const next: string[] = [];
|
||||
for (const raw of urls ?? []) {
|
||||
const u = String(raw ?? "").trim();
|
||||
if (!u || seen.has(u)) continue;
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
const LIST_ACTIONS = new Set([
|
||||
|
|
@ -289,17 +343,47 @@ export class XtreamClient {
|
|||
}
|
||||
|
||||
getLiveCategories() {
|
||||
// Korte TTL: na IPTV-wijzigingen snel weer alle planken zichtbaar
|
||||
return this.getJson<XtreamCategory[]>({ action: "get_live_categories" }, 60_000);
|
||||
return this.getLiveListCached<XtreamCategory[]>(
|
||||
"get_live_categories",
|
||||
{ action: "get_live_categories" },
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
getLiveStreams(categoryId?: string) {
|
||||
return this.getJson<XtreamLiveStream[]>(
|
||||
return this.getLiveListCached<XtreamLiveStream[]>(
|
||||
"get_live_streams",
|
||||
{ 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() {
|
||||
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.
|
||||
* 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>> {
|
||||
const cacheKey = "xmltv:guides";
|
||||
const cached = this.cacheGet<Map<string, XmltvChannelGuide>>(cacheKey, ttlMs);
|
||||
if (cached) return cached;
|
||||
async getXmltvGuidesByChannelId(ttlMs = XMLTV_FRESH_MS): Promise<Map<string, XmltvChannelGuide>> {
|
||||
const force = ttlMs === 0;
|
||||
const gKey = xmltvGlobalKey(this.baseUrl, this.username);
|
||||
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 pass = encodeURIComponent(this.password);
|
||||
const xuiUrls = [
|
||||
|
|
@ -417,12 +541,6 @@ export class XtreamClient {
|
|||
if (map.size === 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { loadXtreamByLineId, loadXtreamForViewer } from "../iptv/service";
|
|||
import {
|
||||
decodeEpgText,
|
||||
epgToIso,
|
||||
recomputeGuideNowNext,
|
||||
type XtreamClient,
|
||||
type XtreamEpgListing,
|
||||
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_STALE_MS = 30 * 60_000;
|
||||
const EPG_DETAIL_TTL_MS = 20 * 60_000;
|
||||
const EPG_STALE_MS = 45 * 60_000;
|
||||
const EPG_DETAIL_TTL_MS = 45 * 60_000;
|
||||
|
||||
type CategoryEpgCacheEntry = {
|
||||
at: number;
|
||||
|
|
@ -398,6 +399,14 @@ function getCachedStreamEpg(tokenId: string, streamId: number): StreamEpgCacheEn
|
|||
const hit = epgStreamCache.get(streamEpgKey(tokenId, streamId));
|
||||
if (!hit) 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;
|
||||
}
|
||||
|
||||
|
|
@ -737,7 +746,7 @@ async function fetchNowNextTitlesParallel(
|
|||
* Warm XMLTV + categorie-caches op zodat TV-lijsten meteen Nu/Straks hebben.
|
||||
*/
|
||||
export function startIptvEpgWarmup(config: Config) {
|
||||
const INTERVAL_MS = 15 * 60_000;
|
||||
const INTERVAL_MS = 10 * 60_000;
|
||||
void applyIptvEpgUrlsToRuntime(config);
|
||||
|
||||
const tick = async () => {
|
||||
|
|
@ -765,13 +774,22 @@ export function startIptvEpgWarmup(config: Config) {
|
|||
const cacheKey = xt?.cacheKey ?? line.id;
|
||||
if (!xt?.line.enableLive) continue;
|
||||
|
||||
// Primair: hele rijke XMLTV warmen (ttl=0 forceert refresh)
|
||||
// Force XMLTV refresh in globale cache (XUI + admin-feeds)
|
||||
try {
|
||||
await xt.client.getXmltvGuidesByChannelId(0);
|
||||
} catch (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 sorted = [...cats].sort((a, b) =>
|
||||
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)) {
|
||||
const catId = String(cat.category_id);
|
||||
let streams = await xt.client.getLiveStreams(catId).catch(() => []);
|
||||
|
|
@ -790,13 +807,6 @@ export function startIptvEpgWarmup(config: Config) {
|
|||
waitForXmltv: true,
|
||||
});
|
||||
}
|
||||
|
||||
const all = sortChannels(await xt.client.getLiveStreams().catch(() => []));
|
||||
if (all.length > 0) {
|
||||
await resolveCategoryEpgTitles(cacheKey, "__all__", all, xt.client, {
|
||||
waitForXmltv: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue