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 multipart from "@fastify/multipart"; 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 { registerSiteRoutes } from "./site/routes"; import { registerDownloadRoutes } from "./downloads/routes"; import { registerSettingsRoutes } from "./settings/routes"; import { registerViewerRoutes } from "./viewer/routes"; import { registerGooglePlayRoutes } from "./google-play/routes"; import { startGooglePlayReconciliationPoller } from "./google-play/service"; import { nodeConnectionManager } from "./websocket/manager"; import { createMetadataService } from "./settings/metadata-factory"; import { toErrorResponse } from "./security/errors"; import { startIptvEpgWarmup } from "./viewer/iptv-client"; import { applyIptvEpgUrlsToRuntime } from "./settings/iptv"; import { startOdidoMpdRefreshWorker } from "./odido/refresh"; import { startViaplayPreLiveWorker } from "./viaplay/refresh"; import { TOKEN_TRAY_MAX_BYTES } from "./install/token-tray-store"; async function main() { const config = loadConfig(); await applyIptvEpgUrlsToRuntime(config); const app = Fastify({ logger: { level: config.NODE_ENV === "production" ? "info" : "debug", redact: ["req.headers.cookie", "req.headers.authorization"], }, trustProxy: true, bodyLimit: TOKEN_TRAY_MAX_BYTES + 1024 * 1024, }); await app.register(cors, { origin: true, credentials: true, }); await app.register(cookie, { secret: config.SESSION_SECRET, }); await app.register(multipart, { limits: { fileSize: TOKEN_TRAY_MAX_BYTES, files: 1, fields: 8, }, }); 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, }, }); // Category event-art fallbacks (public, cacheable) const { createReadStream, existsSync } = await import("fs"); const pathMod = await import("path"); const fallbacksDir = [ pathMod.join(process.cwd(), "assets", "event-fallbacks"), pathMod.join(__dirname, "..", "assets", "event-fallbacks"), pathMod.join(__dirname, "assets", "event-fallbacks"), ].find((d) => existsSync(d)); app.get<{ Params: { file: string } }>("/assets/event-fallbacks/:file", async (request, reply) => { const file = request.params.file || ""; if (!/^[a-z]+\.jpg$/.test(file) || !fallbacksDir) { return reply.code(404).send({ error: "NOT_FOUND" }); } const full = pathMod.join(fallbacksDir, file); if (!existsSync(full)) return reply.code(404).send({ error: "NOT_FOUND" }); reply.header("Cache-Control", "public, max-age=86400, immutable"); return reply.type("image/jpeg").send(createReadStream(full)); }); 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); registerSiteRoutes(app, config); const downloads = registerDownloadRoutes(app, config); registerViewerRoutes(app, config, downloads); const googlePlay = registerGooglePlayRoutes(app, config); registerSettingsRoutes(app, config, googlePlay); const { registerAgentRoutes } = await import("./agent/routes"); registerAgentRoutes(app, config); startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS); startIptvEpgWarmup(config); startOdidoMpdRefreshWorker(() => config.SESSION_SECRET); startViaplayPreLiveWorker(() => config.SESSION_SECRET); 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` ); await prisma.$executeRawUnsafe( `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "logo_url" TEXT` ); } catch (err) { console.warn("Could not ensure still_url/logo_url columns:", err); } // IPTV userlines (Xtream) — per gebruiker en/of legacy per addon-token. try { await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "iptv_lines" ( "id" TEXT NOT NULL, "addon_token_id" TEXT, "viewer_user_id" TEXT, "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( `ALTER TABLE "iptv_lines" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "iptv_lines" ALTER COLUMN "addon_token_id" DROP NOT NULL` ); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_addon_token_id_key" ON "iptv_lines"("addon_token_id")` ); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX IF NOT EXISTS "iptv_lines_viewer_user_id_key" ON "iptv_lines"("viewer_user_id")` ); await prisma.$executeRawUnsafe( `ALTER TABLE "addon_tokens" ADD COLUMN IF NOT EXISTS "viewer_user_id" TEXT` ); await prisma.$executeRawUnsafe( `CREATE INDEX IF NOT EXISTS "addon_tokens_viewer_user_id_idx" ON "addon_tokens"("viewer_user_id")` ); // Migreer legacy kijker→token koppeling naar viewer-owned IPTV await prisma.$executeRawUnsafe(` UPDATE "iptv_lines" AS l SET "viewer_user_id" = v."id" FROM "viewer_users" AS v WHERE v."iptv_addon_token_id" IS NOT NULL AND v."iptv_addon_token_id" = l."addon_token_id" AND l."viewer_user_id" IS NULL `); await prisma.$executeRawUnsafe(` UPDATE "addon_tokens" AS t SET "viewer_user_id" = v."id" FROM "viewer_users" AS v WHERE v."iptv_addon_token_id" IS NOT NULL AND v."iptv_addon_token_id" = t."id" AND t."viewer_user_id" IS NULL `); } 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( `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "release_date" TIMESTAMP(3)` ); await prisma.$executeRawUnsafe( `ALTER TABLE "movies" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3)` ); await prisma.$executeRawUnsafe( `ALTER TABLE "episodes" ADD COLUMN IF NOT EXISTS "library_added_at" TIMESTAMP(3)` ); await prisma.$executeRawUnsafe(` UPDATE "media_files" SET "discovered_at" = "last_seen" WHERE "last_seen" IS NOT NULL AND "last_seen" < "discovered_at" `); await prisma.$executeRawUnsafe(` UPDATE "movies" m SET "library_added_at" = src.min_d FROM ( SELECT movie_id, MIN(discovered_at) AS min_d FROM media_files WHERE movie_id IS NOT NULL AND available = true GROUP BY movie_id ) src WHERE m.id = src.movie_id AND m.library_added_at IS NULL `); await prisma.$executeRawUnsafe(` UPDATE "episodes" e SET "library_added_at" = src.min_d FROM ( SELECT episode_id, MIN(discovered_at) AS min_d FROM media_files WHERE episode_id IS NOT NULL AND available = true GROUP BY episode_id ) src WHERE e.id = src.episode_id AND e.library_added_at IS NULL `); await prisma.$executeRawUnsafe( `CREATE INDEX IF NOT EXISTS "movies_library_added_at_idx" ON "movies"("library_added_at")` ); await prisma.$executeRawUnsafe( `CREATE INDEX IF NOT EXISTS "movies_release_date_idx" ON "movies"("release_date")` ); await prisma.$executeRawUnsafe( `CREATE INDEX IF NOT EXISTS "episodes_library_added_at_idx" ON "episodes"("library_added_at")` ); } catch (err) { console.warn("Could not ensure library_added_at / release_date:", 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( `ALTER TABLE "media_files" ADD COLUMN IF NOT EXISTS "path" TEXT` ); await prisma.$executeRawUnsafe( `CREATE INDEX IF NOT EXISTS "media_files_path_idx" ON "media_files"("path")` ); 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")` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "release_tags" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_file_id" INTEGER` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_content" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_error" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT` ); await prisma.$executeRawUnsafe(` ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "films_host_path" TEXT NOT NULL DEFAULT '' `); await prisma.$executeRawUnsafe(` ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "series_host_path" TEXT NOT NULL DEFAULT '' `); await prisma.$executeRawUnsafe(` DO $$ BEGIN CREATE TYPE "DownloadLibraryTarget" AS ENUM ('FILMS', 'FOUR_K', 'SERIES'); EXCEPTION WHEN duplicate_object THEN null; END $$; `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K' `); await prisma.$executeRawUnsafe(` DO $$ BEGIN CREATE TYPE "DownloadSeriesMode" AS ENUM ('EPISODE', 'SEASON'); EXCEPTION WHEN duplicate_object THEN null; END $$; `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "series_mode" "DownloadSeriesMode" `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "season_number" INTEGER `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_number" INTEGER `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_title" TEXT `); await prisma.$executeRawUnsafe(` ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episodes_json" TEXT `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "integration_settings" ( "id" TEXT NOT NULL, "api_key_enc" TEXT, "username" TEXT, "password_enc" TEXT, "user_agent" TEXT, "enabled" BOOLEAN NOT NULL DEFAULT true, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe( `ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "config_json" TEXT` ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "library_subtitles" ( "id" TEXT NOT NULL, "movie_id" TEXT, "tmdb_id" INTEGER, "imdb_id" TEXT, "language" TEXT NOT NULL DEFAULT 'nl', "format" TEXT NOT NULL DEFAULT 'srt', "content" TEXT NOT NULL, "file_name" TEXT, "download_job_id" TEXT, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "library_subtitles_pkey" PRIMARY KEY ("id") ) `); } catch (err) { console.warn("Could not ensure download tables:", err); } try { await prisma.$executeRawUnsafe(` DO $$ BEGIN CREATE TYPE "WatchMediaType" AS ENUM ('MOVIE', 'EPISODE'); EXCEPTION WHEN duplicate_object THEN null; END $$; `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "viewer_users" ( "id" TEXT NOT NULL, "email" TEXT NOT NULL, "password_hash" TEXT NOT NULL, "name" TEXT, "enabled" BOOLEAN NOT NULL DEFAULT true, "app_access" BOOLEAN NOT NULL DEFAULT true, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "viewer_users_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX IF NOT EXISTS "viewer_users_email_key" ON "viewer_users"("email")` ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "viewer_devices" ( "id" TEXT NOT NULL, "viewer_user_id" TEXT NOT NULL, "name" TEXT NOT NULL, "platform" TEXT NOT NULL DEFAULT 'android_tv', "refresh_token_hash" TEXT NOT NULL, "last_seen_at" TIMESTAMP(3), "revoked" BOOLEAN NOT NULL DEFAULT false, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "viewer_devices_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX IF NOT EXISTS "viewer_devices_refresh_token_hash_key" ON "viewer_devices"("refresh_token_hash")` ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "device_link_codes" ( "id" TEXT NOT NULL, "code_hash" TEXT NOT NULL, "code_display" TEXT, "device_name" TEXT NOT NULL, "platform" TEXT NOT NULL DEFAULT 'android_tv', "expires_at" TIMESTAMP(3) NOT NULL, "consumed_at" TIMESTAMP(3), "viewer_user_id" TEXT, "claim_token_hash" TEXT, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "device_link_codes_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe( `CREATE UNIQUE INDEX IF NOT EXISTS "device_link_codes_code_hash_key" ON "device_link_codes"("code_hash")` ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "watch_progress" ( "id" TEXT NOT NULL, "viewer_user_id" TEXT NOT NULL, "media_type" "WatchMediaType" NOT NULL, "movie_id" TEXT, "episode_id" TEXT, "series_id" TEXT, "position_ms" INTEGER NOT NULL DEFAULT 0, "duration_ms" INTEGER NOT NULL DEFAULT 0, "completed" BOOLEAN NOT NULL DEFAULT false, "hidden_from_continue" BOOLEAN NOT NULL DEFAULT false, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "watch_progress_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "viewer_shelf_preferences" ( "id" TEXT NOT NULL, "viewer_user_id" TEXT NOT NULL, "shelf_id" TEXT NOT NULL, "visible" BOOLEAN NOT NULL DEFAULT true, "sort_order" INTEGER NOT NULL DEFAULT 0, CONSTRAINT "viewer_shelf_preferences_pkey" PRIMARY KEY ("id") ) `); await prisma.$executeRawUnsafe(` DO $$ BEGIN CREATE TYPE "GooglePlaySyncStatus" AS ENUM ( 'NOT_ENABLED', 'PENDING_ADD', 'ACTIVE', 'PENDING_REMOVE', 'REMOVED', 'ERROR' ); EXCEPTION WHEN duplicate_object THEN null; END $$; `); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_email" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_access" BOOLEAN NOT NULL DEFAULT false` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_membership_id" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_status" "GooglePlaySyncStatus" NOT NULL DEFAULT 'NOT_ENABLED'` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_last_synced_at" TIMESTAMP(3)` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_users" ADD COLUMN IF NOT EXISTS "google_play_sync_error" TEXT` ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "google_play_access_audit_logs" ( "id" TEXT NOT NULL, "admin_user_id" TEXT, "admin_email" TEXT, "viewer_user_id" TEXT NOT NULL, "target_email" TEXT NOT NULL, "action" TEXT NOT NULL, "result" TEXT NOT NULL, "membership_id" TEXT, "error_message" TEXT, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "google_play_access_audit_logs_pkey" PRIMARY KEY ("id") ) `); } catch (err) { console.warn("Could not ensure viewer client tables:", err); } try { await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "avatar_image" TEXT` ); await prisma.$executeRawUnsafe( `ALTER TABLE "viewer_profiles" ADD COLUMN IF NOT EXISTS "show_avatar" BOOLEAN NOT NULL DEFAULT true` ); } catch (err) { console.warn("Could not ensure viewer_profiles avatar 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: klein stukje IMDb backfill na start (niet de hele library — dat hield CPU/TMDB bezig). // Rest via Admin → metadata tools of Stremio lazy backfill. if (config.TMDB_API_KEY) { const metadata = createMetadataService(config); setTimeout(() => { void metadata .backfillAllImdbIds({ delayMs: 200, limit: 40 }) .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); });