stremio/apps/master-api/src/app.ts
Jos Vooges | STH 18eb702de2 Add Synology Download Station search and 4K import pipeline.
Admin can search DS, confirm TMDB/IMDb, then auto-move finished downloads into a fixed 4K library folder via the media-node.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 17:42:12 +02:00

301 lines
10 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 { registerDownloadRoutes } from "./downloads/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);
registerDownloadRoutes(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);
}
// IPTV userlines (Xtream) bound to addon tokens.
try {
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "iptv_lines" (
"id" TEXT NOT NULL,
"addon_token_id" TEXT NOT NULL,
"label" TEXT,
"base_url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_enc" TEXT NOT NULL,
"enable_live" BOOLEAN NOT NULL DEFAULT true,
"enable_vod" BOOLEAN NOT NULL DEFAULT true,
"enable_series" BOOLEAN NOT NULL DEFAULT true,
"last_ok_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "iptv_lines_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id")`
);
} catch (err) {
console.warn("Could not ensure iptv_lines table:", err);
}
try {
await prisma.$executeRawUnsafe(
`ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "movies_paths" TEXT[] DEFAULT ARRAY[]::TEXT[]`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "series_paths" TEXT[] DEFAULT ARRAY[]::TEXT[]`
);
} catch (err) {
console.warn("Could not ensure node media path columns:", err);
}
try {
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "LibraryShelfKind" AS ENUM ('MOVIE', 'SERIES');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "library_shelves" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"kind" "LibraryShelfKind" NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "library_shelves_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "library_shelves_slug_key" ON "library_shelves"("slug")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "node_scan_roots" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"path" TEXT NOT NULL,
"shelf_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "node_scan_roots_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "node_scan_roots_node_id_path_key" ON "node_scan_roots"("node_id", "path")`
);
await prisma.$executeRawUnsafe(
`ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "shelf_id" TEXT`
);
await prisma.$executeRawUnsafe(`
INSERT INTO "library_shelves" ("id", "name", "slug", "kind", "sort_order", "enabled", "created_at", "updated_at")
VALUES
('00000000-0000-4000-8000-000000000001', 'Films', 'films', 'MOVIE', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('00000000-0000-4000-8000-000000000002', 'Series', 'series', 'SERIES', 1, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING
`);
} catch (err) {
console.warn("Could not ensure library shelves:", err);
}
try {
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "DownloadJobStatus" AS ENUM ('DOWNLOADING', 'FINISHED', 'IMPORTING', 'IMPORTED', 'FAILED');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "synology_ds_configs" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"base_url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password_enc" TEXT NOT NULL,
"download_destination" TEXT NOT NULL,
"download_host_path" TEXT NOT NULL,
"four_k_host_path" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"last_ok_at" TIMESTAMP(3),
"last_error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "synology_ds_configs_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE UNIQUE INDEX IF NOT EXISTS "synology_ds_configs_node_id_key" ON "synology_ds_configs"("node_id")`
);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "download_jobs" (
"id" TEXT NOT NULL,
"node_id" TEXT NOT NULL,
"status" "DownloadJobStatus" NOT NULL DEFAULT 'DOWNLOADING',
"source_uri" TEXT NOT NULL,
"source_title" TEXT NOT NULL,
"ds_task_id" TEXT,
"tmdb_id" INTEGER,
"imdb_id" TEXT,
"movie_title" TEXT NOT NULL,
"movie_year" INTEGER,
"poster_url" TEXT,
"dest_folder_name" TEXT,
"source_path" TEXT,
"imported_path" TEXT,
"error" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "download_jobs_pkey" PRIMARY KEY ("id")
)
`);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS "download_jobs_status_updated_at_idx" ON "download_jobs"("status", "updated_at")`
);
await prisma.$executeRawUnsafe(
`CREATE INDEX IF NOT EXISTS "download_jobs_node_id_status_idx" ON "download_jobs"("node_id", "status")`
);
} catch (err) {
console.warn("Could not ensure download tables:", 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 + series imdbIds so Cinemeta can resolve local streams.
// Series catalog/meta stay on mcseries-* (tt only used for streams).
if (config.TMDB_API_KEY) {
const metadata = new MetadataService(config.TMDB_API_KEY);
setTimeout(() => {
void metadata
.backfillAllImdbIds({ delayMs: 120 })
.then((r) => {
if (r.movies.checked > 0 || r.series.checked > 0) {
console.log(
`IMDb backfill movies: checked=${r.movies.checked} updated=${r.movies.updated} remaining=${r.movies.remaining}; series: checked=${r.series.checked} updated=${r.series.updated} remaining=${r.series.remaining}`
);
}
})
.catch((err) => console.warn("IMDb backfill failed:", err));
}, 5000);
}
}
main().catch((err) => {
console.error("Failed to start:", err);
void prisma.$disconnect();
process.exit(1);
});