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; }; type CacheEntry = { at: number; data: 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" }, signal: AbortSignal.timeout(20000), }); if (!res.ok) { throw new Error(`Xtream API HTTP ${res.status}`); } const data = (await res.json()) as T; this.cacheSet(cacheKey, data); return data; } 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.getJson({ action: "get_live_categories" }, 5 * 60_000); } getLiveStreams(categoryId?: string) { return this.getJson( { action: "get_live_streams", category_id: categoryId }, 3 * 60_000 ); } 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 []; } } /** * 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 ""; try { // Xtream often base64-encodes title/description if (/^[A-Za-z0-9+/=]+$/.test(raw) && raw.length % 4 === 0) { const decoded = Buffer.from(raw, "base64").toString("utf8"); if (decoded && !decoded.includes("\uFFFD")) return decoded; } } catch { // fall through } return raw; } 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) { return new Date(Number(ts) * 1000).toISOString(); } const raw = which === "start" ? listing.start : listing.end; if (!raw) return undefined; // Formats: "2026-08-25 18:00:00" or "20260825180000 +0000" const compact = raw.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/); if (compact) { const iso = `${compact[1]}-${compact[2]}-${compact[3]}T${compact[4]}:${compact[5]}:${compact[6]}Z`; const d = new Date(iso); if (!Number.isNaN(d.getTime())) return d.toISOString(); } const d = new Date(raw.replace(" ", "T") + (raw.includes("Z") || raw.includes("+") ? "" : "Z")); if (!Number.isNaN(d.getTime())) return d.toISOString(); return undefined; }