stremio/apps/master-api/src/admin/routes.ts
Jos Vooges | STH d2046904a0 Fix silent TMDB matching failures and add node upgrade tooling.
Survive IMDb unique conflicts during movie matching, log per-file sync errors, add library lookup diagnostic, and ship upgrade-node.sh plus Admin upgrade button for Unraid/Synology nodes still on 1.2.9.
2026-09-02 02:55:53 +02:00

925 lines
32 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 { MetadataService } from "../metadata/service";
import type { Config } from "../config";
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
const metadata = new MetadataService(config.TMDB_API_KEY);
app.get("/api/v1/admin/dashboard", { preHandler: requireAdmin }, async () => {
const [
nodesOnline,
nodesOffline,
movieCount,
seriesCount,
episodeCount,
activeStreams,
nodes,
availableMovies,
availableSeries,
mediaFileCount,
availableFileCount,
moviesMissingImdb,
moviesWithImdbStreams,
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(),
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 },
},
}),
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.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,
seriesMissingImdb,
seriesWithImdb,
scannedFiles,
activeStreams,
currentBandwidth: bandwidth.toString(),
};
});
app.get("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const query = request.query as { search?: string };
const movies = await prisma.movie.findMany({
where: query.search
? { title: { contains: query.search, mode: "insensitive" } }
: undefined,
orderBy: { title: "asc" },
include: {
mediaFiles: {
include: { node: { select: { id: true, name: true, status: true } } },
},
},
take: 5000,
});
return {
movies: movies.map((m) => ({
id: m.id,
title: m.title,
year: m.year,
imdbId: m.imdbId,
tmdbId: m.tmdbId,
overview: m.overview,
posterUrl: m.posterUrl,
fileCount: m.mediaFiles.length,
nodeCount: new Set(m.mediaFiles.map((f) => f.nodeId)).size,
qualities: [...new Set(m.mediaFiles.map((f) => f.resolution).filter(Boolean))],
files: m.mediaFiles.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,
available: f.available,
})),
})),
};
});
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 };
const series = await prisma.series.findMany({
where: query.search
? { title: { contains: query.search, mode: "insensitive" } }
: undefined,
orderBy: { title: "asc" },
include: {
seasons: {
include: {
episodes: {
include: {
mediaFiles: {
include: { node: { select: { name: true, status: true } } },
},
},
orderBy: { episodeNumber: "asc" },
},
},
orderBy: { seasonNumber: "asc" },
},
},
take: 2000,
});
return {
series: series.map((s) => {
const episodesWithFiles = s.seasons.reduce(
(n, season) => n + season.episodes.filter((ep) => ep.mediaFiles.length > 0).length,
0
);
const fileCount = s.seasons.reduce(
(n, season) =>
n + season.episodes.reduce((m, ep) => m + ep.mediaFiles.length, 0),
0
);
return {
id: s.id,
title: s.title,
year: s.year,
imdbId: s.imdbId,
tmdbId: s.tmdbId,
posterUrl: s.posterUrl,
episodesWithFiles,
fileCount,
seasons: s.seasons.map((season) => ({
seasonNumber: season.seasonNumber,
episodes: season.episodes.map((ep) => ({
id: ep.id,
seasonNumber: ep.seasonNumber,
episodeNumber: ep.episodeNumber,
title: ep.title,
files: ep.mediaFiles.map((f) => ({
id: f.id,
node: f.node.name,
nodeStatus: f.node.status,
resolution: f.resolution,
available: f.available,
releaseName: f.releaseName,
})),
})),
})),
};
}),
};
});
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 } },
mediaFile: {
include: {
movie: { select: { title: true, year: true } },
episode: {
include: { series: { select: { title: true } } },
},
},
},
},
orderBy: { lastActivity: "desc" },
take: 100,
});
// 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 key = `${s.addonTokenId ?? s.id}:${titleKey}`;
const prev = best.get(key);
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 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,
node: s.node.name,
status: s.status,
viewer: s.addonToken?.label?.trim() || "Onbekend",
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,
};
}),
};
});
app.post("/api/v1/admin/streams/:id/revoke", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
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 };
const token = generateSecureToken(24);
const created = await prisma.addonToken.create({
data: {
userId: user.id,
tokenHash: hashToken(token),
label: body.label,
},
});
return {
token,
id: created.id,
label: created.label,
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 } } },
});
return {
tokens: tokens.map((t) => ({
id: t.id,
label: t.label,
revoked: t.revoked,
createdAt: t.createdAt,
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.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 };
});
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);
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,
modifiedAt: file.lastSeen.toISOString(),
mediaType: file.movieId ? "movie" : "episode",
parsedMovie: file.movieId
? { title: file.releaseName.replace(/\.[^.]+$/, "").replace(/[._]/g, " ") }
: undefined,
parsedEpisode: !file.movieId
? { title: file.releaseName, season: 1, episode: 1 }
: 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 () => {
// Process ALL pending movies + series (batched). Meta for series stays mcseries-*.
const result = await metadata.backfillAllImdbIds({ delayMs: 120 });
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) {
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
}
const results = await metadata.searchCatalog(type, q);
return { results };
});
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);
}
});
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,
};
} 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 };
});
}
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;
}