Show IPTV/Live TV under admin active streams without app changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 13:52:48 +02:00
parent 6a637ab2ef
commit a7a7c9dec4
6 changed files with 386 additions and 39 deletions

View file

@ -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() {
</div>
<p className="muted page-lead">
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.
</p>
<div className="desktop-table card">
@ -99,6 +101,7 @@ export default function StreamsPage() {
</thead>
<tbody>
{streams.map((s) => {
const isIptv = s.source === "iptv" || s.node === "IPTV";
const pct = s.progressPercent ?? 0;
return (
<tr key={s.id}>
@ -107,23 +110,33 @@ export default function StreamsPage() {
</td>
<td>
<div>{s.title}</div>
<div className="muted tiny">{formatBytes(s.bytesSent)} gestreamd</div>
<div className="muted tiny">
{isIptv ? "Direct naar Xtream" : `${formatBytes(s.bytesSent)} gestreamd`}
</div>
</td>
<td style={{ minWidth: 140 }}>
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div className="muted mono tiny">
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
</div>
{isIptv ? (
<div className="muted mono tiny">live</div>
) : (
<>
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div className="muted mono tiny">
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
</div>
</>
)}
</td>
<td>{s.node}</td>
<td>{s.resolution ?? "-"}</td>
<td className="mono tiny">{relativeTime(s.lastActivity)}</td>
<td>
<button type="button" className="danger" onClick={() => revoke(s.id)}>
Intrekken
</button>
{!isIptv && (
<button type="button" className="danger" onClick={() => revoke(s.id)}>
Intrekken
</button>
)}
</td>
</tr>
);
@ -141,6 +154,7 @@ export default function StreamsPage() {
<div className="list-cards">
{streams.map((s) => {
const isIptv = s.source === "iptv" || s.node === "IPTV";
const pct = s.progressPercent ?? 0;
return (
<article key={s.id} className="list-card stream-card">
@ -149,23 +163,28 @@ export default function StreamsPage() {
<span className="mono tiny">{relativeTime(s.lastActivity)}</span>
</div>
<div className="stream-title">{s.title}</div>
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
{!isIptv && (
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
)}
<div className="list-card-meta">
<span className="mono">
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} ·{" "}
{formatBytes(s.bytesSent)}
{isIptv
? "live · Xtream"
: `${s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} · ${formatBytes(s.bytesSent)}`}
</span>
<span>
{s.resolution ?? "?"} · {s.node}
</span>
</div>
<div className="action-row">
<button type="button" className="danger" onClick={() => revoke(s.id)}>
Intrekken
</button>
</div>
{!isIptv && (
<div className="action-row">
<button type="button" className="danger" onClick={() => revoke(s.id)}>
Intrekken
</button>
</div>
)}
</article>
);
})}

View file

@ -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<string, (typeof sessions)[number]>();
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);

View file

@ -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<string, IptvActiveWatch>();
/** Cache Xtream active_cons per lijn (admin pollt elke 5s). */
const consCache = new Map<string, { at: number; active: number }>();
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<number | null> {
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<IptvActiveWatch[]> {
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<void> {
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<void> {
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,
});
}

View file

@ -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`),
});

View file

@ -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 };
}

View file

@ -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 {