Stremio requires notWebReady for non-MP4 so the local streaming server follows XUI redirects; HLS auth often hangs while TS works.
532 lines
17 KiB
TypeScript
532 lines
17 KiB
TypeScript
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<Record<string, unknown>> = [];
|
|
const types = new Set<string>(["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<T extends { name: string }>(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<Record<string, unknown>>;
|
|
|
|
// Always lead with a Live-entry so Stremio can play immediately.
|
|
const liveId = liveChannelId(streamId);
|
|
const videos: Array<Record<string, unknown>> = [
|
|
{
|
|
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<Record<string, unknown>> = [];
|
|
|
|
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<Array<{ name: string; title: string; url: string; behaviorHints?: Record<string, unknown> }>> {
|
|
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<string, unknown>;
|
|
}> = [];
|
|
|
|
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 };
|