stremio/apps/master-api/src/iptv/service.ts
Jos Vooges | STH 002472e235 Mark IPTV live streams notWebReady and prefer MPEG-TS for XUI playback.
Stremio requires notWebReady for non-MP4 so the local streaming server follows XUI redirects; HLS auth often hangs while TS works.
2026-08-25 17:05:51 +02:00

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 };