import Fastify from "fastify"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import websocket from "@fastify/websocket"; import { loadConfig } from "./config"; import { disconnectDatabase, prisma } from "./database/client"; import { registerAuthRoutes, ensureAdminUser } from "./auth/routes"; import { registerNodeRoutes } from "./nodes/routes"; import { registerStremioRoutes } from "./stremio/routes"; import { registerAdminRoutes } from "./admin/routes"; import { registerInstallRoutes } from "./install/routes"; import { nodeConnectionManager } from "./websocket/manager"; import { MetadataService } from "./metadata/service"; import { toErrorResponse } from "./security/errors"; async function main() { const config = loadConfig(); const app = Fastify({ logger: { level: config.NODE_ENV === "production" ? "info" : "debug", redact: ["req.headers.cookie", "req.headers.authorization"], }, trustProxy: true, }); await app.register(cors, { origin: true, credentials: true, }); await app.register(cookie, { secret: config.SESSION_SECRET, }); await app.register(rateLimit, { max: 300, timeWindow: "1 minute", }); await app.register(websocket, { options: { // Large library sync batches (still chunked on the node side) maxPayload: 16 * 1024 * 1024, }, }); app.addHook("onSend", async (request, reply, payload) => { if (!request.url.startsWith("/stremio/")) { reply.header("X-Content-Type-Options", "nosniff"); reply.header("Referrer-Policy", "no-referrer"); reply.header("X-Frame-Options", "DENY"); } return payload; }); app.setErrorHandler((error, _request, reply) => { const { statusCode, body } = toErrorResponse(error); reply.status(statusCode).send(body); }); app.get("/health", async () => ({ status: "ok", service: "master-api", version: "1.0.0", })); await registerAuthRoutes(app, config); await registerNodeRoutes(app); await registerStremioRoutes(app, config); await registerAdminRoutes(app, config); await registerInstallRoutes(app, config); app.get("/api/v1/node/connect", { websocket: true }, (socket) => { void nodeConnectionManager.handleConnection(socket); }); nodeConnectionManager.init(config); await ensureAdminUser(config); // Safety net if migrate history drifted — series meta must not 500. try { await prisma.$executeRawUnsafe( `ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "still_url" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "series" ADD COLUMN IF NOT EXISTS "logo_url" TEXT` ); } catch (err) { console.warn("Could not ensure still_url/logo_url columns:", err); } // Repair drifted episode.series_id so series.episodes and seasons.episodes stay aligned. try { await prisma.$executeRawUnsafe(` UPDATE episodes e SET series_id = s.series_id FROM seasons s WHERE e.season_id = s.id AND e.series_id IS DISTINCT FROM s.series_id `); await prisma.$executeRawUnsafe(` UPDATE episodes e SET season_id = s.id FROM seasons s WHERE s.series_id = e.series_id AND s.season_number = e.season_number AND e.season_id IS DISTINCT FROM s.id `); } catch (err) { console.warn("Could not repair episode series/season drift:", err); } const shutdown = async () => { console.log("Shutting down..."); nodeConnectionManager.shutdown(); await app.close(); await disconnectDatabase(); process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); await app.listen({ port: config.PORT, host: config.HOST }); console.log(`Master API listening on ${config.HOST}:${config.PORT}`); // Background: fill ALL missing movie imdbIds (batched) so Cinemeta can resolve local streams. if (config.TMDB_API_KEY) { const metadata = new MetadataService(config.TMDB_API_KEY); setTimeout(() => { void metadata .backfillMovieImdbIds({ delayMs: 120 }) .then((r) => { if (r.checked > 0) { console.log( `IMDb backfill: checked=${r.checked} updated=${r.updated} remaining=${r.remaining} failed=${r.failed}` ); } }) .catch((err) => console.warn("IMDb backfill failed:", err)); }, 5000); } } main().catch((err) => { console.error("Failed to start:", err); void prisma.$disconnect(); process.exit(1); });