import { decryptSecret } from "../security/crypto"; export type XtreamCredentials = { baseUrl: string; username: string; password: string; }; export type XtreamCategory = { category_id: string; category_name: string; parent_id?: number; }; export type XtreamLiveStream = { num?: number; name: string; stream_type?: string; stream_id: number; stream_icon?: string; epg_channel_id?: string; category_id?: string; tv_archive?: number; tv_archive_duration?: number; direct_source?: string; }; export type XtreamVodStream = { num?: number; name: string; stream_id: number; stream_icon?: string; ranking?: number; category_id?: string; container_extension?: string; rating?: string; plot?: string; cast?: string; director?: string; genre?: string; releaseDate?: string; duration?: string; backdrop_path?: string[]; }; export type XtreamSeries = { num?: number; name: string; series_id: number; cover?: string; plot?: string; cast?: string; director?: string; genre?: string; releaseDate?: string; rating?: string; category_id?: string; backdrop_path?: string[]; }; export type XtreamEpgListing = { id?: string; epg_id?: string; title: string; lang?: string; start: string; end: string; description?: string; channel_id?: string; start_timestamp?: number; stop_timestamp?: number; /** Sommige panels markeren het huidige programma expliciet. */ now_playing?: number | boolean | string; }; /** Rijk programma uit XMLTV (Odido / XUI xmltv.php). */ export type XmltvProgram = { title: string; subTitle: string | null; description: string | null; imageUrl: string | null; category: string | null; episode: string | null; start: string; end: string | null; }; export type XmltvChannelGuide = { now: XmltvProgram | null; next: XmltvProgram | null; /** Programma's van ~nu−2u t/m nu+24u, gesorteerd op start. */ schedule: XmltvProgram[]; }; /** @deprecated thin alias — gebruik XmltvChannelGuide */ export type XmltvNowNext = { now: 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 }; 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(); const next: string[] = []; for (const raw of urls ?? []) { const u = String(raw ?? "").trim(); if (!u || seen.has(u)) continue; seen.add(u); next.push(u); } const changed = next.length !== externalEpgXmlUrls.length || next.some((u, i) => u !== externalEpgXmlUrls[i]); externalEpgXmlUrls = next; if (changed) { globalXmltvGuides.clear(); } } /** @deprecated gebruik setExternalEpgXmlUrls */ export function setExternalEpgXmlUrl(url: string | null | undefined) { setExternalEpgXmlUrls(url ? [url] : []); } export function getExternalEpgXmlUrls(): string[] { return [...externalEpgXmlUrls]; } 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([ "get_live_categories", "get_live_streams", "get_vod_categories", "get_vod_streams", "get_series_categories", "get_series", ]); /** Xtream/XUI geeft soms een array, soms een object-map, soms { data: [...] }. */ export function coerceXtreamList(data: unknown): T[] { if (data == null || data === false) return []; if (Array.isArray(data)) return data as T[]; if (typeof data !== "object") return []; const obj = data as Record; for (const key of ["categories", "streams", "series", "data", "results", "items"]) { if (Array.isArray(obj[key])) return obj[key] as T[]; } const entries = Object.entries(obj); if (entries.length === 0) return []; if (!entries.every(([, v]) => v != null && typeof v === "object")) return []; return entries.map(([key, value]) => { const row = { ...(value as Record) }; if (row.category_id == null && row.categoryId == null && /^\d+$/.test(key)) { row.category_id = key; } if (row.category_name == null && typeof row.name === "string") { row.category_name = row.name; } if (row.stream_id == null && row.streamId != null) { row.stream_id = row.streamId; } return row as T; }); } function normalizeBaseUrl(url: string): string { let u = url.trim().replace(/\/+$/, ""); u = u.replace(/\/player_api\.php$/i, ""); u = u.replace(/\/+$/, ""); if (!/^https?:\/\//i.test(u)) u = `http://${u}`; return u; } export function parseXtreamBaseUrl(raw: string): string { return normalizeBaseUrl(raw); } export class XtreamClient { private cache = new Map>(); readonly baseUrl: string; readonly username: string; readonly password: string; constructor(creds: XtreamCredentials) { this.baseUrl = normalizeBaseUrl(creds.baseUrl); this.username = creds.username; this.password = creds.password; } static fromEncrypted(opts: { baseUrl: string; username: string; passwordEnc: string; sessionSecret: string; }): XtreamClient { return new XtreamClient({ baseUrl: opts.baseUrl, username: opts.username, password: decryptSecret(opts.passwordEnc, opts.sessionSecret), }); } private cacheGet(key: string, ttlMs: number): T | null { const hit = this.cache.get(key); if (!hit) return null; if (Date.now() - hit.at > ttlMs) { this.cache.delete(key); return null; } return hit.data as T; } private cacheSet(key: string, data: unknown) { this.cache.set(key, { at: Date.now(), data }); } private apiUrl(params: Record): string { const q = new URLSearchParams({ username: this.username, password: this.password, }); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== "") q.set(k, String(v)); } return `${this.baseUrl}/player_api.php?${q}`; } private async getJson(params: Record, ttlMs: number): Promise { const url = this.apiUrl(params); const cacheKey = url.replace(this.password, "***"); const cached = this.cacheGet(cacheKey, ttlMs); if (cached) return cached; const res = await fetch(url, { headers: { "User-Agent": "MediaCluster/1.0" }, // Grote live-lijsten kunnen langer duren dan 20s signal: AbortSignal.timeout( params.action === "get_live_streams" && !params.category_id ? 60_000 : 25_000 ), }); if (!res.ok) { throw new Error(`Xtream API HTTP ${res.status}`); } const data = (await res.json()) as unknown; // List-actions: coerce object-maps / wrappers naar arrays (XUI-varianten) if (typeof params.action === "string" && LIST_ACTIONS.has(params.action)) { const list = coerceXtreamList(data); if (!Array.isArray(data) && list.length === 0 && data != null && data !== false) { // Echt foutobject (auth error e.d.), niet cachen throw new Error(`Xtream API gaf geen lijst terug voor ${params.action}`); } this.cacheSet(cacheKey, list); return list as T; } this.cacheSet(cacheKey, data); return data as T; } async authenticate(): Promise<{ status: string; expDate?: string; maxConnections?: string; activeConnections?: string; }> { const data = await this.getJson<{ user_info?: { auth?: number; status?: string; exp_date?: string; max_connections?: string; active_cons?: string; }; server_info?: unknown; }>({}, 30_000); const ui = data.user_info; if (!ui || ui.auth === 0) { throw new Error("Xtream login mislukt (auth=0)"); } return { status: ui.status ?? "Unknown", expDate: ui.exp_date, maxConnections: ui.max_connections, activeConnections: ui.active_cons, }; } getLiveCategories() { return this.getLiveListCached( "get_live_categories", { action: "get_live_categories" }, "" ); } getLiveStreams(categoryId?: string) { return this.getLiveListCached( "get_live_streams", { action: "get_live_streams", category_id: categoryId }, 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); } getVodStreams(categoryId?: string) { return this.getJson( { action: "get_vod_streams", category_id: categoryId }, 3 * 60_000 ); } getSeriesCategories() { return this.getJson({ action: "get_series_categories" }, 5 * 60_000); } getSeries(categoryId?: string) { return this.getJson( { action: "get_series", category_id: categoryId }, 3 * 60_000 ); } getSeriesInfo(seriesId: number) { return this.getJson<{ seasons?: Array<{ air_date?: string; episode_count?: number; name?: string; season_number?: number }>; info?: XtreamSeries & { plot?: string; cast?: string; director?: string }; episodes?: Record< string, Array<{ id: string; episode_num: number; title?: string; container_extension?: string; info?: { plot?: string; duration_secs?: number; movie_image?: string; releasedate?: string }; }> >; }>({ action: "get_series_info", series_id: seriesId }, 5 * 60_000); } async getShortEpg(streamId: number, limit = 12): Promise { try { const data = await this.getJson<{ epg_listings?: XtreamEpgListing[] }>( { action: "get_short_epg", stream_id: streamId, limit }, 60_000 ); if (data.epg_listings?.length) return data.epg_listings; } catch { // fall through to simple data table } try { const data = await this.getJson<{ epg_listings?: XtreamEpgListing[] }>( { action: "get_simple_data_table", stream_id: streamId }, 60_000 ); const rows = data.epg_listings ?? []; return limit > 0 ? rows.slice(0, limit) : rows; } catch { return []; } } /** * Bulk EPG via xmltv.php (+ optionele externe rijke feed). * Geeft now/next per channel id (dunne wrapper om guides). */ async getXmltvNowNextByChannelId(ttlMs = 20 * 60_000): Promise> { const guides = await this.getXmltvGuidesByChannelId(ttlMs); const map = new Map(); for (const [id, g] of guides) { map.set(id, { now: g.now, next: g.next }); } return map; } /** * Rijke gids per XMLTV-channel id: now/next + 24u schedule. * Process-globale SWR-cache: serveert direct uit RAM; refresh op achtergrond. * ttlMs === 0 → force refresh (warmup). */ 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 = [ `${this.baseUrl}/xmltv.php?username=${user}&password=${pass}`, `${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`, ]; const externalFetches = externalEpgXmlUrls.map((url) => fetchXmltvText([url]) .then((xml) => parseXmltvGuides(xml)) .catch((err) => { console.warn("[iptv-epg] external xmltv:", url, err); return new Map(); }) ); const [xuiXml, ...externalMaps] = await Promise.all([ fetchXmltvText(xuiUrls).catch((err) => { console.warn("[iptv-epg] xui xmltv:", err); return ""; }), ...externalFetches, ]); let external = new Map(); for (const m of externalMaps) { external = mergeXmltvGuides(external, m); } const xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map(); const map = mergeXmltvGuides(xui, external); if (map.size === 0) { throw new Error("XMLTV niet beschikbaar"); } return map; } /** * XUI playlists: output=hls | ts | llhls (LLOD low-latency, XUI 1.5+). * On-demand + LLOD v3 levert HLS-segmenten — geen raw TS continuous stream. */ async getLivePlayUrlMap(output: "hls" | "ts" | "llhls" = "hls"): Promise> { const cacheKey = `playlist:${output}`; const cached = this.cacheGet>(cacheKey, 5 * 60_000); if (cached) return cached; const user = encodeURIComponent(this.username); const pass = encodeURIComponent(this.password); const url = `${this.baseUrl}/playlist/${user}/${pass}/m3u_plus?output=${output}`; const res = await fetch(url, { headers: { "User-Agent": "MediaCluster/1.0" }, signal: AbortSignal.timeout(45000), }); if (!res.ok) { throw new Error(`XUI playlist HTTP ${res.status}`); } const text = await res.text(); const map = parseXuiPlaylistPlayUrls(text); this.cacheSet(cacheKey, map); return map; } /** Normaliseer XUI /play/{token} naar expliciet /m3u8 of /ts pad. */ private normalizePlayUrl(url: string, kind: "hls" | "ts"): string { const qIdx = url.indexOf("?"); const path = qIdx >= 0 ? url.slice(0, qIdx) : url; const query = qIdx >= 0 ? url.slice(qIdx) : ""; if (kind === "hls") { if (/\/m3u8$/i.test(path)) return url; if (/\/play\/[^/]+$/i.test(path)) return `${path}/m3u8${query}`; return url; } if (/\/ts$/i.test(path)) return url; if (/\/m3u8$/i.test(path)) return `${path.replace(/\/m3u8$/i, "/ts")}${query}`; if (/\/play\/[^/]+$/i.test(path)) return `${path}/ts${query}`; return url; } /** XUI on-demand LLOD: llhls → hls playlist → classic m3u8 → ts. */ async resolveLivePlayUrls(streamId: number): Promise<{ llhls?: string; hls?: string; ts?: string; classic: string[]; }> { let llhls: string | undefined; try { const map = await this.getLivePlayUrlMap("llhls"); const raw = map.get(streamId); if (raw) llhls = this.normalizePlayUrl(raw, "hls"); } catch { // llhls niet beschikbaar op deze XUI-build } let hls: string | undefined; try { const map = await this.getLivePlayUrlMap("hls"); const raw = map.get(streamId); if (raw) hls = this.normalizePlayUrl(raw, "hls"); } catch { // ignore } let ts: string | undefined; try { const map = await this.getLivePlayUrlMap("ts"); const raw = map.get(streamId); if (raw) ts = this.normalizePlayUrl(raw, "ts"); } catch { // ignore } if (!ts && hls) { ts = hls.replace(/\/m3u8(\?.*)?$/i, "/ts$1"); } return { llhls, hls, ts, classic: [ this.liveStreamUrl(streamId, "m3u8"), this.liveStreamUrl(streamId, "ts"), ], }; } liveStreamUrl(streamId: number, ext: "m3u8" | "ts" | "" = "m3u8"): string { const user = encodeURIComponent(this.username); const pass = encodeURIComponent(this.password); if (!ext) return `${this.baseUrl}/live/${user}/${pass}/${streamId}`; return `${this.baseUrl}/live/${user}/${pass}/${streamId}.${ext}`; } vodStreamUrl(streamId: number, ext = "mp4"): string { const user = encodeURIComponent(this.username); const pass = encodeURIComponent(this.password); return `${this.baseUrl}/movie/${user}/${pass}/${streamId}.${ext}`; } seriesStreamUrl(episodeId: string, ext = "mp4"): string { const user = encodeURIComponent(this.username); const pass = encodeURIComponent(this.password); return `${this.baseUrl}/series/${user}/${pass}/${episodeId}.${ext}`; } } /** Parse XUI/Xtream m3u_plus: map xui-id / tvg channel stream id → play URL. */ export function parseXuiPlaylistPlayUrls(m3u: string): Map { const map = new Map(); const lines = m3u.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!line.startsWith("#EXTINF")) continue; const idMatch = line.match(/\bxui-id="(\d+)"/i) || line.match(/\btvg-ID="(\d+)"/i) || line.match(/\bstreamId="(\d+)"/i); if (!idMatch) continue; const streamId = parseInt(idMatch[1], 10); if (!Number.isFinite(streamId)) continue; let url = ""; for (let j = i + 1; j < lines.length; j++) { const next = lines[j]?.trim(); if (!next) continue; if (next.startsWith("#")) break; url = next; break; } if (url && /^https?:\/\//i.test(url)) { map.set(streamId, url); } } return map; } export function decodeEpgText(raw?: string): string { if (!raw) return ""; const unescaped = raw .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); try { // Xtream often base64-encodes title/description if (/^[A-Za-z0-9+/=]+$/.test(unescaped) && unescaped.length % 4 === 0 && unescaped.length >= 8) { const decoded = Buffer.from(unescaped, "base64").toString("utf8"); if (decoded && !decoded.includes("\uFFFD") && /[\p{L}\p{N}]/u.test(decoded)) { return decoded.trim(); } } } catch { // fall through } return unescaped.trim(); } /** Parse naive wall-clock in Europe/Amsterdam to epoch ms. */ export function parseAmsterdamLocalMs(raw: string): number | null { const m = raw .trim() .match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/); if (!m) return null; const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const s = Number(m[6] ?? "0"); const desiredAsUtc = Date.UTC(y, mo - 1, d, h, mi, s); const dtf = new Intl.DateTimeFormat("en-US", { timeZone: "Europe/Amsterdam", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); const asLocalUtc = (ms: number) => { const parts = dtf.formatToParts(new Date(ms)); const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value ?? "0"; let hour = Number(get("hour")); if (hour === 24) hour = 0; return Date.UTC( Number(get("year")), Number(get("month")) - 1, Number(get("day")), hour, Number(get("minute")), Number(get("second")) ); }; return desiredAsUtc + (desiredAsUtc - asLocalUtc(desiredAsUtc)); } export function parseXmltvTimeMs(raw: string): number | null { const t = raw.trim(); // 20260905160000 +0200 / 20260905160000+0200 / 20260905160000 const m = t.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})?/); if (m) { const y = Number(m[1]); const mo = Number(m[2]); const d = Number(m[3]); const h = Number(m[4]); const mi = Number(m[5]); const s = Number(m[6]); const sign = m[7].startsWith("-") ? -1 : 1; const offH = Number(m[7].replace(/[+-]/, "")); const offM = Number(m[8] ?? "0"); const utc = Date.UTC(y, mo - 1, d, h, mi, s) - sign * (offH * 3600_000 + offM * 60_000); return utc; } const compact = t.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); if (compact) { // Geen offset → Amsterdam lokale tijd return parseAmsterdamLocalMs( `${compact[1]}-${compact[2]}-${compact[3]} ${compact[4]}:${compact[5]}:${compact[6]}` ); } return parseAmsterdamLocalMs(t) ?? (Number.isFinite(Date.parse(t)) ? Date.parse(t) : null); } export function epgToIso(listing: XtreamEpgListing, which: "start" | "end"): string | undefined { const ts = which === "start" ? listing.start_timestamp : listing.stop_timestamp; if (ts && Number(ts) > 0) { // Panels leveren soms ms i.p.v. seconden const n = Number(ts); const ms = n > 1e12 ? n : n * 1000; return new Date(ms).toISOString(); } const raw = which === "start" ? listing.start : listing.end; if (!raw) return undefined; const withOffset = String(raw).match( /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\s*([+-]\d{2})(\d{2})?/ ); if (withOffset) { const ms = parseXmltvTimeMs(String(raw)); if (ms != null) return new Date(ms).toISOString(); } const compact = String(raw).match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/); if (compact) { const ms = parseAmsterdamLocalMs( `${compact[1]}-${compact[2]}-${compact[3]} ${compact[4]}:${compact[5]}:${compact[6]}` ); if (ms != null) return new Date(ms).toISOString(); } const amsterdam = parseAmsterdamLocalMs(String(raw)); if (amsterdam != null) return new Date(amsterdam).toISOString(); const d = new Date(String(raw)); if (!Number.isNaN(d.getTime())) return d.toISOString(); return undefined; } /** * Lightweight XMLTV parser → rijke gids per channel id (now/next + 24u). * Vermijdt zware XML-deps; werkt op Odido / typische Xtream xmltv.php output. */ export function parseXmltvGuides(xml: string): Map { const now = Date.now(); const windowStart = now - 2 * 60 * 60_000; const windowEnd = now + 24 * 60 * 60_000; type Raw = { start: number; end: number; title: string; subTitle: string | null; description: string | null; imageUrl: string | null; category: string | null; episode: string | null; }; const byChannel = new Map(); const progRe = /]*)>([\s\S]*?)<\/programme>/gi; let match: RegExpExecArray | null; while ((match = progRe.exec(xml)) !== null) { const attrs = match[1]; const body = match[2]; const channel = attrs.match(/\bchannel="([^"]+)"/i)?.[1]; const startRaw = attrs.match(/\bstart="([^"]+)"/i)?.[1]; const stopRaw = attrs.match(/\bstop="([^"]+)"/i)?.[1]; if (!channel || !startRaw) continue; const start = parseXmltvTimeMs(startRaw); if (start == null) continue; const end = stopRaw ? parseXmltvTimeMs(stopRaw) : start + 30 * 60_000; if (end == null) continue; // Venster: nog bezig of start binnen 24u if (end < windowStart || start > windowEnd) continue; const title = firstXmlTagText(body, "title") || "Programma"; const subTitle = firstXmlTagText(body, "sub-title"); const description = firstXmlTagText(body, "desc"); const category = firstXmlTagText(body, "category"); const imageUrl = firstXmlIconSrc(body); const episode = formatXmltvEpisode(body); const list = byChannel.get(channel) ?? []; list.push({ start, end, title, subTitle: subTitle || null, description: description || null, imageUrl: imageUrl || null, category: category || null, episode, }); byChannel.set(channel, list); } const result = new Map(); for (const [channel, progs] of byChannel) { progs.sort((a, b) => a.start - b.start); const schedule = progs.map(rawToXmltvProgram); let nowProg: XmltvProgram | null = null; let nextProg: XmltvProgram | null = null; for (const p of schedule) { const startMs = Date.parse(p.start); const endMs = p.end ? Date.parse(p.end) : startMs + 30 * 60_000; if (startMs <= now && now < endMs) { nowProg = p; } else if (startMs > now && !nextProg) { nextProg = p; } } result.set(channel, { now: nowProg, next: nextProg, schedule }); } return result; } /** Thin now/next (backward compat). */ export function parseXmltvNowNext(xml: string): Map { const guides = parseXmltvGuides(xml); const map = new Map(); for (const [id, g] of guides) { map.set(id, { now: g.now, next: g.next }); } return map; } function rawToXmltvProgram(p: { start: number; end: number; title: string; subTitle: string | null; description: string | null; imageUrl: string | null; category: string | null; episode: string | null; }): XmltvProgram { return { title: p.title, subTitle: p.subTitle, description: p.description, imageUrl: p.imageUrl, category: p.category, episode: p.episode, start: new Date(p.start).toISOString(), end: new Date(p.end).toISOString(), }; } function firstXmlTagText(body: string, tag: string): string { const re = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"); const m = body.match(re); if (!m) return ""; const inner = m[1].replace(//g, "$1"); return decodeEpgText(inner); } function firstXmlIconSrc(body: string): string { const m = body.match(/]*\bsrc="([^"]+)"/i) || body.match(/]*\bsrc='([^']+)'/i); const url = m?.[1]?.trim() ?? ""; return /^https?:\/\//i.test(url) ? url : ""; } /** xmltv_ns is 0-based season.episode — toon als S01E02. Prefer onscreen. */ export function formatXmltvEpisode(body: string): string | null { const onscreen = body.match( /]*system="onscreen"[^>]*>([\s\S]*?)<\/episode-num>/i ); if (onscreen) { const t = decodeEpgText(onscreen[1].replace(//g, "$1")); if (t) return t; } const ns = body.match( /]*system="xmltv_ns"[^>]*>([\s\S]*?)<\/episode-num>/i ); if (ns) { const raw = decodeEpgText(ns[1].replace(//g, "$1")); const m = raw.match(/^(\d+)\.(\d+)/); if (m) { const season = Number(m[1]) + 1; const episode = Number(m[2]) + 1; if (Number.isFinite(season) && Number.isFinite(episode)) { return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; } } } return null; } /** Prefer richer (image/desc) over thinner for same channel id. */ export function mergeXmltvGuides( base: Map, richer: Map ): Map { const out = new Map(base); for (const [id, guide] of richer) { const prev = out.get(id); if (!prev) { out.set(id, guide); continue; } // Rijkere feed wint als die images of beschrijvingen heeft, anders merge velden const richerScore = (guide.now?.imageUrl ? 2 : 0) + (guide.now?.description ? 1 : 0) + (guide.schedule.some((p) => p.imageUrl) ? 2 : 0); const prevScore = (prev.now?.imageUrl ? 2 : 0) + (prev.now?.description ? 1 : 0) + (prev.schedule.some((p) => p.imageUrl) ? 2 : 0); if (richerScore >= prevScore) { out.set(id, guide); } } // Case-insensitive alias keys for (const [id, guide] of [...out.entries()]) { const lower = id.toLowerCase(); if (lower !== id && !out.has(lower)) out.set(lower, guide); } return out; } async function fetchXmltvText(urls: string[]): Promise { let lastErr: unknown; for (const url of urls) { try { const res = await fetch(url, { headers: { "User-Agent": "MediaCluster/1.0", Accept: "application/xml,text/xml,*/*", "Accept-Encoding": "gzip, deflate", }, signal: AbortSignal.timeout(120_000), }); if (!res.ok) throw new Error(`XMLTV HTTP ${res.status}`); const xml = await res.text(); if (xml.includes("