Keep NL|ALL-style packs visible, only drop pure Alles/All dumps, and coerce XUI object-map category responses. Co-authored-by: Cursor <cursoragent@cursor.com>
686 lines
21 KiB
TypeScript
686 lines
21 KiB
TypeScript
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;
|
|
};
|
|
|
|
export type XmltvNowNext = {
|
|
now: { title: string; start: string; end: string | null } | null;
|
|
next: { title: string; start: string; end: string | null } | null;
|
|
};
|
|
|
|
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() {
|
|
// Korte TTL: na IPTV-wijzigingen snel weer alle planken zichtbaar
|
|
return this.getJson<XtreamCategory[]>({ action: "get_live_categories" }, 60_000);
|
|
}
|
|
|
|
getLiveStreams(categoryId?: string) {
|
|
return this.getJson<XtreamLiveStream[]>(
|
|
{ action: "get_live_streams", category_id: categoryId },
|
|
3 * 60_000
|
|
);
|
|
}
|
|
|
|
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 — één download voor alle zenders (veel betrouwbaarder
|
|
* dan honderden get_short_epg-calls).
|
|
*/
|
|
async getXmltvNowNextByChannelId(ttlMs = 20 * 60_000): Promise<Map<string, XmltvNowNext>> {
|
|
const cacheKey = "xmltv:now-next";
|
|
const cached = this.cacheGet<Map<string, XmltvNowNext>>(cacheKey, ttlMs);
|
|
if (cached) return cached;
|
|
|
|
const user = encodeURIComponent(this.username);
|
|
const pass = encodeURIComponent(this.password);
|
|
const urls = [
|
|
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}`,
|
|
`${this.baseUrl}/xmltv.php?username=${user}&password=${pass}&type=xml`,
|
|
];
|
|
|
|
let xml = "";
|
|
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}`);
|
|
xml = await res.text();
|
|
if (xml.includes("<programme") || xml.includes("<tv")) break;
|
|
xml = "";
|
|
} catch (err) {
|
|
lastErr = err;
|
|
}
|
|
}
|
|
if (!xml) {
|
|
throw lastErr instanceof Error ? lastErr : new Error("XMLTV niet beschikbaar");
|
|
}
|
|
|
|
const map = parseXmltvNowNext(xml);
|
|
this.cacheSet(cacheKey, map);
|
|
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(/&/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 → now/next per channel id.
|
|
* Vermijdt zware XML-deps; werkt op typische Xtream xmltv.php output.
|
|
*/
|
|
export function parseXmltvNowNext(xml: string): Map<string, XmltvNowNext> {
|
|
const now = Date.now();
|
|
type Prog = { start: number; end: number; title: string };
|
|
const byChannel = new Map<string, Prog[]>();
|
|
|
|
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;
|
|
// Alleen programma's rond nu bewaren (geheugen)
|
|
if (end < now - 2 * 60 * 60_000 || start > now + 6 * 60 * 60_000) continue;
|
|
|
|
const titleTag =
|
|
body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ??
|
|
body.match(/<title>([\s\S]*?)<\/title>/i)?.[1] ??
|
|
"";
|
|
const title = decodeEpgText(titleTag.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")) || "Programma";
|
|
const list = byChannel.get(channel) ?? [];
|
|
list.push({ start, end, title });
|
|
byChannel.set(channel, list);
|
|
}
|
|
|
|
const result = new Map<string, XmltvNowNext>();
|
|
for (const [channel, progs] of byChannel) {
|
|
progs.sort((a, b) => a.start - b.start);
|
|
let nowProg: XmltvNowNext["now"] = null;
|
|
let nextProg: XmltvNowNext["next"] = null;
|
|
for (const p of progs) {
|
|
if (p.start <= now && now < p.end) {
|
|
nowProg = {
|
|
title: p.title,
|
|
start: new Date(p.start).toISOString(),
|
|
end: new Date(p.end).toISOString(),
|
|
};
|
|
} else if (p.start > now && !nextProg) {
|
|
nextProg = {
|
|
title: p.title,
|
|
start: new Date(p.start).toISOString(),
|
|
end: new Date(p.end).toISOString(),
|
|
};
|
|
}
|
|
}
|
|
if (nowProg || nextProg) {
|
|
result.set(channel, { now: nowProg, next: nextProg });
|
|
}
|
|
}
|
|
return result;
|
|
}
|