diff --git a/apps/master-api/src/iptv/service.ts b/apps/master-api/src/iptv/service.ts index 44ffcc4..a17d651 100644 --- a/apps/master-api/src/iptv/service.ts +++ b/apps/master-api/src/iptv/service.ts @@ -433,27 +433,32 @@ export async function getIptvStreams( const parsed = parseIptvId(id); if (!parsed) return []; - // Direct Xtream/XUI URLs only (client → IPTV server). No Master proxy. - // Do NOT set notWebReady — that skips playback and XUI never sees a connection. + // Direct client → XUI URLs. Prefer XUI /play/{token} from m3u playlist + // (classic /live/user/pass/id hangs on real live; works for created_live 24/7). if (type === "tv" && parsed.kind === "live") { const sid = parsed.streamId; - return [ - { - name: "Live · HLS", - title: "m3u8", - url: xt.client.liveStreamUrl(sid, "m3u8"), - }, - { - name: "Live · MPEG-TS", - title: "ts", - url: xt.client.liveStreamUrl(sid, "ts"), - }, - { - name: "Live · raw", - title: "no extension", - url: xt.client.liveStreamUrl(sid, ""), - }, - ]; + const resolved = await xt.client.resolveLivePlayUrls(sid); + const out: Array<{ name: string; title: string; url: string }> = []; + + if (resolved.hls) { + out.push({ name: "Live · HLS", title: "XUI play m3u8", url: resolved.hls }); + } + if (resolved.ts && resolved.ts !== resolved.hls) { + out.push({ name: "Live · MPEG-TS", title: "XUI play ts", url: resolved.ts }); + } + + // Fallback for panels without /play/ playlist (or created_live) + if (out.length === 0) { + for (const url of resolved.classic) { + out.push({ + name: url.endsWith(".ts") ? "Live · MPEG-TS" : "Live · HLS", + title: "classic Xtream", + url, + }); + } + } + + return out; } if (type === "movie" && parsed.kind === "vod") { diff --git a/apps/master-api/src/iptv/xtream.ts b/apps/master-api/src/iptv/xtream.ts index deafdf9..86609ce 100644 --- a/apps/master-api/src/iptv/xtream.ts +++ b/apps/master-api/src/iptv/xtream.ts @@ -22,6 +22,7 @@ export type XtreamLiveStream = { category_id?: string; tv_archive?: number; tv_archive_duration?: number; + direct_source?: string; }; export type XtreamVodStream = { @@ -253,6 +254,62 @@ export class XtreamClient { } } + /** + * XUI 1.5+ playlists use /play/{token}/m3u8 instead of classic /live/user/pass/id. + * Real live channels often hang on classic URLs; created_live (24/7) still works classically. + */ + async getLivePlayUrlMap(output: "hls" | "ts" = "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; + } + + /** Prefer XUI /play/token URL; fall back to classic Xtream path. */ + async resolveLivePlayUrls(streamId: number): Promise<{ hls?: string; ts?: string; classic: string[] }> { + let hls: string | undefined; + try { + const hlsMap = await this.getLivePlayUrlMap("hls"); + hls = hlsMap.get(streamId); + } catch { + // ignore — fall back + } + + let ts: string | undefined; + if (hls) { + if (/\/m3u8$/i.test(hls)) { + ts = hls.replace(/\/m3u8$/i, "/ts"); + } else if (/\/play\/[^/]+$/i.test(hls)) { + const base = hls.replace(/\/$/, ""); + hls = `${base}/m3u8`; + ts = `${base}/ts`; + } + } + + return { + 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); @@ -273,6 +330,35 @@ export class XtreamClient { } } +/** 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 { diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index f157cb4..213385a 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -41,7 +41,7 @@ async function buildManifest(tokenId: string | null, config: Config) { const types = new Set(["movie", "series", ...iptv.types]); return { id: "com.mediacluster.library", - version: "1.5.2", + version: "1.5.3", name: "Media Cluster", description: "Privé bibliotheek + Live TV / IPTV (Xtream) met EPG", catalogs: [...BASE_CATALOGS, ...iptv.catalogs],