import { prisma } from "../database/client"; import type { Config } from "../config"; import { XtreamClient, decodeEpgText, epgToIso, parseXtreamBaseUrl, type XtreamLiveStream, type XtreamSeries, type XtreamVodStream, } from "./xtream"; const IPTV_PREFIX = "mciptv"; const PAGE = 100; export function liveChannelId(streamId: number): string { return `${IPTV_PREFIX}-live-${streamId}`; } export function vodId(streamId: number): string { return `${IPTV_PREFIX}-vod-${streamId}`; } export function seriesId(seriesId: number): string { return `${IPTV_PREFIX}-series-${seriesId}`; } export function parseIptvId(id: string): { kind: "live" | "vod" | "series" | "series-ep"; streamId: number; season?: number; episode?: number; episodeStreamId?: string; } | null { const live = id.match(new RegExp(`^${IPTV_PREFIX}-live-(\\d+)(?::epg:(\\d+))?$`)); if (live) return { kind: "live", streamId: parseInt(live[1], 10) }; const vod = id.match(new RegExp(`^${IPTV_PREFIX}-vod-(\\d+)$`)); if (vod) return { kind: "vod", streamId: parseInt(vod[1], 10) }; const seriesEp = id.match( new RegExp(`^${IPTV_PREFIX}-series-(\\d+):(\\d+):(\\d+)(?::(.+))?$`) ); if (seriesEp) { return { kind: "series-ep", streamId: parseInt(seriesEp[1], 10), season: parseInt(seriesEp[2], 10), episode: parseInt(seriesEp[3], 10), episodeStreamId: seriesEp[4], }; } const series = id.match(new RegExp(`^${IPTV_PREFIX}-series-(\\d+)$`)); if (series) return { kind: "series", streamId: parseInt(series[1], 10) }; return null; } export async function loadXtreamForToken( tokenId: string, config: Config ): Promise<{ client: XtreamClient; line: { enableLive: boolean; enableVod: boolean; enableSeries: boolean } } | null> { const line = await prisma.iptvLine.findUnique({ where: { addonTokenId: tokenId } }); if (!line) return null; try { const client = XtreamClient.fromEncrypted({ baseUrl: line.baseUrl, username: line.username, passwordEnc: line.passwordEnc, sessionSecret: config.SESSION_SECRET, }); return { client, line: { enableLive: line.enableLive, enableVod: line.enableVod, enableSeries: line.enableSeries, }, }; } catch { return null; } } export async function buildIptvCatalogs(tokenId: string, config: Config) { const xt = await loadXtreamForToken(tokenId, config); if (!xt) return { catalogs: [] as unknown[], types: [] as string[], genreLive: [] as string[] }; const genreExtra = (options: string[]) => [ { name: "genre", isRequired: false, options: options.length ? options : undefined }, { name: "search", isRequired: false }, { name: "skip", isRequired: false }, ]; const catalogs: Array> = []; const types = new Set(["movie", "series"]); let liveNames: string[] = []; if (xt.line.enableLive) { types.add("tv"); try { const cats = await xt.client.getLiveCategories(); liveNames = cats.map((c) => c.category_name).filter(Boolean); } catch { liveNames = []; } catalogs.push({ type: "tv", id: "iptv-live", name: "Live TV", extra: genreExtra(liveNames), }); } if (xt.line.enableVod) { let vodNames: string[] = []; try { vodNames = (await xt.client.getVodCategories()).map((c) => c.category_name).filter(Boolean); } catch { vodNames = []; } catalogs.push({ type: "movie", id: "iptv-movies", name: "IPTV Films", extra: genreExtra(vodNames), }); } if (xt.line.enableSeries) { let seriesNames: string[] = []; try { seriesNames = (await xt.client.getSeriesCategories()) .map((c) => c.category_name) .filter(Boolean); } catch { seriesNames = []; } catalogs.push({ type: "series", id: "iptv-series", name: "IPTV Series", extra: genreExtra(seriesNames), }); } return { catalogs, types: [...types], genreLive: liveNames }; } function matchCategoryId( cats: Array<{ category_id: string; category_name: string }>, genre?: string ): string | undefined { if (!genre) return undefined; const hit = cats.find((c) => c.category_name.toLowerCase() === genre.toLowerCase()); return hit?.category_id; } function filterSearch(rows: T[], search?: string): T[] { if (!search) return rows; const q = search.toLowerCase(); return rows.filter((r) => r.name.toLowerCase().includes(q)); } export async function getIptvLiveCatalog( tokenId: string, config: Config, opts: { genre?: string; search?: string; skip?: number } ) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableLive) return []; const cats = await xt.client.getLiveCategories().catch(() => []); const catId = matchCategoryId(cats, opts.genre); let streams = await xt.client.getLiveStreams(catId); streams = filterSearch(streams, opts.search); streams = [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id)); const skip = opts.skip ?? 0; const page = streams.slice(skip, skip + PAGE); const catName = (s: XtreamLiveStream) => cats.find((c) => String(c.category_id) === String(s.category_id))?.category_name; return page.map((s) => ({ id: liveChannelId(s.stream_id), type: "tv" as const, name: s.name, poster: s.stream_icon || undefined, posterShape: "square" as const, logo: s.stream_icon || undefined, genres: catName(s) ? [catName(s)!] : undefined, })); } export async function getIptvVodCatalog( tokenId: string, config: Config, opts: { genre?: string; search?: string; skip?: number } ) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableVod) return []; const cats = await xt.client.getVodCategories().catch(() => []); const catId = matchCategoryId(cats, opts.genre); let streams = await xt.client.getVodStreams(catId); streams = filterSearch(streams, opts.search); const skip = opts.skip ?? 0; const page = streams.slice(skip, skip + PAGE); return page.map((s: XtreamVodStream) => ({ id: vodId(s.stream_id), type: "movie" as const, name: s.name, poster: s.stream_icon || undefined, background: s.backdrop_path?.[0] || undefined, description: s.plot || undefined, releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined, imdbRating: s.rating || undefined, genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined, })); } export async function getIptvSeriesCatalog( tokenId: string, config: Config, opts: { genre?: string; search?: string; skip?: number } ) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableSeries) return []; const cats = await xt.client.getSeriesCategories().catch(() => []); const catId = matchCategoryId(cats, opts.genre); let rows = await xt.client.getSeries(catId); rows = filterSearch(rows, opts.search); const skip = opts.skip ?? 0; const page = rows.slice(skip, skip + PAGE); return page.map((s: XtreamSeries) => ({ id: seriesId(s.series_id), type: "series" as const, name: s.name, poster: s.cover || undefined, background: s.backdrop_path?.[0] || undefined, description: s.plot || undefined, releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined, imdbRating: s.rating || undefined, genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined, })); } export async function getIptvLiveMeta(tokenId: string, config: Config, streamId: number) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableLive) return null; const streams = await xt.client.getLiveStreams(); const ch = streams.find((s) => s.stream_id === streamId); if (!ch) return null; const cats = await xt.client.getLiveCategories().catch(() => []); const bouquet = cats.find((c) => String(c.category_id) === String(ch.category_id))?.category_name ?? "Live"; const epg = await xt.client.getShortEpg(streamId, 24); const now = Date.now(); let nowTitle = ""; let nextTitle = ""; const icon = ch.stream_icon || undefined; const epgVideos = epg .map((listing, index) => { const start = epgToIso(listing, "start"); const end = epgToIso(listing, "end"); if (!start) return null; const title = decodeEpgText(listing.title) || "Programma"; const overview = decodeEpgText(listing.description) || undefined; const startMs = Date.parse(start); const endMs = end ? Date.parse(end) : startMs + 30 * 60_000; const isNow = startMs <= now && now < endMs; if (isNow) nowTitle = title; if (!nextTitle && startMs > now) nextTitle = title; const runtimeMin = Math.max(1, Math.round((endMs - startMs) / 60_000)); const clock = new Date(startMs).toLocaleTimeString("nl-NL", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Europe/Amsterdam", }); return { id: `${liveChannelId(streamId)}:epg:${Math.floor(startMs / 1000)}`, title: isNow ? `● Nu · ${title}` : `${clock} · ${title}`, overview, thumbnail: icon, released: start, startTime: start, endTime: end ?? new Date(endMs).toISOString(), runtime: `${runtimeMin} min`, season: 0, episode: index + 1, available: true, }; }) .filter(Boolean) as Array>; // Always lead with a Live-entry so Stremio can play immediately. const liveId = liveChannelId(streamId); const videos: Array> = [ { id: liveId, title: nowTitle ? `▶ Live — ${nowTitle}` : "▶ Live kijken", overview: nextTitle ? `Hierna: ${nextTitle}` : bouquet, thumbnail: icon, released: new Date(now).toISOString(), available: true, season: 0, episode: 0, }, ...epgVideos, ]; const descriptionParts = [ nowTitle ? `Nu: ${nowTitle}` : null, nextTitle ? `Straks: ${nextTitle}` : null, bouquet ? `Bouquet: ${bouquet}` : null, ].filter(Boolean); return { id: liveId, type: "tv" as const, name: ch.name, poster: icon, posterShape: "square" as const, logo: icon, background: icon, description: descriptionParts.join(" · ") || `Live · ${bouquet}`, genres: [bouquet], videos, behaviorHints: { defaultVideoId: liveId, hasScheduledVideos: epgVideos.length > 0, }, }; } export async function getIptvVodMeta(tokenId: string, config: Config, streamId: number) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableVod) return null; const streams = await xt.client.getVodStreams(); const s = streams.find((x) => x.stream_id === streamId); if (!s) return null; return { id: vodId(streamId), type: "movie" as const, name: s.name, poster: s.stream_icon || undefined, background: s.backdrop_path?.[0] || undefined, description: s.plot || undefined, releaseInfo: s.releaseDate?.slice?.(0, 4) || undefined, runtime: s.duration || undefined, imdbRating: s.rating || undefined, cast: s.cast ? s.cast.split(",").map((x) => x.trim()).filter(Boolean) : undefined, director: s.director ? s.director.split(",").map((x) => x.trim()).filter(Boolean) : undefined, genres: s.genre ? s.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined, }; } export async function getIptvSeriesMeta(tokenId: string, config: Config, seriesNum: number) { const xt = await loadXtreamForToken(tokenId, config); if (!xt?.line.enableSeries) return null; const info = await xt.client.getSeriesInfo(seriesNum); const name = info.info?.name ?? `Serie ${seriesNum}`; const sid = seriesId(seriesNum); const videos: Array> = []; const episodes = info.episodes ?? {}; for (const [seasonKey, eps] of Object.entries(episodes)) { const seasonNumber = parseInt(seasonKey, 10) || 1; for (const ep of eps) { const epNum = ep.episode_num; const released = ep.info?.releasedate ? new Date(ep.info.releasedate).toISOString() : new Date(Date.UTC(2000, 0, 1 + seasonNumber * 40 + epNum)).toISOString(); videos.push({ id: `${sid}:${seasonNumber}:${epNum}:${ep.id}`, title: ep.title || `Episode ${epNum}`, season: seasonNumber, episode: epNum, overview: ep.info?.plot || undefined, thumbnail: ep.info?.movie_image || undefined, released: Number.isNaN(Date.parse(released)) ? new Date(Date.UTC(2000, 0, seasonNumber * 40 + epNum)).toISOString() : released, available: true, }); } } videos.sort( (a, b) => Number(a.season) - Number(b.season) || Number(a.episode) - Number(b.episode) ); return { id: sid, type: "series" as const, name, poster: info.info?.cover || undefined, background: info.info?.backdrop_path?.[0] || undefined, description: info.info?.plot || undefined, genres: info.info?.genre ? info.info.genre.split(",").map((g) => g.trim()).filter(Boolean) : undefined, cast: info.info?.cast ? info.info.cast.split(",").map((x) => x.trim()).filter(Boolean) : undefined, director: info.info?.director ? info.info.director.split(",").map((x) => x.trim()).filter(Boolean) : undefined, imdbRating: info.info?.rating || undefined, videos, behaviorHints: { hasScheduledVideos: false }, }; } export async function getIptvStreams( tokenId: string, config: Config, type: string, id: string ): Promise }>> { const xt = await loadXtreamForToken(tokenId, config); if (!xt) return []; const parsed = parseIptvId(id); if (!parsed) return []; // Direct client → XUI. Prefer /play/{token} from m3u playlist. // Stremio: notWebReady MUST be true for non-MP4 (m3u8/ts) so the local // streaming server handles redirects — traffic stays client→XUI, not Master. // Real live HLS often hangs on XUI /auth/*.m3u8; MPEG-TS is reliable. if (type === "tv" && parsed.kind === "live") { const sid = parsed.streamId; const resolved = await xt.client.resolveLivePlayUrls(sid); const out: Array<{ name: string; title: string; url: string; behaviorHints: Record; }> = []; const hint = (filename: string) => ({ notWebReady: true, bingeGroup: `mciptv-live-${sid}`, filename, }); if (resolved.ts) { out.push({ name: "Live · MPEG-TS", title: "XUI · direct ts", url: resolved.ts, behaviorHints: hint(`live-${sid}.ts`), }); } if (resolved.hls && resolved.hls !== resolved.ts) { out.push({ name: "Live · HLS", title: "XUI · m3u8 (kan hangen)", url: resolved.hls, behaviorHints: hint(`live-${sid}.m3u8`), }); } if (out.length === 0) { for (const url of resolved.classic) { const isTs = url.endsWith(".ts"); out.push({ name: isTs ? "Live · MPEG-TS" : "Live · HLS", title: "classic Xtream", url, behaviorHints: hint(isTs ? `live-${sid}.ts` : `live-${sid}.m3u8`), }); } } return out; } if (type === "movie" && parsed.kind === "vod") { const streams = await xt.client.getVodStreams(); const s = streams.find((x) => x.stream_id === parsed.streamId); const ext = s?.container_extension || "mp4"; // VOD/series also non-MP4 often — keep notWebReady for containers != mp4 return [ { name: `IPTV · ${ext.toUpperCase()}`, title: s?.name || "VOD", url: xt.client.vodStreamUrl(parsed.streamId, ext), behaviorHints: { notWebReady: ext.toLowerCase() !== "mp4", filename: `vod-${parsed.streamId}.${ext}`, }, }, ]; } if (type === "series" && (parsed.kind === "series-ep" || parsed.kind === "series")) { if (parsed.kind === "series") return []; const info = await xt.client.getSeriesInfo(parsed.streamId); const seasonEps = info.episodes?.[String(parsed.season)] ?? []; const ep = seasonEps.find((e) => e.id === parsed.episodeStreamId) || seasonEps.find((e) => e.episode_num === parsed.episode); const ext = ep?.container_extension || "mp4"; const epId = ep?.id || parsed.episodeStreamId; if (!epId) return []; return [ { name: `IPTV · S${parsed.season}E${parsed.episode}`, title: ep?.title || `Episode ${parsed.episode}`, url: xt.client.seriesStreamUrl(epId, ext), behaviorHints: { notWebReady: ext.toLowerCase() !== "mp4", filename: `series-${parsed.streamId}-s${parsed.season}e${parsed.episode}.${ext}`, }, }, ]; } return []; } export { parseXtreamBaseUrl, IPTV_PREFIX };