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(); 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(); 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 = {}; 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 { const apiKey = generateApiKey(); await prisma.nodeCredential.update({ where: { nodeId }, data: { apiKeyHash: hashToken(apiKey), rotatedAt: new Date(), }, }); return apiKey; }