stremio/apps/master-api/src/iptv/xtream.ts
Jos Vooges | STH a7038f04fa 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.
2026-09-15 18:55:13 +02:00

1015 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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>();
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<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([
"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<T = unknown>(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<string, unknown>;
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<string, unknown>) };
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<string, CacheEntry<unknown>>();
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<T>(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, string | number | undefined>): 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<T>(params: Record<string, string | number | undefined>, ttlMs: number): Promise<T> {
const url = this.apiUrl(params);
const cacheKey = url.replace(this.password, "***");
const cached = this.cacheGet<T>(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<XtreamCategory[]>(
"get_live_categories",
{ action: "get_live_categories" },
""
);
}
getLiveStreams(categoryId?: string) {
return this.getLiveListCached<XtreamLiveStream[]>(
"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<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);
}
getVodStreams(categoryId?: string) {
return this.getJson<XtreamVodStream[]>(
{ action: "get_vod_streams", category_id: categoryId },
3 * 60_000
);
}
getSeriesCategories() {
return this.getJson<XtreamCategory[]>({ action: "get_series_categories" }, 5 * 60_000);
}
getSeries(categoryId?: string) {
return this.getJson<XtreamSeries[]>(
{ 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<XtreamEpgListing[]> {
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<Map<string, XmltvNowNext>> {
const guides = await this.getXmltvGuidesByChannelId(ttlMs);
const map = new Map<string, XmltvNowNext>();
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<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 = [
`${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<string, XmltvChannelGuide>();
})
);
const [xuiXml, ...externalMaps] = await Promise.all([
fetchXmltvText(xuiUrls).catch((err) => {
console.warn("[iptv-epg] xui xmltv:", err);
return "";
}),
...externalFetches,
]);
let external = new Map<string, XmltvChannelGuide>();
for (const m of externalMaps) {
external = mergeXmltvGuides(external, m);
}
const xui = xuiXml ? parseXmltvGuides(xuiXml) : new Map<string, XmltvChannelGuide>();
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<Map<number, string>> {
const cacheKey = `playlist:${output}`;
const cached = this.cacheGet<Map<number, string>>(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<number, string> {
const map = new Map<number, string>();
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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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<string, XmltvChannelGuide> {
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<string, Raw[]>();
const progRe = /<programme\b([^>]*)>([\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<string, XmltvChannelGuide>();
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<string, XmltvNowNext> {
const guides = parseXmltvGuides(xml);
const map = new Map<string, XmltvNowNext>();
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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1");
return decodeEpgText(inner);
}
function firstXmlIconSrc(body: string): string {
const m =
body.match(/<icon\b[^>]*\bsrc="([^"]+)"/i) ||
body.match(/<icon\b[^>]*\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(
/<episode-num\b[^>]*system="onscreen"[^>]*>([\s\S]*?)<\/episode-num>/i
);
if (onscreen) {
const t = decodeEpgText(onscreen[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1"));
if (t) return t;
}
const ns = body.match(
/<episode-num\b[^>]*system="xmltv_ns"[^>]*>([\s\S]*?)<\/episode-num>/i
);
if (ns) {
const raw = decodeEpgText(ns[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/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<string, XmltvChannelGuide>,
richer: Map<string, XmltvChannelGuide>
): Map<string, XmltvChannelGuide> {
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<string> {
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("<programme") || xml.includes("<tv")) return xml;
} catch (err) {
lastErr = err;
}
}
throw lastErr instanceof Error ? lastErr : new Error("XMLTV niet beschikbaar");
}