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)}
- revoke(s.id)}>
- Intrekken
-
+ {!isIptv && (
+ revoke(s.id)}>
+ Intrekken
+
+ )}
);
@@ -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}
-
- revoke(s.id)}>
- Intrekken
-
-
+ {!isIptv && (
+
+ revoke(s.id)}>
+ Intrekken
+
+
+ )}
);
})}
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 {