stremio/apps/master-api/src/iptv/active-watches.ts
Jos Vooges | STH a7a7c9dec4 Show IPTV/Live TV under admin active streams without app changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 13:52:48 +02:00

222 lines
6.1 KiB
TypeScript

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