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, ] = 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 } }), prisma.node.findMany({ where: { revoked: false }, select: { totalStorage: true, freeStorage: true, currentBandwidth: true, libraryFileCount: true }, }), prisma.movie.count({ where: { mediaFiles: { some: { available: true } } } }), prisma.series.count({ where: { seasons: { some: { episodes: { some: { mediaFiles: { some: { available: true } } } }, }, }, }, }), prisma.mediaFile.count(), prisma.mediaFile.count({ where: { available: true } }), ]); const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n); const freeStorage = nodes.reduce((sum, n) => sum + n.freeStorage, 0n); 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, 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(); // Drop expired rows so prefetch leftovers do not clutter the UI. await prisma.playbackSession.updateMany({ where: { status: "ACTIVE", revoked: false, absoluteExpiresAt: { lt: now }, }, data: { status: "EXPIRED" }, }); const sessions = await prisma.playbackSession.findMany({ where: { status: "ACTIVE", revoked: false, absoluteExpiresAt: { gt: now }, // Only show recently touched sessions (prefetch often never streams bytes). lastActivity: { gt: new Date(now.getTime() - 15 * 60 * 1000) }, }, include: { node: { select: { id: true, name: true } }, mediaFile: { include: { movie: { select: { title: true, year: true } }, episode: { include: { series: { select: { title: true } } }, }, }, }, }, orderBy: { lastActivity: "desc" }, take: 100, }); return { streams: sessions.map((s) => ({ id: s.id, node: s.node.name, status: s.status, createdAt: s.createdAt, lastActivity: s.lastActivity, absoluteExpiresAt: s.absoluteExpiresAt, 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), resolution: s.mediaFile.resolution, })), }; }); 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" }, }); return { tokens: tokens.map((t) => ({ id: t.id, label: t.label, revoked: t.revoked, createdAt: t.createdAt, })), }; }); 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/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; }