151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
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);
|
|
});
|