diff --git a/apps/master-api/src/iptv/xtream.ts b/apps/master-api/src/iptv/xtream.ts index c750817..72f363e 100644 --- a/apps/master-api/src/iptv/xtream.ts +++ b/apps/master-api/src/iptv/xtream.ts @@ -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 }; +const globalXmltvGuides = new Map(); +const globalXmltvRefreshing = new Set(); + +type GlobalListEntry = { at: number; data: unknown }; +const globalLiveListCache = new Map(); +const globalLiveListRefreshing = new Set(); + /** Optionele externe rijke feeds (icons/desc), gezet vanuit admin/config. */ let externalEpgXmlUrls: string[] = []; export function setExternalEpgXmlUrls(urls: string[] | null | undefined) { const seen = new Set(); - 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 +): Map { + const out = new Map(); + for (const [id, g] of map) { + out.set(id, recomputeGuideNowNext(g)); + } + return out; +} + type CacheEntry = { 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({ action: "get_live_categories" }, 60_000); + return this.getLiveListCached( + "get_live_categories", + { action: "get_live_categories" }, + "" + ); } getLiveStreams(categoryId?: string) { - return this.getJson( + return this.getLiveListCached( + "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( + action: string, + params: Record, + suffix: string + ): Promise { + 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(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(params, LIVE_LIST_FRESH_MS); + globalLiveListCache.set(key, { at: Date.now(), data }); + return data; + } + getVodCategories() { return this.getJson({ 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> { - const cacheKey = "xmltv:guides"; - const cached = this.cacheGet>(cacheKey, ttlMs); - if (cached) return cached; + async getXmltvGuidesByChannelId(ttlMs = XMLTV_FRESH_MS): Promise> { + 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(); + 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> { 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(); - for (const [id, g] of map) thin.set(id, { now: g.now, next: g.next }); - this.cacheSet("xmltv:now-next", thin); return map; } diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index 95f9d5e..d2f9d30 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -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); }