import { access, constants, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { count, desc, eq } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { cronRuns, episodes, titles, user } from "@/lib/db/schema"; import { listBackups } from "@/lib/services/backup"; import { imageCacheEnabled } from "@/lib/services/image-cache"; const DATA_DIR = process.env.DATA_DIR || "./data"; const DATABASE_URL = process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db"); const CACHE_DIR = process.env.CACHE_DIR ? path.join(process.env.CACHE_DIR, "images") : path.join(DATA_DIR, "images"); const TMDB_API_BASE_URL = process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3"; export interface SystemHealthData { database: { dbSizeBytes: number; walSizeBytes: number; titleCount: number; episodeCount: number; userCount: number; }; tmdb: { connected: boolean; tokenValid: boolean; tokenConfigured: boolean; responseTimeMs: number | null; error: string | null; }; jobs: { jobName: string; cronPattern: string | null; nextRunAt: string | null; lastRunAt: string | null; lastDurationMs: number | null; lastStatus: "running" | "success" | "error" | null; lastError: string | null; isCurrentlyRunning: boolean; }[]; imageCache: { enabled: boolean; totalSizeBytes: number; imageCount: number; categories: Record; }; backups: { lastBackupAt: string | null; lastBackupAgeHours: number | null; backupCount: number; totalSizeBytes: number; }; environment: { dataDir: string; dataDirWritable: boolean; envVars: { name: string; value: string | null }[]; }; checkedAt: string; } const JOB_NAMES = [ "nightlyRefreshLibrary", "refreshAvailability", "refreshRecommendations", "refreshTvChildren", "cacheImages", "scheduledBackup", "updateCheck", ]; function getDatabaseHealth(): SystemHealthData["database"] { let dbSizeBytes = 0; let walSizeBytes = 0; try { dbSizeBytes = Bun.file(DATABASE_URL).size; } catch {} try { walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size; } catch {} const [titleCount] = db.select({ count: count() }).from(titles).all(); const [episodeCount] = db.select({ count: count() }).from(episodes).all(); const [userCount] = db.select({ count: count() }).from(user).all(); return { dbSizeBytes, walSizeBytes, titleCount: titleCount.count, episodeCount: episodeCount.count, userCount: userCount.count, }; } async function getTmdbHealth(): Promise { const token = process.env.TMDB_API_READ_ACCESS_TOKEN; if (!token) { return { connected: false, tokenValid: false, tokenConfigured: false, responseTimeMs: null, error: null, }; } try { const start = performance.now(); const res = await fetch(`${TMDB_API_BASE_URL}/configuration`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/json", }, signal: AbortSignal.timeout(5000), }); const responseTimeMs = Math.round(performance.now() - start); if (res.ok) { return { connected: true, tokenValid: true, tokenConfigured: true, responseTimeMs, error: null, }; } return { connected: true, tokenValid: false, tokenConfigured: true, responseTimeMs, error: `HTTP ${res.status}`, }; } catch (err) { return { connected: false, tokenValid: false, tokenConfigured: true, responseTimeMs: null, error: err instanceof Error ? err.message : String(err), }; } } function getJobsHealth(): SystemHealthData["jobs"] { // Lazy-import to avoid circular dependency issues at module level const { getJobSchedules } = require("@/lib/cron") as typeof import("@/lib/cron"); const schedules = getJobSchedules(); const scheduleMap = new Map(schedules.map((s) => [s.jobName, s])); // Fetch only the latest cron run per job (index-optimized LIMIT 1 each) const latestByJob = new Map(); for (const jobName of JOB_NAMES) { const latest = db .select() .from(cronRuns) .where(eq(cronRuns.jobName, jobName)) .orderBy(desc(cronRuns.startedAt)) .limit(1) .get(); if (latest) latestByJob.set(jobName, latest); } return JOB_NAMES.map((jobName) => { const latest = latestByJob.get(jobName); const isCurrentlyRunning = latest?.status === "running"; const schedule = scheduleMap.get(jobName); // Prefer the in-memory durationMs column; fall back to timestamp diff let lastDurationMs: number | null = latest?.durationMs ?? null; if (lastDurationMs === null && latest?.finishedAt && latest.startedAt) { lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime(); } return { jobName, cronPattern: schedule?.pattern ?? null, nextRunAt: schedule?.nextRunAt ?? null, lastRunAt: latest?.startedAt?.toISOString() ?? null, lastDurationMs, lastStatus: (latest?.status as "running" | "success" | "error") ?? null, lastError: latest?.errorMessage ?? null, isCurrentlyRunning, }; }); } async function getImageCacheHealth(): Promise { const enabled = imageCacheEnabled(); if (!enabled) { return { enabled: false, totalSizeBytes: 0, imageCount: 0, categories: {} }; } const categoryNames = ["posters", "backdrops", "stills", "logos"]; const categories: Record = {}; let totalSizeBytes = 0; let imageCount = 0; await Promise.all( categoryNames.map(async (category) => { const dir = path.join(CACHE_DIR, category); try { const files = await readdir(dir); const sizes = await Promise.all( files.map(async (file) => { try { const s = await stat(path.join(dir, file)); return s.isFile() ? s.size : 0; } catch { return 0; } }), ); const sizeBytes = sizes.reduce((sum, s) => sum + s, 0); categories[category] = { count: files.length, sizeBytes }; totalSizeBytes += sizeBytes; imageCount += files.length; } catch { categories[category] = { count: 0, sizeBytes: 0 }; } }), ); return { enabled: true, totalSizeBytes, imageCount, categories }; } async function getBackupsHealth(): Promise { try { const backups = await listBackups(); const totalSizeBytes = backups.reduce((sum, b) => sum + b.sizeBytes, 0); const lastBackup = backups[0] ?? null; const lastBackupAt = lastBackup?.createdAt ?? null; const lastBackupAgeHours = lastBackupAt ? Math.round( (Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60), ) : null; return { lastBackupAt, lastBackupAgeHours, backupCount: backups.length, totalSizeBytes, }; } catch { return { lastBackupAt: null, lastBackupAgeHours: null, backupCount: 0, totalSizeBytes: 0, }; } } async function getEnvironmentHealth(): Promise< SystemHealthData["environment"] > { const resolvedDataDir = path.resolve(DATA_DIR); let dataDirWritable = false; try { await access(resolvedDataDir, constants.W_OK); dataDirWritable = true; } catch {} const redact = (val: string | undefined): string | null => { if (!val) return null; if (val.length <= 8) return "••••••••"; return `${val.slice(0, 4)}${"•".repeat(Math.min(val.length - 4, 24))}`; }; const env = (name: string) => process.env[name] ?? null; return { dataDir: resolvedDataDir, dataDirWritable, envVars: [ // Core { name: "DATA_DIR", value: resolvedDataDir }, { name: "LOG_LEVEL", value: env("LOG_LEVEL") ?? "info" }, // Auth { name: "BETTER_AUTH_URL", value: env("BETTER_AUTH_URL") }, { name: "BETTER_AUTH_SECRET", value: redact(env("BETTER_AUTH_SECRET") ?? undefined), }, // TMDB { name: "TMDB_API_READ_ACCESS_TOKEN", value: redact(env("TMDB_API_READ_ACCESS_TOKEN") ?? undefined), }, { name: "TMDB_API_BASE_URL", value: env("TMDB_API_BASE_URL") }, { name: "TMDB_IMAGE_BASE_URL", value: env("TMDB_IMAGE_BASE_URL") }, // Image cache { name: "IMAGE_CACHE_ENABLED", value: env("IMAGE_CACHE_ENABLED") ?? "true", }, // OIDC { name: "OIDC_ISSUER_URL", value: env("OIDC_ISSUER_URL") }, { name: "OIDC_CLIENT_ID", value: env("OIDC_CLIENT_ID") }, { name: "OIDC_CLIENT_SECRET", value: redact(env("OIDC_CLIENT_SECRET") ?? undefined), }, { name: "OIDC_PROVIDER_NAME", value: env("OIDC_PROVIDER_NAME") }, { name: "OIDC_AUTO_REGISTER", value: env("OIDC_AUTO_REGISTER") }, { name: "DISABLE_PASSWORD_LOGIN", value: env("DISABLE_PASSWORD_LOGIN") }, ], }; } export async function getSystemHealth(): Promise { const [tmdb, imageCache, backups, environment] = await Promise.all([ getTmdbHealth(), getImageCacheHealth(), getBackupsHealth(), getEnvironmentHealth(), ]); return { database: getDatabaseHealth(), tmdb, jobs: getJobsHealth(), imageCache, backups, environment, checkedAt: new Date().toISOString(), }; }