657 lines
25 KiB
TypeScript
657 lines
25 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 { 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";
|
|
|
|
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,
|
|
});
|
|
|
|
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,
|
|
},
|
|
});
|
|
|
|
// 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);
|
|
startGooglePlayReconciliationPoller(googlePlay, config.GOOGLE_PLAY_SYNC_INTERVAL_MS);
|
|
startIptvEpgWarmup(config);
|
|
startOdidoMpdRefreshWorker(() => 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);
|
|
});
|