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 { 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(); 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 }>(); 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> { 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> { const result = new Map(); 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; }