From a7a7c9dec425d259e6c1317b05d84a135ee67349 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 8 Sep 2026 13:52:48 +0200 Subject: [PATCH] Show IPTV/Live TV under admin active streams without app changes. Co-authored-by: Cursor --- apps/admin-ui/src/app/streams/page.tsx | 61 ++++-- apps/master-api/src/admin/routes.ts | 107 ++++++++-- apps/master-api/src/iptv/active-watches.ts | 222 +++++++++++++++++++++ apps/master-api/src/iptv/service.ts | 9 +- apps/master-api/src/stremio/routes.ts | 14 ++ apps/master-api/src/viewer/iptv-client.ts | 12 ++ 6 files changed, 386 insertions(+), 39 deletions(-) create mode 100644 apps/master-api/src/iptv/active-watches.ts diff --git a/apps/admin-ui/src/app/streams/page.tsx b/apps/admin-ui/src/app/streams/page.tsx index b3165bb..cf86e88 100644 --- a/apps/admin-ui/src/app/streams/page.tsx +++ b/apps/admin-ui/src/app/streams/page.tsx @@ -5,6 +5,7 @@ import { Nav, useAuth } from "@/components/Nav"; interface Stream { id: string; + source?: "library" | "iptv"; node: string; status: string; viewer: string; @@ -81,7 +82,8 @@ export default function StreamsPage() {

- Eén rij per kijker. Stopt iemand → verdwijnt binnen ~3 min. Voortgang ≈ bytes / bestand. + Bibliotheek én Live TV / IPTV. Bibliotheek verdwijnt binnen ~3 min na stoppen; IPTV + blijft zolang Xtream een actieve connectie ziet.

@@ -99,6 +101,7 @@ export default function StreamsPage() { {streams.map((s) => { + const isIptv = s.source === "iptv" || s.node === "IPTV"; const pct = s.progressPercent ?? 0; return ( @@ -107,23 +110,33 @@ export default function StreamsPage() {
{s.title}
-
{formatBytes(s.bytesSent)} gestreamd
+
+ {isIptv ? "Direct naar Xtream" : `${formatBytes(s.bytesSent)} gestreamd`} +
-
-
-
-
- {s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} -
+ {isIptv ? ( +
live
+ ) : ( + <> +
+
+
+
+ {s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} +
+ + )} {s.node} {s.resolution ?? "-"} {relativeTime(s.lastActivity)} - + {!isIptv && ( + + )} ); @@ -141,6 +154,7 @@ export default function StreamsPage() {
{streams.map((s) => { + const isIptv = s.source === "iptv" || s.node === "IPTV"; const pct = s.progressPercent ?? 0; return (
@@ -149,23 +163,28 @@ export default function StreamsPage() { {relativeTime(s.lastActivity)}
{s.title}
-
-
-
+ {!isIptv && ( +
+
+
+ )}
- {s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} ·{" "} - {formatBytes(s.bytesSent)} + {isIptv + ? "live · Xtream" + : `${s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} · ${formatBytes(s.bytesSent)}`} {s.resolution ?? "?"} · {s.node}
-
- -
+ {!isIptv && ( +
+ +
+ )} ); })} diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index fb42044..236a492 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -10,6 +10,7 @@ import { AppError } from "../security/errors"; import { nodeConnectionManager } from "../websocket/manager"; import { MetadataService } from "../metadata/service"; import type { Config } from "../config"; +import { listActiveIptvWatches } from "../iptv/active-watches"; export async function registerAdminRoutes(app: FastifyInstance, config: Config) { const metadata = new MetadataService(config.TMDB_API_KEY); @@ -38,15 +39,19 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) prisma.movie.count(), prisma.series.count(), prisma.episode.count(), - prisma.playbackSession.count({ - where: { - status: "ACTIVE", - revoked: false, - absoluteExpiresAt: { gt: new Date() }, - lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) }, - bytesSent: { gt: 512n * 1024n }, - }, - }), + (async () => { + const library = await prisma.playbackSession.count({ + where: { + status: "ACTIVE", + revoked: false, + absoluteExpiresAt: { gt: new Date() }, + lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) }, + bytesSent: { gt: 512n * 1024n }, + }, + }); + const iptv = await listActiveIptvWatches(config); + return library + iptv.length; + })(), prisma.node.findMany({ where: { revoked: false, status: "ONLINE" }, select: { @@ -291,8 +296,15 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) take: 100, }); + const iptvWatches = await listActiveIptvWatches(config); + // App-clients zetten ViewerUser.id in userId (zonder addonToken). - const viewerIds = [...new Set(sessions.map((s) => s.userId).filter((id): id is string => !!id))]; + const viewerIds = [ + ...new Set([ + ...sessions.map((s) => s.userId).filter((id): id is string => !!id), + ...iptvWatches.map((w) => w.viewerUserId).filter((id): id is string => !!id), + ]), + ]; const viewerUsers = viewerIds.length > 0 ? await prisma.viewerUser.findMany({ @@ -302,6 +314,22 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) : []; const viewerById = new Map(viewerUsers.map((v) => [v.id, v])); + const tokenIds = [ + ...new Set(iptvWatches.map((w) => w.addonTokenId).filter((id): id is string => !!id)), + ]; + const tokens = + tokenIds.length > 0 + ? await prisma.addonToken.findMany({ + where: { id: { in: tokenIds } }, + select: { + id: true, + label: true, + viewerUser: { select: { id: true, name: true, email: true } }, + }, + }) + : []; + const tokenById = new Map(tokens.map((t) => [t.id, t])); + function streamViewerLabel(s: (typeof sessions)[number]): string { const tokenLabel = s.addonToken?.label?.trim(); if (tokenLabel) return tokenLabel; @@ -316,6 +344,22 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) return "Onbekend"; } + function iptvViewerLabel(w: (typeof iptvWatches)[number]): string { + if (w.viewerUserId) { + const v = viewerById.get(w.viewerUserId); + if (v) return v.name?.trim() || v.email; + } + if (w.addonTokenId) { + const t = tokenById.get(w.addonTokenId); + const label = + t?.label?.trim() || + t?.viewerUser?.name?.trim() || + t?.viewerUser?.email?.trim(); + if (label) return label; + } + return "Onbekend"; + } + // Eén rij per kijker+titel (hoogste bytes wint) — niet per mediabestand. const best = new Map(); for (const s of sessions) { @@ -330,10 +374,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) if (!prev || s.bytesSent > prev.bytesSent) best.set(key, s); } - return { - streams: [...best.values()] - .sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()) - .map((s) => { + const libraryStreams = [...best.values()] + .sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime()) + .map((s) => { const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0); const sent = Number(s.bytesSent || 0); // bytesSent loopt netjes op; lastByteOffset springt vaak naar EOF door player Range-probes. @@ -347,6 +390,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) const year = s.mediaFile.movie?.year ?? null; return { id: s.id, + source: "library" as const, node: s.node.name, status: s.status, viewer: streamViewerLabel(s), @@ -361,12 +405,45 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) fileSizeBytes: (s.fileSizeBytes > 0n ? s.fileSizeBytes : s.mediaFile.sizeBytes).toString(), progressPercent, }; - }), + }); + + const iptvStreams = iptvWatches.map((w) => { + const kindLabel = w.kind === "live" ? "Live TV" : w.kind === "vod" ? "IPTV VOD" : "IPTV Series"; + return { + id: w.id, + source: "iptv" as const, + node: "IPTV", + status: "ACTIVE", + viewer: iptvViewerLabel(w), + addonTokenId: w.addonTokenId, + createdAt: w.startedAt, + lastActivity: w.lastActivity, + absoluteExpiresAt: new Date(w.lastActivity.getTime() + 60 * 60_000), + title: `${kindLabel} · ${w.title}`, + resolution: null as string | null, + bytesSent: "0", + lastByteOffset: "0", + fileSizeBytes: "0", + progressPercent: null as number | null, + }; + }); + + return { + streams: [...libraryStreams, ...iptvStreams].sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ), }; }); app.post("/api/v1/admin/streams/:id/revoke", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; + if (id.startsWith("iptv-")) { + throw new AppError( + "INVALID_REQUEST", + "IPTV-streams lopen direct naar Xtream en kunnen hier niet worden ingetrokken", + 400 + ); + } const session = await prisma.playbackSession.findUnique({ where: { id } }); if (!session) throw new AppError("NOT_FOUND", "Session not found", 404); diff --git a/apps/master-api/src/iptv/active-watches.ts b/apps/master-api/src/iptv/active-watches.ts new file mode 100644 index 0000000..19c4eac --- /dev/null +++ b/apps/master-api/src/iptv/active-watches.ts @@ -0,0 +1,222 @@ +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, + }); +} diff --git a/apps/master-api/src/iptv/service.ts b/apps/master-api/src/iptv/service.ts index 077bf7d..deebb1c 100644 --- a/apps/master-api/src/iptv/service.ts +++ b/apps/master-api/src/iptv/service.ts @@ -516,6 +516,9 @@ export async function getIptvStreams( // Real live HLS often hangs on XUI /auth/*.m3u8; MPEG-TS is reliable. if (type === "tv" && parsed.kind === "live") { const sid = parsed.streamId; + const liveList = await xt.client.getLiveStreams(); + const channel = liveList.find((s) => s.stream_id === sid); + const channelName = channel?.name?.trim() || `Live #${sid}`; const resolved = await xt.client.resolveLivePlayUrls(sid); const out: Array<{ name: string; @@ -533,7 +536,7 @@ export async function getIptvStreams( if (resolved.ts) { out.push({ name: "Live · MPEG-TS", - title: "XUI · direct ts", + title: channelName, url: resolved.ts, behaviorHints: hint(`live-${sid}.ts`), }); @@ -541,7 +544,7 @@ export async function getIptvStreams( if (resolved.hls && resolved.hls !== resolved.ts) { out.push({ name: "Live · HLS", - title: "XUI · m3u8 (kan hangen)", + title: channelName, url: resolved.hls, behaviorHints: hint(`live-${sid}.m3u8`), }); @@ -552,7 +555,7 @@ export async function getIptvStreams( const isTs = url.endsWith(".ts"); out.push({ name: isTs ? "Live · MPEG-TS" : "Live · HLS", - title: "classic Xtream", + title: channelName, url, behaviorHints: hint(isTs ? `live-${sid}.ts` : `live-${sid}.m3u8`), }); diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index dd657cc..a8ab64a 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -284,6 +284,20 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config { type, id, streamCount: streams.length, sampleHost: safeStreamHost(streams[0]?.url) }, "iptv streams resolved (direct to Xtream)" ); + const parsed = parseIptvId(id); + if (parsed && streams.length > 0) { + const kind = + parsed.kind === "live" ? "live" : parsed.kind === "vod" ? "vod" : "series"; + const title = + streams[0]?.title || + streams[0]?.name || + (parsed.kind === "live" ? `Live #${parsed.streamId}` : `IPTV #${parsed.streamId}`); + void import("../iptv/active-watches") + .then(({ touchIptvWatchForToken }) => + touchIptvWatchForToken(auth.tokenId, kind, parsed.streamId, title) + ) + .catch(() => undefined); + } return { streams }; } diff --git a/apps/master-api/src/viewer/iptv-client.ts b/apps/master-api/src/viewer/iptv-client.ts index c5e51a0..9e2612a 100644 --- a/apps/master-api/src/viewer/iptv-client.ts +++ b/apps/master-api/src/viewer/iptv-client.ts @@ -208,6 +208,12 @@ export async function getViewerIptvPlayUrl( const formatFor = (url: string): "hls" | "ts" => url.includes(".m3u8") || url.includes("m3u8") ? "hls" : "ts"; + void import("../iptv/active-watches") + .then(({ touchIptvWatchForViewer }) => + touchIptvWatchForViewer(viewerId, "live", streamId, ch.name) + ) + .catch(() => undefined); + return { streamId, name: ch.name, @@ -228,6 +234,12 @@ export async function getViewerIptvEpg(viewerId: string, config: Config, streamI const ch = streams.find((s) => s.stream_id === streamId); if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404); + void import("../iptv/active-watches") + .then(({ refreshIptvWatch }) => + refreshIptvWatch({ iptvLineId: xt.cacheKey, streamId, title: ch.name }) + ) + .catch(() => undefined); + const cached = getCachedStreamEpg(xt.cacheKey, streamId); if (cached && (cached.now || cached.next)) { return {