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.
1015 lines
31 KiB
TypeScript
1015 lines
31 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;
|
||
};
|
||
|
||
/** 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(/&/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<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");
|
||
}
|