stremio/apps/master-api/src/iptv/xtream.ts
Jos Vooges | STH fe4b1210ac Prefer classic Xtream TS URLs for live IPTV playback.
Match Smarters by serving /live/user/pass/id.ts first from master-api, and tune the Android live player buffer for steadier TS playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 00:06:02 +02:00

426 lines
12 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;
};
type CacheEntry<T> = { 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<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" },
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<XtreamCategory[]>({ action: "get_live_categories" }, 5 * 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 [];
}
}
/**
* 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 "";
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;
}