1761 lines
60 KiB
TypeScript
1761 lines
60 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { prisma } from "../database/client";
|
|
import { requireAdmin } from "../auth/routes";
|
|
import {
|
|
generateApiKey,
|
|
generateSecureToken,
|
|
hashToken,
|
|
} from "../security/crypto";
|
|
import { AppError } from "../security/errors";
|
|
import { nodeConnectionManager } from "../websocket/manager";
|
|
import { createMetadataService } from "../settings/metadata-factory";
|
|
import type { Config } from "../config";
|
|
import { listActiveIptvWatches } from "../iptv/active-watches";
|
|
import {
|
|
isClientWatchId,
|
|
listActiveClientWatches,
|
|
revokeClientWatch,
|
|
} from "../viewer/client-watches";
|
|
import {
|
|
parseMovieFromPath,
|
|
parseSeriesFromPath,
|
|
seriesFolderFromPath,
|
|
libraryFolderFromPath,
|
|
} from "../metadata/filename-parser";
|
|
|
|
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
|
|
const metadata = createMetadataService(config);
|
|
|
|
app.get("/api/v1/admin/dashboard", { preHandler: requireAdmin }, async () => {
|
|
const [
|
|
nodesOnline,
|
|
nodesOffline,
|
|
movieCount,
|
|
seriesCount,
|
|
episodeCount,
|
|
activeStreams,
|
|
nodes,
|
|
availableMovies,
|
|
availableSeries,
|
|
mediaFileCount,
|
|
availableFileCount,
|
|
moviesMissingImdb,
|
|
moviesWithImdbStreams,
|
|
moviesMissingTmdb,
|
|
seriesMissingImdb,
|
|
seriesWithImdb,
|
|
] = await Promise.all([
|
|
prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
|
|
prisma.node.count({ where: { status: "OFFLINE", revoked: false } }),
|
|
prisma.movie.count(),
|
|
prisma.series.count(),
|
|
prisma.episode.count(),
|
|
(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);
|
|
const client = listActiveClientWatches();
|
|
return library + iptv.length + client.length;
|
|
})(),
|
|
prisma.node.findMany({
|
|
where: { revoked: false, status: "ONLINE" },
|
|
select: {
|
|
id: true,
|
|
locationId: true,
|
|
totalStorage: true,
|
|
freeStorage: true,
|
|
currentBandwidth: true,
|
|
libraryFileCount: true,
|
|
},
|
|
}),
|
|
prisma.movie.count({ where: { mediaFiles: { some: { available: true } } } }),
|
|
prisma.series.count({
|
|
where: {
|
|
episodes: { some: { mediaFiles: { some: { available: true } } } },
|
|
},
|
|
}),
|
|
prisma.mediaFile.count(),
|
|
prisma.mediaFile.count({ where: { available: true } }),
|
|
prisma.movie.count({
|
|
where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
|
|
}),
|
|
prisma.movie.count({
|
|
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
|
|
}),
|
|
prisma.movie.count({
|
|
where: { tmdbId: null, mediaFiles: { some: { available: true } } },
|
|
}),
|
|
prisma.series.count({
|
|
where: { imdbId: null, tmdbId: { not: null } },
|
|
}),
|
|
prisma.series.count({
|
|
where: { imdbId: { not: null } },
|
|
}),
|
|
]);
|
|
|
|
// Per locatie één keer tellen (nodes op dezelfde NAS rapporteren hetzelfde volume).
|
|
const storageByKey = new Map<string, { total: bigint; free: bigint }>();
|
|
for (const n of nodes) {
|
|
const key = n.locationId ?? n.id;
|
|
const cur = storageByKey.get(key);
|
|
if (!cur || n.totalStorage > cur.total) {
|
|
storageByKey.set(key, { total: n.totalStorage, free: n.freeStorage });
|
|
}
|
|
}
|
|
let totalStorage = 0n;
|
|
let freeStorage = 0n;
|
|
for (const s of storageByKey.values()) {
|
|
totalStorage += s.total;
|
|
freeStorage += s.free;
|
|
}
|
|
const bandwidth = nodes.reduce((sum, n) => sum + n.currentBandwidth, 0n);
|
|
const scannedFiles = nodes.reduce((sum, n) => sum + n.libraryFileCount, 0);
|
|
|
|
return {
|
|
nodesOnline,
|
|
nodesOffline,
|
|
totalStorage: totalStorage.toString(),
|
|
freeStorage: freeStorage.toString(),
|
|
movies: movieCount,
|
|
series: seriesCount,
|
|
episodes: episodeCount,
|
|
availableMovies,
|
|
availableSeries,
|
|
mediaFiles: mediaFileCount,
|
|
availableFiles: availableFileCount,
|
|
moviesMissingImdb,
|
|
moviesWithImdbStreams,
|
|
moviesMissingTmdb,
|
|
seriesMissingImdb,
|
|
seriesWithImdb,
|
|
scannedFiles,
|
|
activeStreams,
|
|
currentBandwidth: bandwidth.toString(),
|
|
};
|
|
});
|
|
|
|
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as {
|
|
search?: string;
|
|
nodeId?: string;
|
|
path?: string;
|
|
limit?: string;
|
|
};
|
|
const nodeId = query.nodeId?.trim() || undefined;
|
|
const pathQ = query.path?.trim() || undefined;
|
|
const searchQ = query.search?.trim() || undefined;
|
|
const hasQuery =
|
|
(searchQ != null && searchQ.length >= 2) || (pathQ != null && pathQ.length >= 2);
|
|
if (!hasQuery) {
|
|
return { movies: [], total: 0, requiresQuery: true };
|
|
}
|
|
|
|
const limitRaw = Number.parseInt(query.limit ?? "50", 10);
|
|
const take = Number.isFinite(limitRaw)
|
|
? Math.min(Math.max(limitRaw, 1), 100)
|
|
: 50;
|
|
|
|
const movies = await prisma.movie.findMany({
|
|
where: {
|
|
AND: [
|
|
searchQ
|
|
? {
|
|
OR: [
|
|
{ title: { contains: searchQ, mode: "insensitive" } },
|
|
{ originalTitle: { contains: searchQ, mode: "insensitive" } },
|
|
{ imdbId: { contains: searchQ, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {},
|
|
nodeId || pathQ
|
|
? {
|
|
mediaFiles: {
|
|
some: {
|
|
...(nodeId ? { nodeId } : {}),
|
|
...(pathQ ? { path: { contains: pathQ, mode: "insensitive" } } : {}),
|
|
},
|
|
},
|
|
}
|
|
: {},
|
|
],
|
|
},
|
|
orderBy: { title: "asc" },
|
|
include: {
|
|
mediaFiles: {
|
|
include: { node: { select: { id: true, name: true, status: true } } },
|
|
},
|
|
},
|
|
take,
|
|
});
|
|
|
|
return {
|
|
movies: movies.map((m) => {
|
|
const files = m.mediaFiles.filter((f) => {
|
|
if (nodeId && f.nodeId !== nodeId) return false;
|
|
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
|
|
const nodeMap = new Map<
|
|
string,
|
|
{ id: string; name: string; fileCount: number; folders: Map<string, number> }
|
|
>();
|
|
for (const f of m.mediaFiles) {
|
|
let cur = nodeMap.get(f.nodeId);
|
|
if (!cur) {
|
|
cur = { id: f.nodeId, name: f.node.name, fileCount: 0, folders: new Map() };
|
|
nodeMap.set(f.nodeId, cur);
|
|
}
|
|
cur.fileCount += 1;
|
|
const folder = f.path ? libraryFolderFromPath(f.path) : null;
|
|
if (folder) cur.folders.set(folder, (cur.folders.get(folder) ?? 0) + 1);
|
|
}
|
|
|
|
return {
|
|
id: m.id,
|
|
title: m.title,
|
|
year: m.year,
|
|
imdbId: m.imdbId,
|
|
tmdbId: m.tmdbId,
|
|
overview: m.overview,
|
|
posterUrl: m.posterUrl,
|
|
fileCount: files.length,
|
|
nodeCount: nodeMap.size,
|
|
qualities: [...new Set(files.map((f) => f.resolution).filter(Boolean))],
|
|
nodes: [...nodeMap.values()]
|
|
.map((n) => ({
|
|
id: n.id,
|
|
name: n.name,
|
|
fileCount: n.fileCount,
|
|
folders: [...n.folders.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([folder, fileCount]) => ({ folder, fileCount })),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
files: files.map((f) => ({
|
|
id: f.id,
|
|
nodeId: f.nodeId,
|
|
node: f.node.name,
|
|
nodeStatus: f.node.status,
|
|
resolution: f.resolution,
|
|
sizeBytes: f.sizeBytes.toString(),
|
|
videoCodec: f.videoCodec,
|
|
audioCodec: f.audioCodec,
|
|
hdrType: f.hdrType,
|
|
releaseName: f.releaseName,
|
|
path: f.path,
|
|
folder: f.path ? libraryFolderFromPath(f.path) : null,
|
|
available: f.available,
|
|
looksLikeSeries: f.path ? Boolean(parseSeriesFromPath(f.path)) : false,
|
|
})),
|
|
};
|
|
}),
|
|
total: movies.length,
|
|
limit: take,
|
|
requiresQuery: false,
|
|
};
|
|
});
|
|
|
|
app.get("/api/v1/admin/movies/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const movie = await prisma.movie.findUnique({
|
|
where: { id },
|
|
include: {
|
|
mediaFiles: { include: { node: true } },
|
|
},
|
|
});
|
|
if (!movie) throw new AppError("NOT_FOUND", "Movie not found", 404);
|
|
return { movie };
|
|
});
|
|
|
|
app.get("/api/v1/admin/series", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as {
|
|
search?: string;
|
|
nodeId?: string;
|
|
path?: string;
|
|
limit?: string;
|
|
};
|
|
const nodeId = query.nodeId?.trim() || undefined;
|
|
const pathQ = query.path?.trim() || undefined;
|
|
const searchQ = query.search?.trim() || undefined;
|
|
const hasQuery =
|
|
(searchQ != null && searchQ.length >= 2) || (pathQ != null && pathQ.length >= 2);
|
|
if (!hasQuery) {
|
|
return { series: [], total: 0, requiresQuery: true };
|
|
}
|
|
|
|
const limitRaw = Number.parseInt(query.limit ?? "50", 10);
|
|
const take = Number.isFinite(limitRaw)
|
|
? Math.min(Math.max(limitRaw, 1), 100)
|
|
: 50;
|
|
|
|
const series = await prisma.series.findMany({
|
|
where: {
|
|
AND: [
|
|
searchQ
|
|
? {
|
|
OR: [
|
|
{ title: { contains: searchQ, mode: "insensitive" } },
|
|
{ imdbId: { contains: searchQ, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {},
|
|
nodeId || pathQ
|
|
? {
|
|
seasons: {
|
|
some: {
|
|
episodes: {
|
|
some: {
|
|
mediaFiles: {
|
|
some: {
|
|
...(nodeId ? { nodeId } : {}),
|
|
...(pathQ
|
|
? { path: { contains: pathQ, mode: "insensitive" } }
|
|
: {}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
: {},
|
|
],
|
|
},
|
|
orderBy: { title: "asc" },
|
|
include: {
|
|
seasons: {
|
|
include: {
|
|
episodes: {
|
|
include: {
|
|
mediaFiles: {
|
|
include: {
|
|
node: { select: { id: true, name: true, status: true } },
|
|
},
|
|
},
|
|
},
|
|
orderBy: { episodeNumber: "asc" },
|
|
},
|
|
},
|
|
orderBy: { seasonNumber: "asc" },
|
|
},
|
|
},
|
|
take,
|
|
});
|
|
|
|
return {
|
|
series: series.map((s) => {
|
|
const allFiles = s.seasons.flatMap((season) =>
|
|
season.episodes.flatMap((ep) => ep.mediaFiles)
|
|
);
|
|
const scopedFiles = allFiles.filter((f) => {
|
|
if (nodeId && f.nodeId !== nodeId) return false;
|
|
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
|
|
const nodeMap = new Map<
|
|
string,
|
|
{ id: string; name: string; fileCount: number; folders: Map<string, number> }
|
|
>();
|
|
for (const f of allFiles) {
|
|
let cur = nodeMap.get(f.nodeId);
|
|
if (!cur) {
|
|
cur = {
|
|
id: f.nodeId,
|
|
name: f.node.name,
|
|
fileCount: 0,
|
|
folders: new Map(),
|
|
};
|
|
nodeMap.set(f.nodeId, cur);
|
|
}
|
|
cur.fileCount += 1;
|
|
const folder = f.path ? seriesFolderFromPath(f.path) : null;
|
|
if (folder) cur.folders.set(folder, (cur.folders.get(folder) ?? 0) + 1);
|
|
}
|
|
|
|
const episodesWithFiles = s.seasons.reduce((n, season) => {
|
|
return (
|
|
n +
|
|
season.episodes.filter((ep) => {
|
|
const files = ep.mediaFiles.filter((f) => {
|
|
if (nodeId && f.nodeId !== nodeId) return false;
|
|
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
return files.length > 0;
|
|
}).length
|
|
);
|
|
}, 0);
|
|
|
|
return {
|
|
id: s.id,
|
|
title: s.title,
|
|
year: s.year,
|
|
imdbId: s.imdbId,
|
|
tmdbId: s.tmdbId,
|
|
posterUrl: s.posterUrl,
|
|
episodesWithFiles,
|
|
fileCount: scopedFiles.length,
|
|
nodes: [...nodeMap.values()]
|
|
.map((n) => ({
|
|
id: n.id,
|
|
name: n.name,
|
|
fileCount: n.fileCount,
|
|
folders: [...n.folders.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([folder, fileCount]) => ({ folder, fileCount })),
|
|
}))
|
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
seasons: s.seasons.map((season) => ({
|
|
seasonNumber: season.seasonNumber,
|
|
episodes: season.episodes
|
|
.map((ep) => {
|
|
const files = ep.mediaFiles
|
|
.filter((f) => {
|
|
if (nodeId && f.nodeId !== nodeId) return false;
|
|
if (pathQ && !(f.path ?? "").toLowerCase().includes(pathQ.toLowerCase())) {
|
|
return false;
|
|
}
|
|
return true;
|
|
})
|
|
.map((f) => ({
|
|
id: f.id,
|
|
nodeId: f.nodeId,
|
|
node: f.node.name,
|
|
nodeStatus: f.node.status,
|
|
resolution: f.resolution,
|
|
available: f.available,
|
|
releaseName: f.releaseName,
|
|
path: f.path,
|
|
folder: f.path ? seriesFolderFromPath(f.path) : null,
|
|
}));
|
|
return {
|
|
id: ep.id,
|
|
seasonNumber: ep.seasonNumber,
|
|
episodeNumber: ep.episodeNumber,
|
|
title: ep.title,
|
|
files,
|
|
};
|
|
})
|
|
.filter((ep) => (!nodeId && !pathQ) || ep.files.length > 0),
|
|
})).filter((season) => (!nodeId && !pathQ) || season.episodes.length > 0),
|
|
};
|
|
}),
|
|
total: series.length,
|
|
limit: take,
|
|
requiresQuery: false,
|
|
};
|
|
});
|
|
|
|
app.get("/api/v1/admin/streams", { preHandler: requireAdmin }, async () => {
|
|
const now = new Date();
|
|
await prisma.playbackSession.updateMany({
|
|
where: {
|
|
status: "ACTIVE",
|
|
revoked: false,
|
|
absoluteExpiresAt: { lt: now },
|
|
},
|
|
data: { status: "EXPIRED" },
|
|
});
|
|
|
|
// Alleen echte kijkers: >512KB gestreamd + recent actief.
|
|
const liveSince = new Date(now.getTime() - 3 * 60 * 1000);
|
|
const minBytes = 512n * 1024n;
|
|
const sessions = await prisma.playbackSession.findMany({
|
|
where: {
|
|
status: "ACTIVE",
|
|
revoked: false,
|
|
absoluteExpiresAt: { gt: now },
|
|
lastActivity: { gt: liveSince },
|
|
bytesSent: { gt: minBytes },
|
|
},
|
|
include: {
|
|
node: { select: { id: true, name: true } },
|
|
addonToken: {
|
|
select: {
|
|
id: true,
|
|
label: true,
|
|
viewerUser: { select: { id: true, name: true, email: true } },
|
|
},
|
|
},
|
|
mediaFile: {
|
|
include: {
|
|
movie: { select: { title: true, year: true } },
|
|
episode: {
|
|
include: { series: { select: { title: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: { lastActivity: "desc" },
|
|
take: 100,
|
|
});
|
|
|
|
const iptvWatches = await listActiveIptvWatches(config);
|
|
const clientWatches = listActiveClientWatches();
|
|
|
|
// App-clients zetten ViewerUser.id in userId (zonder addonToken).
|
|
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),
|
|
...clientWatches.map((w) => w.viewerUserId),
|
|
]),
|
|
];
|
|
const viewerUsers =
|
|
viewerIds.length > 0
|
|
? await prisma.viewerUser.findMany({
|
|
where: { id: { in: viewerIds } },
|
|
select: { id: true, name: true, email: true },
|
|
})
|
|
: [];
|
|
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;
|
|
const linked =
|
|
s.addonToken?.viewerUser?.name?.trim() ||
|
|
s.addonToken?.viewerUser?.email?.trim();
|
|
if (linked) return linked;
|
|
if (s.userId) {
|
|
const v = viewerById.get(s.userId);
|
|
if (v) return v.name?.trim() || v.email;
|
|
}
|
|
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";
|
|
}
|
|
|
|
function clientViewerLabel(viewerUserId: string): string {
|
|
const v = viewerById.get(viewerUserId);
|
|
if (v) return v.name?.trim() || v.email;
|
|
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) {
|
|
const titleKey =
|
|
s.mediaFile.movieId ??
|
|
(s.mediaFile.episode
|
|
? `ep:${s.mediaFile.episode.seriesId ?? ""}:${s.mediaFile.episode.seasonNumber}:${s.mediaFile.episode.episodeNumber}`
|
|
: s.mediaFileId);
|
|
const viewerKey = s.addonTokenId ?? s.userId ?? s.id;
|
|
const key = `${viewerKey}:${titleKey}`;
|
|
const prev = best.get(key);
|
|
if (!prev || s.bytesSent > prev.bytesSent) best.set(key, 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.
|
|
const progressPercent =
|
|
fileSize > 0 ? Math.min(100, Math.max(0, (sent / fileSize) * 100)) : null;
|
|
const title =
|
|
s.mediaFile.movie?.title ??
|
|
(s.mediaFile.episode
|
|
? `${s.mediaFile.episode.series?.title} S${String(s.mediaFile.episode.seasonNumber).padStart(2, "0")}E${String(s.mediaFile.episode.episodeNumber).padStart(2, "0")}`
|
|
: s.mediaFile.releaseName);
|
|
const year = s.mediaFile.movie?.year ?? null;
|
|
return {
|
|
id: s.id,
|
|
source: "library" as const,
|
|
kind: "library" as const,
|
|
node: s.node.name,
|
|
status: s.status,
|
|
viewer: streamViewerLabel(s),
|
|
addonTokenId: s.addonToken?.id ?? null,
|
|
createdAt: s.createdAt,
|
|
lastActivity: s.lastActivity,
|
|
absoluteExpiresAt: s.absoluteExpiresAt,
|
|
title: year ? `${title} (${year})` : title,
|
|
resolution: s.mediaFile.resolution,
|
|
bytesSent: s.bytesSent.toString(),
|
|
lastByteOffset: s.lastByteOffset.toString(),
|
|
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,
|
|
kind: w.kind,
|
|
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,
|
|
};
|
|
});
|
|
|
|
const clientStreams = clientWatches.map((w) => {
|
|
const isEvent = w.kind === "event";
|
|
const titlePrefix = isEvent ? "Event" : "Live";
|
|
const node = w.provider?.trim() || (isEvent ? "Event" : "Live");
|
|
return {
|
|
id: w.id,
|
|
source: "client" as const,
|
|
kind: w.kind,
|
|
node,
|
|
status: "ACTIVE",
|
|
viewer: clientViewerLabel(w.viewerUserId),
|
|
addonTokenId: null as string | null,
|
|
createdAt: w.startedAt,
|
|
lastActivity: w.lastActivity,
|
|
absoluteExpiresAt: new Date(w.lastActivity.getTime() + 90_000),
|
|
title: `${titlePrefix} · ${w.title}`,
|
|
resolution: null as string | null,
|
|
bytesSent: "0",
|
|
lastByteOffset: "0",
|
|
fileSizeBytes: "0",
|
|
progressPercent: null as number | null,
|
|
};
|
|
});
|
|
|
|
return {
|
|
streams: [...libraryStreams, ...iptvStreams, ...clientStreams].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 (isClientWatchId(id)) {
|
|
// Direct play (geen proxy): presence wissen stopt de stream op het apparaat niet.
|
|
const cleared = revokeClientWatch(id);
|
|
if (!cleared) throw new AppError("NOT_FOUND", "Session not found", 404);
|
|
return {
|
|
ok: true,
|
|
presenceCleared: true,
|
|
streamStopped: false,
|
|
note: "Presence gewist; de stream op het apparaat stopt niet (direct play).",
|
|
};
|
|
}
|
|
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);
|
|
|
|
await prisma.playbackSession.update({
|
|
where: { id },
|
|
data: { status: "REVOKED", revoked: true },
|
|
});
|
|
nodeConnectionManager.revokePlaybackSession(session.nodeId, id);
|
|
return { ok: true };
|
|
});
|
|
|
|
app.get("/api/v1/admin/locations", { preHandler: requireAdmin }, async () => {
|
|
const locations = await prisma.location.findMany({
|
|
include: { nodes: { select: { id: true, name: true, status: true } } },
|
|
orderBy: { priority: "desc" },
|
|
});
|
|
return { locations };
|
|
});
|
|
|
|
app.post("/api/v1/admin/locations", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as { name?: string; description?: string; priority?: number };
|
|
if (!body.name) throw new AppError("INVALID_REQUEST", "name required");
|
|
const location = await prisma.location.create({
|
|
data: {
|
|
name: body.name,
|
|
description: body.description,
|
|
priority: body.priority ?? 0,
|
|
},
|
|
});
|
|
return { location };
|
|
});
|
|
|
|
app.post("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const body = request.body as { label?: string; viewerUserId?: string };
|
|
const token = generateSecureToken(24);
|
|
|
|
if (body.viewerUserId) {
|
|
const viewer = await prisma.viewerUser.findUnique({ where: { id: body.viewerUserId } });
|
|
if (!viewer) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
|
}
|
|
|
|
const created = await prisma.addonToken.create({
|
|
data: {
|
|
userId: user.id,
|
|
viewerUserId: body.viewerUserId?.trim() || null,
|
|
tokenHash: hashToken(token),
|
|
label: body.label,
|
|
},
|
|
});
|
|
|
|
return {
|
|
token,
|
|
id: created.id,
|
|
label: created.label,
|
|
viewerUserId: created.viewerUserId,
|
|
installUrl: `/stremio/${token}/manifest.json`,
|
|
};
|
|
});
|
|
|
|
app.get("/api/v1/admin/addon-tokens", { preHandler: requireAdmin }, async (request) => {
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const tokens = await prisma.addonToken.findMany({
|
|
where: { userId: user.id },
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
iptvLine: { select: { id: true, enableLive: true, lastOkAt: true, lastError: true } },
|
|
viewerUser: { select: { id: true, email: true, name: true } },
|
|
},
|
|
});
|
|
return {
|
|
tokens: tokens.map((t) => ({
|
|
id: t.id,
|
|
label: t.label,
|
|
revoked: t.revoked,
|
|
createdAt: t.createdAt,
|
|
viewerUserId: t.viewerUserId,
|
|
viewerLabel: t.viewerUser?.name || t.viewerUser?.email || null,
|
|
hasIptv: !!t.iptvLine,
|
|
iptvOk: t.iptvLine ? !t.iptvLine.lastError && !!t.iptvLine.lastOkAt : false,
|
|
})),
|
|
};
|
|
});
|
|
|
|
app.post("/api/v1/admin/addon-tokens/:id/revoke", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const updated = await prisma.addonToken.updateMany({
|
|
where: { id, userId: user.id },
|
|
data: { revoked: true },
|
|
});
|
|
if (updated.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
return { ok: true };
|
|
});
|
|
|
|
app.post("/api/v1/admin/addon-tokens/:id/activate", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const updated = await prisma.addonToken.updateMany({
|
|
where: { id, userId: user.id },
|
|
data: { revoked: false },
|
|
});
|
|
if (updated.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
return { ok: true };
|
|
});
|
|
|
|
app.delete("/api/v1/admin/addon-tokens/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const deleted = await prisma.addonToken.deleteMany({
|
|
where: { id, userId: user.id },
|
|
});
|
|
if (deleted.count === 0) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
return { ok: true };
|
|
});
|
|
|
|
// Manual library matching
|
|
app.get("/api/v1/admin/media-files", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as {
|
|
nodeId?: string;
|
|
path?: string;
|
|
q?: string;
|
|
unmatched?: string;
|
|
};
|
|
const nodeId = query.nodeId?.trim() || undefined;
|
|
const pathQ = query.path?.trim() || query.q?.trim() || undefined;
|
|
if (!pathQ && !nodeId) {
|
|
throw new AppError("INVALID_REQUEST", "path of nodeId verplicht", 400);
|
|
}
|
|
|
|
const files = await prisma.mediaFile.findMany({
|
|
where: {
|
|
...(nodeId ? { nodeId } : {}),
|
|
...(pathQ
|
|
? {
|
|
OR: [
|
|
{ path: { contains: pathQ, mode: "insensitive" } },
|
|
{ releaseName: { contains: pathQ, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
...(query.unmatched === "1" || query.unmatched === "true"
|
|
? { movieId: null, episodeId: null }
|
|
: {}),
|
|
},
|
|
orderBy: [{ path: "asc" }, { releaseName: "asc" }],
|
|
take: 200,
|
|
include: {
|
|
node: { select: { id: true, name: true } },
|
|
movie: { select: { id: true, title: true, year: true, imdbId: true } },
|
|
episode: {
|
|
select: {
|
|
id: true,
|
|
seasonNumber: true,
|
|
episodeNumber: true,
|
|
title: true,
|
|
series: { select: { id: true, title: true, year: true, imdbId: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
files: files.map((f) => ({
|
|
id: f.id,
|
|
nodeId: f.nodeId,
|
|
node: f.node.name,
|
|
path: f.path,
|
|
folder: f.path ? seriesFolderFromPath(f.path) : null,
|
|
releaseName: f.releaseName,
|
|
resolution: f.resolution,
|
|
available: f.available,
|
|
mediaType: f.movieId ? "movie" : f.episodeId ? "episode" : "unmatched",
|
|
movie: f.movie,
|
|
series: f.episode?.series
|
|
? {
|
|
id: f.episode.series.id,
|
|
title: f.episode.series.title,
|
|
year: f.episode.series.year,
|
|
imdbId: f.episode.series.imdbId,
|
|
seasonNumber: f.episode.seasonNumber,
|
|
episodeNumber: f.episode.episodeNumber,
|
|
episodeTitle: f.episode.title,
|
|
episodeId: f.episode.id,
|
|
}
|
|
: null,
|
|
parsedHint: f.path
|
|
? parseSeriesFromPath(f.path) || parseMovieFromPath(f.path)
|
|
: null,
|
|
})),
|
|
};
|
|
});
|
|
|
|
app.post("/api/v1/admin/media-files/:id/match", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as {
|
|
movieId?: string;
|
|
episodeId?: string;
|
|
ignore?: boolean;
|
|
};
|
|
|
|
const file = await prisma.mediaFile.findUnique({ where: { id } });
|
|
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
|
|
|
|
if (body.ignore) {
|
|
await prisma.libraryMatch.create({
|
|
data: {
|
|
releaseName: file.releaseName,
|
|
ignored: true,
|
|
manual: true,
|
|
},
|
|
});
|
|
await prisma.mediaFile.update({
|
|
where: { id },
|
|
data: { movieId: null, episodeId: null, available: false },
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
await prisma.mediaFile.update({
|
|
where: { id },
|
|
data: {
|
|
movieId: body.movieId ?? null,
|
|
episodeId: body.episodeId ?? null,
|
|
available: true,
|
|
},
|
|
});
|
|
|
|
await prisma.libraryMatch.create({
|
|
data: {
|
|
movieId: body.movieId,
|
|
episodeId: body.episodeId,
|
|
releaseName: file.releaseName,
|
|
manual: true,
|
|
ignored: false,
|
|
},
|
|
});
|
|
|
|
return { ok: true };
|
|
});
|
|
|
|
/** Wis match en match opnieuw op basis van opgeslagen pad (of releaseName). */
|
|
app.post("/api/v1/admin/media-files/:id/rematch", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const file = await prisma.mediaFile.findUnique({ where: { id } });
|
|
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
|
|
|
|
const sourcePath = file.path?.trim() || file.releaseName;
|
|
const asSeries = parseSeriesFromPath(sourcePath);
|
|
const asMovie = asSeries ? null : parseMovieFromPath(sourcePath);
|
|
if (!asSeries && !asMovie) {
|
|
throw new AppError(
|
|
"INVALID_REQUEST",
|
|
"Pad/bestandsnaam niet te parsen als serie of film — controleer mapstructuur (Show/Season 01/…)",
|
|
400
|
|
);
|
|
}
|
|
|
|
await prisma.mediaFile.update({
|
|
where: { id },
|
|
data: { movieId: null, episodeId: null },
|
|
});
|
|
|
|
const matched = await metadata.matchMediaFile(file.nodeId, {
|
|
localFileId: file.localFileId,
|
|
sizeBytes: Number(file.sizeBytes),
|
|
container: file.container ?? undefined,
|
|
resolution: file.resolution ?? undefined,
|
|
videoCodec: file.videoCodec ?? undefined,
|
|
audioCodec: file.audioCodec ?? undefined,
|
|
releaseName: file.releaseName,
|
|
path: file.path ?? undefined,
|
|
modifiedAt: file.lastSeen.toISOString(),
|
|
mediaType: asSeries ? "episode" : "movie",
|
|
parsedMovie: asMovie ?? undefined,
|
|
parsedEpisode: asSeries ?? undefined,
|
|
shelfId: file.shelfId ?? undefined,
|
|
});
|
|
|
|
const updated = await prisma.mediaFile.findUnique({
|
|
where: { id },
|
|
include: {
|
|
movie: { select: { id: true, title: true } },
|
|
episode: {
|
|
select: {
|
|
seasonNumber: true,
|
|
episodeNumber: true,
|
|
series: { select: { id: true, title: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
ok: true,
|
|
matched: Boolean(matched),
|
|
mediaType: asSeries ? "episode" : "movie",
|
|
parsed: asSeries ?? asMovie,
|
|
movie: updated?.movie ?? null,
|
|
series: updated?.episode?.series
|
|
? {
|
|
...updated.episode.series,
|
|
seasonNumber: updated.episode.seasonNumber,
|
|
episodeNumber: updated.episode.episodeNumber,
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
|
|
app.post("/api/v1/admin/media-files/:id/refresh-metadata", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const file = await prisma.mediaFile.findUnique({ where: { id } });
|
|
if (!file) throw new AppError("NOT_FOUND", "Media file not found", 404);
|
|
|
|
const sourcePath = file.path?.trim() || file.releaseName;
|
|
const asSeries = parseSeriesFromPath(sourcePath);
|
|
const asMovie = asSeries ? null : parseMovieFromPath(sourcePath);
|
|
|
|
await metadata.matchMediaFile(file.nodeId, {
|
|
localFileId: file.localFileId,
|
|
sizeBytes: Number(file.sizeBytes),
|
|
container: file.container ?? undefined,
|
|
resolution: file.resolution ?? undefined,
|
|
videoCodec: file.videoCodec ?? undefined,
|
|
audioCodec: file.audioCodec ?? undefined,
|
|
releaseName: file.releaseName,
|
|
path: file.path ?? undefined,
|
|
modifiedAt: file.lastSeen.toISOString(),
|
|
mediaType: file.movieId ? "movie" : file.episodeId ? "episode" : asSeries ? "episode" : "movie",
|
|
parsedMovie: asMovie ?? (file.movieId
|
|
? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") }
|
|
: undefined),
|
|
parsedEpisode: asSeries ?? (!file.movieId
|
|
? { title: file.releaseName, season: 1, episode: 1 }
|
|
: undefined),
|
|
shelfId: file.shelfId ?? undefined,
|
|
});
|
|
|
|
return { ok: true };
|
|
});
|
|
|
|
app.post("/api/v1/admin/library/dedupe", { preHandler: requireAdmin }, async () => {
|
|
const result = await metadata.dedupeLibrary();
|
|
return result;
|
|
});
|
|
|
|
app.post("/api/v1/admin/library/backfill-imdb", { preHandler: requireAdmin }, async (request) => {
|
|
const body = (request.body ?? {}) as { limit?: number };
|
|
const limit =
|
|
typeof body.limit === "number" && body.limit > 0
|
|
? Math.min(Math.floor(body.limit), 500)
|
|
: 50;
|
|
const result = await metadata.backfillAllImdbIds({ delayMs: 200, limit });
|
|
return result;
|
|
});
|
|
|
|
app.post("/api/v1/admin/library/rematch-metadata", { preHandler: requireAdmin }, async (request) => {
|
|
const body = (request.body ?? {}) as { limit?: number };
|
|
const limit =
|
|
typeof body.limit === "number" && body.limit > 0
|
|
? Math.min(Math.floor(body.limit), 300)
|
|
: 100;
|
|
const result = await metadata.rematchLibraryMetadata({ delayMs: 120, limit });
|
|
return result;
|
|
});
|
|
|
|
app.get("/api/v1/admin/library/lookup", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as { q?: string };
|
|
const q = (query.q ?? "").trim();
|
|
if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400);
|
|
|
|
const [movies, mediaFiles] = await Promise.all([
|
|
prisma.movie.findMany({
|
|
where: { title: { contains: q, mode: "insensitive" } },
|
|
orderBy: { title: "asc" },
|
|
take: 20,
|
|
include: {
|
|
mediaFiles: {
|
|
select: {
|
|
id: true,
|
|
releaseName: true,
|
|
available: true,
|
|
createdAt: true,
|
|
nodeId: true,
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
},
|
|
},
|
|
}),
|
|
prisma.mediaFile.findMany({
|
|
where: { releaseName: { contains: q, mode: "insensitive" } },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 30,
|
|
include: {
|
|
movie: { select: { id: true, title: true, year: true, tmdbId: true, imdbId: true } },
|
|
node: { select: { id: true, name: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
query: q,
|
|
movies: movies.map((m) => ({
|
|
id: m.id,
|
|
title: m.title,
|
|
year: m.year,
|
|
fileCount: m.mediaFiles.length,
|
|
availableFiles: m.mediaFiles.filter((f) => f.available).length,
|
|
files: m.mediaFiles,
|
|
})),
|
|
mediaFiles: mediaFiles.map((f) => ({
|
|
id: f.id,
|
|
releaseName: f.releaseName,
|
|
available: f.available,
|
|
movieId: f.movieId,
|
|
movieTitle: f.movie?.title ?? null,
|
|
nodeName: f.node?.name ?? f.nodeId,
|
|
createdAt: f.createdAt,
|
|
matched: Boolean(f.movieId || f.episodeId),
|
|
})),
|
|
unmatchedFiles: mediaFiles.filter((f) => !f.movieId && !f.episodeId).length,
|
|
};
|
|
});
|
|
|
|
app.post("/api/v1/admin/library/repair", { preHandler: requireAdmin }, async (request) => {
|
|
request.log.info("library repair started");
|
|
const started = Date.now();
|
|
const result = await metadata.repairLibraryAfterPathMigration();
|
|
request.log.info(
|
|
{ ...result, durationMs: Date.now() - started },
|
|
"library repair finished"
|
|
);
|
|
return result;
|
|
});
|
|
|
|
app.get("/api/v1/admin/metadata/search", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as { type?: string; q?: string };
|
|
const type = query.type === "series" ? "series" : "movie";
|
|
const q = (query.q ?? "").trim();
|
|
if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400);
|
|
if (!config.TMDB_API_KEY) {
|
|
const { resolveOmdbApiKey } = await import("../settings/omdb");
|
|
const omdbKey = await resolveOmdbApiKey(config.SESSION_SECRET, config.OMDB_API_KEY);
|
|
if (!omdbKey) {
|
|
throw new AppError("CONFIG", "TMDB_API_KEY of OMDB_API_KEY ontbreekt", 503);
|
|
}
|
|
}
|
|
const results = await metadata.searchCatalog(type, q);
|
|
return { results };
|
|
});
|
|
|
|
app.get("/api/v1/admin/metadata/tv/:tmdbId", { preHandler: requireAdmin }, async (request) => {
|
|
const { tmdbId: raw } = request.params as { tmdbId: string };
|
|
const tmdbId = parseInt(raw, 10);
|
|
if (!Number.isFinite(tmdbId)) throw new AppError("INVALID_REQUEST", "tmdbId ongeldig", 400);
|
|
if (!config.TMDB_API_KEY) {
|
|
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
|
|
}
|
|
const series = await metadata.getTvSeriesInfo(tmdbId);
|
|
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
|
|
return { series };
|
|
});
|
|
|
|
app.get(
|
|
"/api/v1/admin/metadata/tv/:tmdbId/season/:season",
|
|
{ preHandler: requireAdmin },
|
|
async (request) => {
|
|
const { tmdbId: rawId, season: rawSeason } = request.params as {
|
|
tmdbId: string;
|
|
season: string;
|
|
};
|
|
const tmdbId = parseInt(rawId, 10);
|
|
const seasonNumber = parseInt(rawSeason, 10);
|
|
if (!Number.isFinite(tmdbId) || !Number.isFinite(seasonNumber)) {
|
|
throw new AppError("INVALID_REQUEST", "tmdbId/season ongeldig", 400);
|
|
}
|
|
if (!config.TMDB_API_KEY) {
|
|
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
|
|
}
|
|
const season = await metadata.getTvSeason(tmdbId, seasonNumber);
|
|
if (!season) throw new AppError("NOT_FOUND", "Seizoen niet gevonden", 404);
|
|
return { season };
|
|
}
|
|
);
|
|
|
|
app.post("/api/v1/admin/movies/:id/link", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as { tmdbId?: number; imdbId?: string };
|
|
if (body.tmdbId == null && !body.imdbId?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400);
|
|
}
|
|
try {
|
|
const result = await metadata.linkMovie(id, {
|
|
tmdbId: body.tmdbId,
|
|
imdbId: body.imdbId?.trim(),
|
|
});
|
|
return { ok: true, ...result };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Koppelen mislukt";
|
|
throw new AppError("LINK_FAILED", message, 400);
|
|
}
|
|
});
|
|
|
|
app.post("/api/v1/admin/series/:id/link", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as { tmdbId?: number; imdbId?: string };
|
|
if (body.tmdbId == null && !body.imdbId?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400);
|
|
}
|
|
try {
|
|
const result = await metadata.linkSeries(id, {
|
|
tmdbId: body.tmdbId,
|
|
imdbId: body.imdbId?.trim(),
|
|
});
|
|
return { ok: true, ...result };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Koppelen mislukt";
|
|
throw new AppError("LINK_FAILED", message, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Hele host-map op één node herkoppelen aan een TMDB/IMDb-serie.
|
|
* Body: { nodeId, folder, tmdbId? | imdbId? }
|
|
*/
|
|
app.post("/api/v1/admin/library/relink-folder", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as {
|
|
nodeId?: string;
|
|
folder?: string;
|
|
tmdbId?: number;
|
|
imdbId?: string;
|
|
};
|
|
if (!body.nodeId?.trim() || !body.folder?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "nodeId en folder verplicht", 400);
|
|
}
|
|
if (body.tmdbId == null && !body.imdbId?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "tmdbId of imdbId verplicht", 400);
|
|
}
|
|
try {
|
|
const result = await metadata.relinkFolderToSeries({
|
|
nodeId: body.nodeId.trim(),
|
|
folder: body.folder.trim(),
|
|
tmdbId: body.tmdbId,
|
|
imdbId: body.imdbId?.trim(),
|
|
});
|
|
return { ok: true, ...result };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Herkoppelen mislukt";
|
|
throw new AppError("RELINK_FAILED", message, 400);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Series-achtige bestanden zonder match (geen episode/movie), gegroepeerd per node+map.
|
|
*/
|
|
app.get("/api/v1/admin/library/pending-series", { preHandler: requireAdmin }, async (request) => {
|
|
const query = request.query as { nodeId?: string };
|
|
const nodeId = query.nodeId?.trim() || undefined;
|
|
|
|
const files = await prisma.mediaFile.findMany({
|
|
where: {
|
|
movieId: null,
|
|
episodeId: null,
|
|
available: true,
|
|
...(nodeId ? { nodeId } : {}),
|
|
OR: [
|
|
{ path: { not: null } },
|
|
{ shelf: { kind: "SERIES" } },
|
|
],
|
|
},
|
|
orderBy: [{ path: "asc" }, { releaseName: "asc" }],
|
|
take: 5000,
|
|
include: {
|
|
node: { select: { id: true, name: true, status: true } },
|
|
shelf: { select: { id: true, name: true, kind: true } },
|
|
},
|
|
});
|
|
|
|
type Group = {
|
|
key: string;
|
|
nodeId: string;
|
|
nodeName: string;
|
|
folder: string;
|
|
parsedTitle: string | null;
|
|
fileCount: number;
|
|
samplePaths: string[];
|
|
seasonsHint: Set<number>;
|
|
};
|
|
const groups = new Map<string, Group>();
|
|
|
|
for (const f of files) {
|
|
const parsed = f.path ? parseSeriesFromPath(f.path) : null;
|
|
const isSeriesShelf = f.shelf?.kind === "SERIES";
|
|
if (!parsed && !isSeriesShelf) continue;
|
|
|
|
const folder =
|
|
(f.path ? seriesFolderFromPath(f.path) : null) ||
|
|
(f.path ? f.path.replace(/\\/g, "/").split("/").slice(0, -1).join("/") : null) ||
|
|
`(geen pad) ${f.releaseName}`;
|
|
const key = `${f.nodeId}::${folder}`;
|
|
let g = groups.get(key);
|
|
if (!g) {
|
|
g = {
|
|
key,
|
|
nodeId: f.nodeId,
|
|
nodeName: f.node.name,
|
|
folder,
|
|
parsedTitle: parsed?.title ?? null,
|
|
fileCount: 0,
|
|
samplePaths: [],
|
|
seasonsHint: new Set(),
|
|
};
|
|
groups.set(key, g);
|
|
}
|
|
g.fileCount += 1;
|
|
if (parsed?.season) g.seasonsHint.add(parsed.season);
|
|
if (g.samplePaths.length < 3) {
|
|
g.samplePaths.push(f.path || f.releaseName);
|
|
}
|
|
if (!g.parsedTitle && parsed?.title) g.parsedTitle = parsed.title;
|
|
}
|
|
|
|
const pending = [...groups.values()]
|
|
.map((g) => ({
|
|
key: g.key,
|
|
nodeId: g.nodeId,
|
|
nodeName: g.nodeName,
|
|
folder: g.folder,
|
|
parsedTitle: g.parsedTitle,
|
|
fileCount: g.fileCount,
|
|
seasons: [...g.seasonsHint].sort((a, b) => a - b),
|
|
samplePaths: g.samplePaths,
|
|
}))
|
|
.sort((a, b) => a.nodeName.localeCompare(b.nodeName) || a.folder.localeCompare(b.folder));
|
|
|
|
return { pending, totalFolders: pending.length, totalFiles: pending.reduce((n, p) => n + p.fileCount, 0) };
|
|
});
|
|
|
|
app.get("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async () => {
|
|
const { ensureDefaultShelves } = await import("../library/shelves");
|
|
await ensureDefaultShelves();
|
|
const shelves = await prisma.libraryShelf.findMany({
|
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
|
include: {
|
|
_count: { select: { scanRoots: true, mediaFiles: true } },
|
|
scanRoots: {
|
|
select: { path: true, node: { select: { name: true } } },
|
|
orderBy: { path: "asc" },
|
|
},
|
|
},
|
|
});
|
|
return {
|
|
shelves: shelves.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
homepageName: s.homepageName,
|
|
slug: s.slug,
|
|
kind: s.kind,
|
|
sortOrder: s.sortOrder,
|
|
enabled: s.enabled,
|
|
scanRootCount: s._count.scanRoots,
|
|
mediaFileCount: s._count.mediaFiles,
|
|
paths: s.scanRoots.map((r) => ({
|
|
path: r.path,
|
|
nodeName: r.node.name,
|
|
})),
|
|
})),
|
|
};
|
|
});
|
|
|
|
app.post("/api/v1/admin/library/shelves", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as { name?: string; kind?: "MOVIE" | "SERIES"; sortOrder?: number };
|
|
if (!body.name?.trim()) throw new AppError("INVALID_REQUEST", "name required", 400);
|
|
if (body.kind !== "MOVIE" && body.kind !== "SERIES") {
|
|
throw new AppError("INVALID_REQUEST", "kind must be MOVIE or SERIES", 400);
|
|
}
|
|
const { slugifyShelfName, ensureDefaultShelves } = await import("../library/shelves");
|
|
await ensureDefaultShelves();
|
|
let slug = slugifyShelfName(body.name);
|
|
const existing = await prisma.libraryShelf.findUnique({ where: { slug } });
|
|
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
|
|
const shelf = await prisma.libraryShelf.create({
|
|
data: {
|
|
name: body.name.trim(),
|
|
slug,
|
|
kind: body.kind,
|
|
sortOrder: body.sortOrder ?? 10,
|
|
},
|
|
});
|
|
return { shelf };
|
|
});
|
|
|
|
app.patch("/api/v1/admin/library/shelves/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as {
|
|
name?: string;
|
|
homepageName?: string | null;
|
|
enabled?: boolean;
|
|
sortOrder?: number;
|
|
};
|
|
const data: Record<string, unknown> = {};
|
|
if (body.name !== undefined) {
|
|
const name = body.name.trim();
|
|
if (!name) throw new AppError("INVALID_REQUEST", "name required", 400);
|
|
data.name = name;
|
|
}
|
|
if (body.homepageName !== undefined) {
|
|
const homepageName = body.homepageName?.trim();
|
|
data.homepageName = homepageName ? homepageName : null;
|
|
}
|
|
if (body.enabled !== undefined) data.enabled = body.enabled;
|
|
if (body.sortOrder !== undefined) data.sortOrder = body.sortOrder;
|
|
const shelf = await prisma.libraryShelf.update({ where: { id }, data });
|
|
return { shelf };
|
|
});
|
|
|
|
app.delete("/api/v1/admin/library/shelves/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
if (id === "00000000-0000-4000-8000-000000000001" || id === "00000000-0000-4000-8000-000000000002") {
|
|
throw new AppError("INVALID_REQUEST", "Standaard Films/Series planken kunnen niet weg", 400);
|
|
}
|
|
const roots = await prisma.nodeScanRoot.count({ where: { shelfId: id } });
|
|
if (roots > 0) {
|
|
throw new AppError("IN_USE", "Plank heeft nog scanpaden — ontkoppel die eerst", 400);
|
|
}
|
|
await prisma.mediaFile.updateMany({ where: { shelfId: id }, data: { shelfId: null } });
|
|
await prisma.libraryShelf.delete({ where: { id } });
|
|
return { ok: true };
|
|
});
|
|
|
|
app.get("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const token = await prisma.addonToken.findFirst({
|
|
where: { id, userId: user.id },
|
|
include: { iptvLine: true },
|
|
});
|
|
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
const line = token.iptvLine;
|
|
return {
|
|
iptv: line
|
|
? {
|
|
id: line.id,
|
|
label: line.label,
|
|
baseUrl: line.baseUrl,
|
|
username: line.username,
|
|
hasPassword: true,
|
|
enableLive: line.enableLive,
|
|
enableVod: line.enableVod,
|
|
enableSeries: line.enableSeries,
|
|
lastOkAt: line.lastOkAt,
|
|
lastError: line.lastError,
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
|
|
app.put("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const body = request.body as {
|
|
label?: string;
|
|
baseUrl?: string;
|
|
username?: string;
|
|
password?: string;
|
|
enableLive?: boolean;
|
|
enableVod?: boolean;
|
|
enableSeries?: boolean;
|
|
};
|
|
|
|
const token = await prisma.addonToken.findFirst({
|
|
where: { id, userId: user.id },
|
|
include: { iptvLine: true },
|
|
});
|
|
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
|
|
if (!body.baseUrl?.trim() || !body.username?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "baseUrl and username required", 400);
|
|
}
|
|
if (!body.password?.trim() && !token.iptvLine) {
|
|
throw new AppError("INVALID_REQUEST", "password required for new IPTV line", 400);
|
|
}
|
|
|
|
const { encryptSecret } = await import("../security/crypto");
|
|
const { XtreamClient, parseXtreamBaseUrl } = await import("../iptv/xtream");
|
|
|
|
const baseUrl = parseXtreamBaseUrl(body.baseUrl);
|
|
const passwordEnc = body.password?.trim()
|
|
? encryptSecret(body.password.trim(), config.SESSION_SECRET)
|
|
: token.iptvLine!.passwordEnc;
|
|
|
|
// Validate against Xtream before saving
|
|
try {
|
|
const client = XtreamClient.fromEncrypted({
|
|
baseUrl,
|
|
username: body.username.trim(),
|
|
passwordEnc,
|
|
sessionSecret: config.SESSION_SECRET,
|
|
});
|
|
await client.authenticate();
|
|
const liveCats = body.enableLive === false ? [] : await client.getLiveCategories().catch(() => []);
|
|
const line = await prisma.iptvLine.upsert({
|
|
where: { addonTokenId: id },
|
|
create: {
|
|
addonTokenId: id,
|
|
label: body.label?.trim() || null,
|
|
baseUrl,
|
|
username: body.username.trim(),
|
|
passwordEnc,
|
|
enableLive: body.enableLive ?? true,
|
|
enableVod: body.enableVod ?? true,
|
|
enableSeries: body.enableSeries ?? true,
|
|
lastOkAt: new Date(),
|
|
lastError: null,
|
|
},
|
|
update: {
|
|
label: body.label?.trim() || null,
|
|
baseUrl,
|
|
username: body.username.trim(),
|
|
passwordEnc,
|
|
enableLive: body.enableLive ?? true,
|
|
enableVod: body.enableVod ?? true,
|
|
enableSeries: body.enableSeries ?? true,
|
|
lastOkAt: new Date(),
|
|
lastError: null,
|
|
},
|
|
});
|
|
return {
|
|
ok: true,
|
|
iptv: {
|
|
id: line.id,
|
|
liveCategories: liveCats.length,
|
|
lastOkAt: line.lastOkAt,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Xtream connect failed";
|
|
if (token.iptvLine) {
|
|
await prisma.iptvLine.update({
|
|
where: { id: token.iptvLine.id },
|
|
data: { lastError: message },
|
|
});
|
|
}
|
|
throw new AppError("IPTV_ERROR", message, 400);
|
|
}
|
|
});
|
|
|
|
app.delete("/api/v1/admin/addon-tokens/:id/iptv", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const token = await prisma.addonToken.findFirst({ where: { id, userId: user.id } });
|
|
if (!token) throw new AppError("NOT_FOUND", "Addon token not found", 404);
|
|
await prisma.iptvLine.deleteMany({ where: { addonTokenId: id } });
|
|
return { ok: true };
|
|
});
|
|
|
|
app.post("/api/v1/admin/addon-tokens/:id/iptv/test", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const user = (request as import("../auth/routes").AuthenticatedRequest).user;
|
|
const token = await prisma.addonToken.findFirst({
|
|
where: { id, userId: user.id },
|
|
include: { iptvLine: true },
|
|
});
|
|
if (!token?.iptvLine) throw new AppError("NOT_FOUND", "Geen IPTV-line op deze addon", 404);
|
|
const { XtreamClient } = await import("../iptv/xtream");
|
|
try {
|
|
const client = XtreamClient.fromEncrypted({
|
|
baseUrl: token.iptvLine.baseUrl,
|
|
username: token.iptvLine.username,
|
|
passwordEnc: token.iptvLine.passwordEnc,
|
|
sessionSecret: config.SESSION_SECRET,
|
|
});
|
|
const auth = await client.authenticate();
|
|
const [live, vod, series] = await Promise.all([
|
|
client.getLiveCategories().catch(() => []),
|
|
client.getVodCategories().catch(() => []),
|
|
client.getSeriesCategories().catch(() => []),
|
|
]);
|
|
await prisma.iptvLine.update({
|
|
where: { id: token.iptvLine.id },
|
|
data: { lastOkAt: new Date(), lastError: null },
|
|
});
|
|
return {
|
|
ok: true,
|
|
status: auth.status,
|
|
expDate: auth.expDate,
|
|
liveCategories: live.length,
|
|
vodCategories: vod.length,
|
|
seriesCategories: series.length,
|
|
liveCategoryNames: live.slice(0, 40).map((c) => String(c.category_name ?? "")),
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Test mislukt";
|
|
await prisma.iptvLine.update({
|
|
where: { id: token.iptvLine.id },
|
|
data: { lastError: message },
|
|
});
|
|
throw new AppError("IPTV_ERROR", message, 400);
|
|
}
|
|
});
|
|
|
|
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as {
|
|
title: string;
|
|
year?: number;
|
|
imdbId?: string;
|
|
tmdbId?: number;
|
|
};
|
|
if (!body.title) throw new AppError("INVALID_REQUEST", "title required");
|
|
const movie = await prisma.movie.create({ data: body });
|
|
return { movie };
|
|
});
|
|
|
|
// --- Odido script/provider settings ---
|
|
app.get("/api/v1/admin/scripts/odido", { preHandler: requireAdmin }, async () => {
|
|
const { getOdidoSettingsPublic } = await import("../odido/settings");
|
|
return getOdidoSettingsPublic();
|
|
});
|
|
|
|
app.put("/api/v1/admin/scripts/odido", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as {
|
|
username?: string;
|
|
pin?: string;
|
|
wvdPath?: string;
|
|
enabled?: boolean;
|
|
};
|
|
const { upsertOdidoSettings } = await import("../odido/settings");
|
|
return upsertOdidoSettings(config.SESSION_SECRET, body);
|
|
});
|
|
|
|
app.post("/api/v1/admin/scripts/odido/wvd", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as { contentBase64?: string };
|
|
if (!body.contentBase64?.trim()) {
|
|
throw new AppError("INVALID_REQUEST", "contentBase64 verplicht", 400);
|
|
}
|
|
const { saveOdidoWvdUpload } = await import("../odido/settings");
|
|
return saveOdidoWvdUpload(body.contentBase64);
|
|
});
|
|
|
|
// --- Custom ClearKey/DASH live channel lists ---
|
|
app.get("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async () => {
|
|
const { listLiveChannelListsAdmin } = await import("../viewer/live-channels");
|
|
return { lists: await listLiveChannelListsAdmin() };
|
|
});
|
|
|
|
app.get("/api/v1/admin/live-lists/epg-channels", { preHandler: requireAdmin }, async () => {
|
|
const { listEpgChannelOptions } = await import("../viewer/live-channels");
|
|
return { channels: await listEpgChannelOptions(config) };
|
|
});
|
|
|
|
app.get("/api/v1/admin/live-lists/logo-options", { preHandler: requireAdmin }, async () => {
|
|
const { listChannelLogoOptions } = await import("../viewer/live-channels");
|
|
return { logos: await listChannelLogoOptions() };
|
|
});
|
|
|
|
app.get("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { getLiveChannelListAdmin } = await import("../viewer/live-channels");
|
|
return getLiveChannelListAdmin(id);
|
|
});
|
|
|
|
app.post("/api/v1/admin/live-lists", { preHandler: requireAdmin }, async (request) => {
|
|
const body = request.body as { name?: string; sortOrder?: number; enabled?: boolean };
|
|
const { createLiveChannelList } = await import("../viewer/live-channels");
|
|
return createLiveChannelList(body);
|
|
});
|
|
|
|
app.patch("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as {
|
|
name?: string;
|
|
sortOrder?: number;
|
|
enabled?: boolean;
|
|
provider?: string | null;
|
|
};
|
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
|
return updateLiveChannelList(id, body);
|
|
});
|
|
|
|
app.post(
|
|
"/api/v1/admin/live-lists/:id/odido/link",
|
|
{ preHandler: requireAdmin },
|
|
async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
|
return updateLiveChannelList(id, { provider: "odido" });
|
|
}
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/admin/live-lists/:id/odido/unlink",
|
|
{ preHandler: requireAdmin },
|
|
async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { updateLiveChannelList } = await import("../viewer/live-channels");
|
|
return updateLiveChannelList(id, { provider: null });
|
|
}
|
|
);
|
|
|
|
app.post(
|
|
"/api/v1/admin/live-lists/:id/odido/sync",
|
|
{ preHandler: requireAdmin },
|
|
async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { syncOdidoList } = await import("../odido/sync");
|
|
return syncOdidoList(id, config.SESSION_SECRET);
|
|
}
|
|
);
|
|
|
|
app.delete("/api/v1/admin/live-lists/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { deleteLiveChannelList } = await import("../viewer/live-channels");
|
|
return deleteLiveChannelList(id);
|
|
});
|
|
|
|
app.post("/api/v1/admin/live-lists/:id/channels", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as {
|
|
name?: string;
|
|
logoUrl?: string | null;
|
|
mpdUrl?: string;
|
|
kid?: string;
|
|
key?: string;
|
|
epgChannelId?: string | null;
|
|
sortOrder?: number;
|
|
enabled?: boolean;
|
|
};
|
|
const { createLiveChannel } = await import("../viewer/live-channels");
|
|
return createLiveChannel(id, body, config.SESSION_SECRET);
|
|
});
|
|
|
|
app.patch("/api/v1/admin/live-channels/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const body = request.body as {
|
|
name?: string;
|
|
logoUrl?: string | null;
|
|
mpdUrl?: string;
|
|
kid?: string;
|
|
key?: string;
|
|
epgChannelId?: string | null;
|
|
sortOrder?: number;
|
|
enabled?: boolean;
|
|
};
|
|
const { updateLiveChannel } = await import("../viewer/live-channels");
|
|
return updateLiveChannel(id, body, config.SESSION_SECRET);
|
|
});
|
|
|
|
app.delete("/api/v1/admin/live-channels/:id", { preHandler: requireAdmin }, async (request) => {
|
|
const { id } = request.params as { id: string };
|
|
const { deleteLiveChannel } = await import("../viewer/live-channels");
|
|
return deleteLiveChannel(id);
|
|
});
|
|
}
|
|
|
|
export async function rotateNodeCredentials(nodeId: string): Promise<string> {
|
|
const apiKey = generateApiKey();
|
|
await prisma.nodeCredential.update({
|
|
where: { nodeId },
|
|
data: {
|
|
apiKeyHash: hashToken(apiKey),
|
|
rotatedAt: new Date(),
|
|
},
|
|
});
|
|
return apiKey;
|
|
}
|