476 lines
15 KiB
TypeScript
476 lines
15 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,
|
|
] = 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 } }),
|
|
prisma.movie.count({
|
|
where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
|
|
}),
|
|
prisma.movie.count({
|
|
where: { imdbId: { not: null }, mediaFiles: { some: { 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,
|
|
moviesMissingImdb,
|
|
moviesWithImdbStreams,
|
|
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/library/backfill-imdb", { preHandler: requireAdmin }, async () => {
|
|
// Process ALL pending movies (batched). Can take a few minutes for large libraries.
|
|
const result = await metadata.backfillMovieImdbIds({ delayMs: 120 });
|
|
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<string> {
|
|
const apiKey = generateApiKey();
|
|
await prisma.nodeCredential.update({
|
|
where: { nodeId },
|
|
data: {
|
|
apiKeyHash: hashToken(apiKey),
|
|
rotatedAt: new Date(),
|
|
},
|
|
});
|
|
return apiKey;
|
|
}
|