Add epg=1 to the channels API so the app loads program titles in one request per category instead of one call per channel. Co-authored-by: Cursor <cursoragent@cursor.com>
289 lines
9.3 KiB
TypeScript
289 lines
9.3 KiB
TypeScript
import { prisma } from "../database/client";
|
|
import type { Config } from "../config";
|
|
import { AppError } from "../security/errors";
|
|
import { loadXtreamForToken } from "../iptv/service";
|
|
import {
|
|
decodeEpgText,
|
|
epgToIso,
|
|
type XtreamClient,
|
|
type XtreamEpgListing,
|
|
type XtreamLiveStream,
|
|
} from "../iptv/xtream";
|
|
|
|
/** Maak Xtream-categorienamen leesbaarder voor TV-gebruikers. */
|
|
export function friendlyCategoryName(raw: string): string {
|
|
let s = raw.trim();
|
|
s = s.replace(/^[A-Z]{2,3}\s*[|·:]\s*/i, "");
|
|
s = s.replace(/^[\s★☆●▪•\-–—]+/, "");
|
|
return s.trim() || raw.trim() || "Overig";
|
|
}
|
|
|
|
export async function resolveViewerIptvToken(viewerId: string): Promise<string | null> {
|
|
const viewer = await prisma.viewerUser.findUnique({
|
|
where: { id: viewerId },
|
|
select: { iptvAccess: true, iptvAddonTokenId: true, enabled: true, appAccess: true },
|
|
});
|
|
if (!viewer?.enabled || !viewer.appAccess || !viewer.iptvAccess) return null;
|
|
|
|
if (viewer.iptvAddonTokenId) {
|
|
const linked = await prisma.addonToken.findFirst({
|
|
where: {
|
|
id: viewer.iptvAddonTokenId,
|
|
revoked: false,
|
|
iptvLine: { enableLive: true, lastError: null },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
if (linked) return linked.id;
|
|
}
|
|
|
|
const fallback = await prisma.iptvLine.findFirst({
|
|
where: {
|
|
enableLive: true,
|
|
lastError: null,
|
|
addonToken: { revoked: false },
|
|
},
|
|
select: { addonTokenId: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
});
|
|
return fallback?.addonTokenId ?? null;
|
|
}
|
|
|
|
export async function getViewerIptvStatus(viewerId: string, config: Config) {
|
|
const tokenId = await resolveViewerIptvToken(viewerId);
|
|
if (!tokenId) return { enabled: false as const };
|
|
|
|
const xt = await loadXtreamForToken(tokenId, config);
|
|
if (!xt?.line.enableLive) return { enabled: false as const };
|
|
|
|
const cats = await xt.client.getLiveCategories().catch(() => []);
|
|
return {
|
|
enabled: true as const,
|
|
categoryCount: cats.length,
|
|
};
|
|
}
|
|
|
|
export async function getViewerIptvCategories(viewerId: string, config: Config) {
|
|
const tokenId = await resolveViewerIptvToken(viewerId);
|
|
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
|
|
|
const xt = await loadXtreamForToken(tokenId, config);
|
|
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
|
|
|
const cats = await xt.client.getLiveCategories().catch(() => []);
|
|
const streams = await xt.client.getLiveStreams().catch(() => []);
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const s of streams) {
|
|
const key = String(s.category_id ?? "");
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
|
|
const categories = cats
|
|
.map((c) => ({
|
|
id: String(c.category_id),
|
|
name: friendlyCategoryName(c.category_name),
|
|
rawName: c.category_name,
|
|
channelCount: counts.get(String(c.category_id)) ?? 0,
|
|
}))
|
|
.filter((c) => c.channelCount > 0)
|
|
.sort((a, b) => a.name.localeCompare(b.name, "nl"));
|
|
|
|
if (streams.length > 0) {
|
|
categories.unshift({
|
|
id: "__all__",
|
|
name: "Alle zenders",
|
|
rawName: "Alle zenders",
|
|
channelCount: streams.length,
|
|
});
|
|
}
|
|
|
|
return { categories };
|
|
}
|
|
|
|
function sortChannels(streams: XtreamLiveStream[]): XtreamLiveStream[] {
|
|
return [...streams].sort((a, b) => (a.num ?? a.stream_id) - (b.num ?? b.stream_id));
|
|
}
|
|
|
|
export async function getViewerIptvChannels(
|
|
viewerId: string,
|
|
config: Config,
|
|
categoryId?: string,
|
|
includeEpg = false
|
|
) {
|
|
const tokenId = await resolveViewerIptvToken(viewerId);
|
|
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
|
|
|
const xt = await loadXtreamForToken(tokenId, config);
|
|
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
|
|
|
let streams =
|
|
!categoryId || categoryId === "__all__"
|
|
? await xt.client.getLiveStreams()
|
|
: await xt.client.getLiveStreams(categoryId);
|
|
streams = sortChannels(streams);
|
|
|
|
const nowTitles = includeEpg
|
|
? await getCachedCategoryNowTitles(tokenId, categoryId, streams, xt.client)
|
|
: null;
|
|
|
|
return {
|
|
channels: streams.map((s) => ({
|
|
streamId: s.stream_id,
|
|
name: s.name.trim(),
|
|
logoUrl: s.stream_icon || null,
|
|
number: s.num ?? null,
|
|
categoryId: s.category_id ? String(s.category_id) : null,
|
|
nowTitle: nowTitles?.get(s.stream_id) ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getViewerIptvPlayUrl(
|
|
viewerId: string,
|
|
config: Config,
|
|
streamId: number
|
|
) {
|
|
const tokenId = await resolveViewerIptvToken(viewerId);
|
|
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
|
|
|
const xt = await loadXtreamForToken(tokenId, config);
|
|
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
|
|
|
const streams = await xt.client.getLiveStreams();
|
|
const ch = streams.find((s) => s.stream_id === streamId);
|
|
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
|
|
|
const resolved = await xt.client.resolveLivePlayUrls(streamId);
|
|
|
|
// Smarters-gedrag: klassiek Xtream-pad eerst (/live/user/pass/id.ts), daarna playlist-fallbacks
|
|
const classicTs = resolved.classic.find((u) => /\.ts$/i.test(u));
|
|
const classicHls = resolved.classic.find((u) => /\.m3u8$/i.test(u) || u.includes("m3u8"));
|
|
const playlistHls = resolved.hls ?? resolved.llhls;
|
|
const playlistTs = resolved.ts;
|
|
|
|
const candidates = [classicTs, playlistTs, classicHls, playlistHls, ...resolved.classic].filter(
|
|
(u): u is string => Boolean(u)
|
|
);
|
|
const unique = [...new Set(candidates)];
|
|
const primary = unique[0];
|
|
if (!primary) throw new AppError("UNAVAILABLE", "Geen afspeel-URL voor deze zender", 503);
|
|
const fallback = unique[1];
|
|
|
|
const formatFor = (url: string): "hls" | "ts" =>
|
|
url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts";
|
|
|
|
return {
|
|
streamId,
|
|
name: ch.name,
|
|
logoUrl: ch.stream_icon || null,
|
|
streamUrl: primary,
|
|
format: formatFor(primary),
|
|
fallbackStreamUrl: fallback ?? null,
|
|
fallbackFormat: fallback ? formatFor(fallback) : null,
|
|
};
|
|
}
|
|
|
|
export async function getViewerIptvEpg(viewerId: string, config: Config, streamId: number) {
|
|
const tokenId = await resolveViewerIptvToken(viewerId);
|
|
if (!tokenId) throw new AppError("FORBIDDEN", "Geen TV-toegang", 403);
|
|
|
|
const xt = await loadXtreamForToken(tokenId, config);
|
|
if (!xt?.line.enableLive) throw new AppError("FORBIDDEN", "Live TV niet beschikbaar", 403);
|
|
|
|
const streams = await xt.client.getLiveStreams();
|
|
const ch = streams.find((s) => s.stream_id === streamId);
|
|
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
|
|
|
const epg = await xt.client.getShortEpg(streamId, 8);
|
|
const { now: nowProgram, next: nextProgram } = parseNowNextPrograms(epg);
|
|
|
|
return {
|
|
streamId,
|
|
channelName: ch.name,
|
|
logoUrl: ch.stream_icon || null,
|
|
now: nowProgram,
|
|
next: nextProgram,
|
|
};
|
|
}
|
|
|
|
const EPG_NOW_CACHE_TTL_MS = 90_000;
|
|
const epgNowCache = new Map<string, { at: number; titles: Map<number, string> }>();
|
|
|
|
function epgCacheKey(tokenId: string, categoryId?: string): string {
|
|
return `${tokenId}:${categoryId?.trim() || "__all__"}`;
|
|
}
|
|
|
|
export function parseNowNextPrograms(epg: XtreamEpgListing[]): {
|
|
now: { title: string; start: string; end: string | null } | null;
|
|
next: { title: string; start: string; end: string | null } | null;
|
|
} {
|
|
const now = Date.now();
|
|
let nowProgram: { title: string; start: string; end: string | null } | null = null;
|
|
let nextProgram: { title: string; start: string; end: string | null } | null = null;
|
|
|
|
for (const listing of epg) {
|
|
const start = epgToIso(listing, "start");
|
|
const end = epgToIso(listing, "end");
|
|
if (!start) continue;
|
|
const title = decodeEpgText(listing.title) || "Programma";
|
|
const startMs = Date.parse(start);
|
|
const endMs = end ? Date.parse(end) : startMs + 30 * 60_000;
|
|
|
|
if (startMs <= now && now < endMs) {
|
|
nowProgram = { title, start, end: end ?? null };
|
|
} else if (startMs > now && !nextProgram) {
|
|
nextProgram = { title, start, end: end ?? null };
|
|
}
|
|
}
|
|
|
|
return { now: nowProgram, next: nextProgram };
|
|
}
|
|
|
|
async function getCachedCategoryNowTitles(
|
|
tokenId: string,
|
|
categoryId: string | undefined,
|
|
streams: XtreamLiveStream[],
|
|
client: XtreamClient
|
|
): Promise<Map<number, string>> {
|
|
const key = epgCacheKey(tokenId, categoryId);
|
|
const cached = epgNowCache.get(key);
|
|
if (cached && Date.now() - cached.at < EPG_NOW_CACHE_TTL_MS) {
|
|
return cached.titles;
|
|
}
|
|
|
|
const titles = await fetchNowTitlesParallel(
|
|
client,
|
|
streams.map((s) => s.stream_id),
|
|
14
|
|
);
|
|
epgNowCache.set(key, { at: Date.now(), titles });
|
|
return titles;
|
|
}
|
|
|
|
async function fetchNowTitlesParallel(
|
|
client: XtreamClient,
|
|
streamIds: number[],
|
|
concurrency: number
|
|
): Promise<Map<number, string>> {
|
|
const result = new Map<number, string>();
|
|
if (streamIds.length === 0) return result;
|
|
|
|
const queue = [...streamIds];
|
|
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
while (queue.length > 0) {
|
|
const streamId = queue.shift();
|
|
if (streamId == null) return;
|
|
try {
|
|
const epg = await client.getShortEpg(streamId, 4);
|
|
const { now } = parseNowNextPrograms(epg);
|
|
if (now?.title) result.set(streamId, now.title);
|
|
} catch {
|
|
// zender zonder EPG overslaan
|
|
}
|
|
}
|
|
});
|
|
|
|
await Promise.all(workers);
|
|
return result;
|
|
}
|