Show IPTV/Live TV under admin active streams without app changes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6a637ab2ef
commit
a7a7c9dec4
6 changed files with 386 additions and 39 deletions
|
|
@ -5,6 +5,7 @@ import { Nav, useAuth } from "@/components/Nav";
|
||||||
|
|
||||||
interface Stream {
|
interface Stream {
|
||||||
id: string;
|
id: string;
|
||||||
|
source?: "library" | "iptv";
|
||||||
node: string;
|
node: string;
|
||||||
status: string;
|
status: string;
|
||||||
viewer: string;
|
viewer: string;
|
||||||
|
|
@ -81,7 +82,8 @@ export default function StreamsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="muted page-lead">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="desktop-table card">
|
<div className="desktop-table card">
|
||||||
|
|
@ -99,6 +101,7 @@ export default function StreamsPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{streams.map((s) => {
|
{streams.map((s) => {
|
||||||
|
const isIptv = s.source === "iptv" || s.node === "IPTV";
|
||||||
const pct = s.progressPercent ?? 0;
|
const pct = s.progressPercent ?? 0;
|
||||||
return (
|
return (
|
||||||
<tr key={s.id}>
|
<tr key={s.id}>
|
||||||
|
|
@ -107,23 +110,33 @@ export default function StreamsPage() {
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div>{s.title}</div>
|
<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>
|
||||||
<td style={{ minWidth: 140 }}>
|
<td style={{ minWidth: 140 }}>
|
||||||
|
{isIptv ? (
|
||||||
|
<div className="muted mono tiny">live</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
|
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
|
||||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="muted mono tiny">
|
<div className="muted mono tiny">
|
||||||
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
|
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{s.node}</td>
|
<td>{s.node}</td>
|
||||||
<td>{s.resolution ?? "-"}</td>
|
<td>{s.resolution ?? "-"}</td>
|
||||||
<td className="mono tiny">{relativeTime(s.lastActivity)}</td>
|
<td className="mono tiny">{relativeTime(s.lastActivity)}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{!isIptv && (
|
||||||
<button type="button" className="danger" onClick={() => revoke(s.id)}>
|
<button type="button" className="danger" onClick={() => revoke(s.id)}>
|
||||||
Intrekken
|
Intrekken
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -141,6 +154,7 @@ export default function StreamsPage() {
|
||||||
|
|
||||||
<div className="list-cards">
|
<div className="list-cards">
|
||||||
{streams.map((s) => {
|
{streams.map((s) => {
|
||||||
|
const isIptv = s.source === "iptv" || s.node === "IPTV";
|
||||||
const pct = s.progressPercent ?? 0;
|
const pct = s.progressPercent ?? 0;
|
||||||
return (
|
return (
|
||||||
<article key={s.id} className="list-card stream-card">
|
<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>
|
<span className="mono tiny">{relativeTime(s.lastActivity)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="stream-title">{s.title}</div>
|
<div className="stream-title">{s.title}</div>
|
||||||
|
{!isIptv && (
|
||||||
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
|
<div className="progress-track" title={`${pct.toFixed(0)}%`}>
|
||||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="list-card-meta">
|
<div className="list-card-meta">
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} ·{" "}
|
{isIptv
|
||||||
{formatBytes(s.bytesSent)}
|
? "live · Xtream"
|
||||||
|
: `${s.progressPercent != null ? `${pct.toFixed(0)}%` : "—"} · ${formatBytes(s.bytesSent)}`}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{s.resolution ?? "?"} · {s.node}
|
{s.resolution ?? "?"} · {s.node}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!isIptv && (
|
||||||
<div className="action-row">
|
<div className="action-row">
|
||||||
<button type="button" className="danger" onClick={() => revoke(s.id)}>
|
<button type="button" className="danger" onClick={() => revoke(s.id)}>
|
||||||
Intrekken
|
Intrekken
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { AppError } from "../security/errors";
|
||||||
import { nodeConnectionManager } from "../websocket/manager";
|
import { nodeConnectionManager } from "../websocket/manager";
|
||||||
import { MetadataService } from "../metadata/service";
|
import { MetadataService } from "../metadata/service";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
|
import { listActiveIptvWatches } from "../iptv/active-watches";
|
||||||
|
|
||||||
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
||||||
const metadata = new MetadataService(config.TMDB_API_KEY);
|
const metadata = new MetadataService(config.TMDB_API_KEY);
|
||||||
|
|
@ -38,7 +39,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
prisma.movie.count(),
|
prisma.movie.count(),
|
||||||
prisma.series.count(),
|
prisma.series.count(),
|
||||||
prisma.episode.count(),
|
prisma.episode.count(),
|
||||||
prisma.playbackSession.count({
|
(async () => {
|
||||||
|
const library = await prisma.playbackSession.count({
|
||||||
where: {
|
where: {
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
revoked: false,
|
revoked: false,
|
||||||
|
|
@ -46,7 +48,10 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
|
lastActivity: { gt: new Date(Date.now() - 3 * 60 * 1000) },
|
||||||
bytesSent: { gt: 512n * 1024n },
|
bytesSent: { gt: 512n * 1024n },
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
const iptv = await listActiveIptvWatches(config);
|
||||||
|
return library + iptv.length;
|
||||||
|
})(),
|
||||||
prisma.node.findMany({
|
prisma.node.findMany({
|
||||||
where: { revoked: false, status: "ONLINE" },
|
where: { revoked: false, status: "ONLINE" },
|
||||||
select: {
|
select: {
|
||||||
|
|
@ -291,8 +296,15 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const iptvWatches = await listActiveIptvWatches(config);
|
||||||
|
|
||||||
// App-clients zetten ViewerUser.id in userId (zonder addonToken).
|
// 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 =
|
const viewerUsers =
|
||||||
viewerIds.length > 0
|
viewerIds.length > 0
|
||||||
? await prisma.viewerUser.findMany({
|
? 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 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 {
|
function streamViewerLabel(s: (typeof sessions)[number]): string {
|
||||||
const tokenLabel = s.addonToken?.label?.trim();
|
const tokenLabel = s.addonToken?.label?.trim();
|
||||||
if (tokenLabel) return tokenLabel;
|
if (tokenLabel) return tokenLabel;
|
||||||
|
|
@ -316,6 +344,22 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
return "Onbekend";
|
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.
|
// Eén rij per kijker+titel (hoogste bytes wint) — niet per mediabestand.
|
||||||
const best = new Map<string, (typeof sessions)[number]>();
|
const best = new Map<string, (typeof sessions)[number]>();
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
|
|
@ -330,8 +374,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
if (!prev || s.bytesSent > prev.bytesSent) best.set(key, s);
|
if (!prev || s.bytesSent > prev.bytesSent) best.set(key, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const libraryStreams = [...best.values()]
|
||||||
streams: [...best.values()]
|
|
||||||
.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime())
|
.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime())
|
||||||
.map((s) => {
|
.map((s) => {
|
||||||
const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0);
|
const fileSize = Number(s.fileSizeBytes || s.mediaFile.sizeBytes || 0);
|
||||||
|
|
@ -347,6 +390,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
|
||||||
const year = s.mediaFile.movie?.year ?? null;
|
const year = s.mediaFile.movie?.year ?? null;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
source: "library" as const,
|
||||||
node: s.node.name,
|
node: s.node.name,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
viewer: streamViewerLabel(s),
|
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(),
|
fileSizeBytes: (s.fileSizeBytes > 0n ? s.fileSizeBytes : s.mediaFile.sizeBytes).toString(),
|
||||||
progressPercent,
|
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) => {
|
app.post("/api/v1/admin/streams/:id/revoke", { preHandler: requireAdmin }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
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 } });
|
const session = await prisma.playbackSession.findUnique({ where: { id } });
|
||||||
if (!session) throw new AppError("NOT_FOUND", "Session not found", 404);
|
if (!session) throw new AppError("NOT_FOUND", "Session not found", 404);
|
||||||
|
|
||||||
|
|
|
||||||
222
apps/master-api/src/iptv/active-watches.ts
Normal file
222
apps/master-api/src/iptv/active-watches.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -516,6 +516,9 @@ export async function getIptvStreams(
|
||||||
// Real live HLS often hangs on XUI /auth/*.m3u8; MPEG-TS is reliable.
|
// Real live HLS often hangs on XUI /auth/*.m3u8; MPEG-TS is reliable.
|
||||||
if (type === "tv" && parsed.kind === "live") {
|
if (type === "tv" && parsed.kind === "live") {
|
||||||
const sid = parsed.streamId;
|
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 resolved = await xt.client.resolveLivePlayUrls(sid);
|
||||||
const out: Array<{
|
const out: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -533,7 +536,7 @@ export async function getIptvStreams(
|
||||||
if (resolved.ts) {
|
if (resolved.ts) {
|
||||||
out.push({
|
out.push({
|
||||||
name: "Live · MPEG-TS",
|
name: "Live · MPEG-TS",
|
||||||
title: "XUI · direct ts",
|
title: channelName,
|
||||||
url: resolved.ts,
|
url: resolved.ts,
|
||||||
behaviorHints: hint(`live-${sid}.ts`),
|
behaviorHints: hint(`live-${sid}.ts`),
|
||||||
});
|
});
|
||||||
|
|
@ -541,7 +544,7 @@ export async function getIptvStreams(
|
||||||
if (resolved.hls && resolved.hls !== resolved.ts) {
|
if (resolved.hls && resolved.hls !== resolved.ts) {
|
||||||
out.push({
|
out.push({
|
||||||
name: "Live · HLS",
|
name: "Live · HLS",
|
||||||
title: "XUI · m3u8 (kan hangen)",
|
title: channelName,
|
||||||
url: resolved.hls,
|
url: resolved.hls,
|
||||||
behaviorHints: hint(`live-${sid}.m3u8`),
|
behaviorHints: hint(`live-${sid}.m3u8`),
|
||||||
});
|
});
|
||||||
|
|
@ -552,7 +555,7 @@ export async function getIptvStreams(
|
||||||
const isTs = url.endsWith(".ts");
|
const isTs = url.endsWith(".ts");
|
||||||
out.push({
|
out.push({
|
||||||
name: isTs ? "Live · MPEG-TS" : "Live · HLS",
|
name: isTs ? "Live · MPEG-TS" : "Live · HLS",
|
||||||
title: "classic Xtream",
|
title: channelName,
|
||||||
url,
|
url,
|
||||||
behaviorHints: hint(isTs ? `live-${sid}.ts` : `live-${sid}.m3u8`),
|
behaviorHints: hint(isTs ? `live-${sid}.ts` : `live-${sid}.m3u8`),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,20 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
|
||||||
{ type, id, streamCount: streams.length, sampleHost: safeStreamHost(streams[0]?.url) },
|
{ type, id, streamCount: streams.length, sampleHost: safeStreamHost(streams[0]?.url) },
|
||||||
"iptv streams resolved (direct to Xtream)"
|
"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 };
|
return { streams };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,12 @@ export async function getViewerIptvPlayUrl(
|
||||||
const formatFor = (url: string): "hls" | "ts" =>
|
const formatFor = (url: string): "hls" | "ts" =>
|
||||||
url.includes(".m3u8") || url.includes("m3u8") ? "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 {
|
return {
|
||||||
streamId,
|
streamId,
|
||||||
name: ch.name,
|
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);
|
const ch = streams.find((s) => s.stream_id === streamId);
|
||||||
if (!ch) throw new AppError("NOT_FOUND", "Zender niet gevonden", 404);
|
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);
|
const cached = getCachedStreamEpg(xt.cacheKey, streamId);
|
||||||
if (cached && (cached.now || cached.next)) {
|
if (cached && (cached.now || cached.next)) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue