import { randomUUID } from "crypto"; import type { Config } from "../config"; import { prisma } from "../database/client"; import { loadXtreamByLineId } from "./service"; export type IptvWatchKind = "live" | "vod" | "series"; export type IptvActiveWatch = { id: string; iptvLineId: string; viewerUserId: string | null; addonTokenId: string | null; kind: IptvWatchKind; streamId: number; title: string; startedAt: Date; lastActivity: Date; }; /** Eén actieve IPTV-kijk per lijn (nieuwste zender wint). */ const byLineId = new Map(); /** Cache Xtream active_cons per lijn (admin pollt elke 5s). */ const consCache = new Map(); const CONS_CACHE_MS = 20_000; /** Altijd tonen na play, ook als XUI de connectie nog niet telt. */ const GRACE_MS = 3 * 60_000; /** Hard maximum zonder enige activiteit / connectie. */ const MAX_AGE_MS = 6 * 60 * 60_000; function viewerKey(viewerUserId: string | null, addonTokenId: string | null): string { return viewerUserId ?? addonTokenId ?? "anon"; } export function touchIptvWatch(input: { iptvLineId: string; viewerUserId?: string | null; addonTokenId?: string | null; kind: IptvWatchKind; streamId: number; title: string; }): IptvActiveWatch { const now = new Date(); const existing = byLineId.get(input.iptvLineId); const sameChannel = existing && existing.streamId === input.streamId && existing.kind === input.kind && viewerKey(existing.viewerUserId, existing.addonTokenId) === viewerKey(input.viewerUserId ?? null, input.addonTokenId ?? null); const watch: IptvActiveWatch = { id: sameChannel ? existing.id : `iptv-${randomUUID()}`, iptvLineId: input.iptvLineId, viewerUserId: input.viewerUserId ?? existing?.viewerUserId ?? null, addonTokenId: input.addonTokenId ?? existing?.addonTokenId ?? null, kind: input.kind, streamId: input.streamId, title: input.title.trim() || existing?.title || `IPTV #${input.streamId}`, startedAt: sameChannel ? existing.startedAt : now, lastActivity: now, }; byLineId.set(input.iptvLineId, watch); return watch; } export function refreshIptvWatch(opts: { iptvLineId: string; streamId?: number; title?: string; }): void { const w = byLineId.get(opts.iptvLineId); if (!w) return; if (opts.streamId != null && w.streamId !== opts.streamId) return; w.lastActivity = new Date(); if (opts.title?.trim()) w.title = opts.title.trim(); } async function activeConsForLine(lineId: string, config: Config): Promise { const cached = consCache.get(lineId); if (cached && Date.now() - cached.at < CONS_CACHE_MS) return cached.active; const xt = await loadXtreamByLineId(lineId, config); if (!xt) return null; try { const info = await xt.client.authenticate(); const n = parseInt(String(info.activeConnections ?? "0"), 10); const active = Number.isFinite(n) ? n : 0; consCache.set(lineId, { at: Date.now(), active }); return active; } catch { return null; } } /** * Actieve IPTV-kijks voor Admin Streams. * Na play: grace-periode. Daarna: aanhouden zolang Xtream active_cons > 0. */ export async function listActiveIptvWatches(config: Config): Promise { const now = Date.now(); const out: IptvActiveWatch[] = []; for (const [lineId, watch] of [...byLineId.entries()]) { const age = now - watch.lastActivity.getTime(); if (age > MAX_AGE_MS) { byLineId.delete(lineId); continue; } if (age <= GRACE_MS) { out.push(watch); continue; } const cons = await activeConsForLine(lineId, config); if (cons != null && cons > 0) { watch.lastActivity = new Date(); out.push(watch); continue; } if (cons === 0) { byLineId.delete(lineId); continue; } // Xtream onbereikbaar: nog even tonen if (age <= 15 * 60_000) out.push(watch); else byLineId.delete(lineId); } return out.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()); } /** Aantal watches in grace (dashboard-fallback zonder Xtream-check). */ export function countActiveIptvWatches(): number { const now = Date.now(); let n = 0; for (const w of byLineId.values()) { if (now - w.lastActivity.getTime() <= MAX_AGE_MS) n += 1; } return n; } /** Stremio-addon: registreer kijk na stream-resolve. */ export async function touchIptvWatchForToken( tokenId: string, kind: IptvWatchKind, streamId: number, title: string ): Promise { const token = await prisma.addonToken.findUnique({ where: { id: tokenId }, select: { id: true, viewerUserId: true, iptvLine: { select: { id: true } }, }, }); if (!token) return; let lineId = token.iptvLine?.id ?? null; if (!lineId && token.viewerUserId) { const line = await prisma.iptvLine.findUnique({ where: { viewerUserId: token.viewerUserId }, select: { id: true }, }); lineId = line?.id ?? null; } if (!lineId) return; touchIptvWatch({ iptvLineId: lineId, viewerUserId: token.viewerUserId, addonTokenId: token.id, kind, streamId, title, }); } /** App-client: registreer kijk na /play. */ export async function touchIptvWatchForViewer( viewerId: string, kind: IptvWatchKind, streamId: number, title: string ): Promise { const line = await prisma.iptvLine.findUnique({ where: { viewerUserId: viewerId }, select: { id: true }, }); if (!line) { // Legacy: lijn via iptvAddonTokenId const viewer = await prisma.viewerUser.findUnique({ where: { id: viewerId }, select: { iptvAddonTokenId: true }, }); if (!viewer?.iptvAddonTokenId) return; const legacy = await prisma.iptvLine.findUnique({ where: { addonTokenId: viewer.iptvAddonTokenId }, select: { id: true }, }); if (!legacy) return; touchIptvWatch({ iptvLineId: legacy.id, viewerUserId: viewerId, addonTokenId: viewer.iptvAddonTokenId, kind, streamId, title, }); return; } touchIptvWatch({ iptvLineId: line.id, viewerUserId: viewerId, kind, streamId, title, }); }