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(); 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 } >(); 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 } >(); 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(); 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; }; const groups = new Map(); 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 = {}; 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; tweaks?: Record; }; const { upsertOdidoSettings } = await import("../odido/settings"); return upsertOdidoSettings(config.SESSION_SECRET, { ...body, tweaks: body.tweaks as import("../odido/tweaks").OdidoTweaks | undefined, }); }); 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); }); app.post("/api/v1/admin/scripts/odido/refresh-mpd", { preHandler: requireAdmin }, async () => { const { refreshOdidoMpdCache } = await import("../odido/refresh"); return refreshOdidoMpdCache(config.SESSION_SECRET, "manual"); }); app.post("/api/v1/admin/scripts/odido/flush-mpd", { preHandler: requireAdmin }, async () => { const { flushOdidoMpdCache } = await import("../odido/refresh"); return flushOdidoMpdCache(); }); app.post("/api/v1/admin/scripts/odido/accounts", { preHandler: requireAdmin }, async (request) => { const body = request.body as { username?: string; pin?: string; label?: string; enabled?: boolean; }; if (!body.username?.trim() || !body.pin?.trim()) { throw new AppError("INVALID_REQUEST", "username en pin verplicht", 400); } const { addOdidoAccount } = await import("../odido/settings"); return addOdidoAccount(config.SESSION_SECRET, { username: body.username, pin: body.pin, label: body.label, enabled: body.enabled, }); }); app.patch( "/api/v1/admin/scripts/odido/accounts/:accountId", { preHandler: requireAdmin }, async (request) => { const { accountId } = request.params as { accountId: string }; const body = request.body as { username?: string; pin?: string; label?: string; enabled?: boolean; }; const { updateOdidoAccount } = await import("../odido/settings"); return updateOdidoAccount(config.SESSION_SECRET, accountId, body); } ); app.delete( "/api/v1/admin/scripts/odido/accounts/:accountId", { preHandler: requireAdmin }, async (request) => { const { accountId } = request.params as { accountId: string }; const { removeOdidoAccount } = await import("../odido/settings"); return removeOdidoAccount(config.SESSION_SECRET, accountId); } ); // --- 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, config); } ); 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 { const apiKey = generateApiKey(); await prisma.nodeCredential.update({ where: { nodeId }, data: { apiKeyHash: hashToken(apiKey), rotatedAt: new Date(), }, }); return apiKey; }