From 8d8c49a7f0cdd8ccd4001e25e1ca1ba12cbba430 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Fri, 6 Mar 2026 17:31:14 -0500 Subject: [PATCH] Replace API route handlers with server actions across the app - Delete 10 API routes (stats, system-health, update-check, jobs/trigger, backup/restore, person, titles import/resolve, registration/status) and move logic into server actions in lib/actions/settings.ts and lib/actions/watchlist.ts - Refactor SystemHealthCards to accept initialData prop and hydrate Jotai atoms via useHydrateAtoms; replace useSystemHealth SWR hook with a lightweight useSystemHealthRefresh that calls getSystemHealthAction - Convert BackupRestoreSection to use restoreBackupAction + useTransition instead of a raw fetch call - Split SystemStatusCard, BackgroundJobsCard, and StorageCard into standalone components reading from systemHealthDataAtom - Make SetupPage async with connection() to opt into dynamic rendering --- app/(auth)/setup/page.tsx | 4 +- .../_components/backup-restore-section.tsx | 46 +- .../_components/system-health-section.tsx | 505 +++++++++--------- app/(pages)/settings/page.tsx | 16 +- app/api/admin/jobs/trigger/route.ts | 35 -- app/api/admin/system-health/route.ts | 16 - app/api/admin/update-check/route.ts | 16 - app/api/backup/restore/route.ts | 35 -- app/api/person/[id]/filmography/route.ts | 18 - app/api/person/[id]/route.ts | 34 -- app/api/registration/status/route.ts | 16 - app/api/stats/route.ts | 45 -- app/api/titles/import/route.ts | 35 -- app/api/titles/resolve/route.ts | 35 -- app/layout.tsx | 2 +- components/update-toast.tsx | 46 +- hooks/use-system-health.ts | 19 - lib/actions/settings.ts | 56 +- lib/actions/watchlist.ts | 23 + lib/atoms/stats.ts | 17 +- lib/atoms/system-health.ts | 7 + 21 files changed, 396 insertions(+), 630 deletions(-) delete mode 100644 app/api/admin/jobs/trigger/route.ts delete mode 100644 app/api/admin/system-health/route.ts delete mode 100644 app/api/admin/update-check/route.ts delete mode 100644 app/api/backup/restore/route.ts delete mode 100644 app/api/person/[id]/filmography/route.ts delete mode 100644 app/api/person/[id]/route.ts delete mode 100644 app/api/registration/status/route.ts delete mode 100644 app/api/stats/route.ts delete mode 100644 app/api/titles/import/route.ts delete mode 100644 app/api/titles/resolve/route.ts delete mode 100644 hooks/use-system-health.ts create mode 100644 lib/atoms/system-health.ts diff --git a/app/(auth)/setup/page.tsx b/app/(auth)/setup/page.tsx index 0e5d44f..2c10698 100644 --- a/app/(auth)/setup/page.tsx +++ b/app/(auth)/setup/page.tsx @@ -1,8 +1,10 @@ import { redirect } from "next/navigation"; +import { connection } from "next/server"; import { isTmdbConfigured } from "@/lib/config"; import { SetupForm } from "./_components/setup-form"; -export default function SetupPage() { +export default async function SetupPage() { + await connection(); if (isTmdbConfigured()) redirect("/"); return ; } diff --git a/app/(pages)/settings/_components/backup-restore-section.tsx b/app/(pages)/settings/_components/backup-restore-section.tsx index 5d4e9d4..db1fbbe 100644 --- a/app/(pages)/settings/_components/backup-restore-section.tsx +++ b/app/(pages)/settings/_components/backup-restore-section.tsx @@ -1,7 +1,7 @@ "use client"; import { IconCloudUpload } from "@tabler/icons-react"; -import { useRef, useState } from "react"; +import { useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -16,35 +16,29 @@ import { import { Button } from "@/components/ui/button"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; import { Spinner } from "@/components/ui/spinner"; +import { restoreBackupAction } from "@/lib/actions/settings"; export function BackupRestoreSection() { - const [restoring, setRestoring] = useState(false); + const [isPending, startTransition] = useTransition(); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); - async function handleRestore(file: File) { - setRestoring(true); - try { - const formData = new FormData(); - formData.append("file", file); - const res = await fetch("/api/backup/restore", { - method: "POST", - body: formData, - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Restore failed"); + function handleRestore(file: File) { + const formData = new FormData(); + formData.append("file", file); + startTransition(async () => { + try { + await restoreBackupAction(formData); + toast.success("Database restored. Reloading..."); + setTimeout(() => window.location.reload(), 1500); + } catch (err) { + const message = err instanceof Error ? err.message : "Restore failed"; + toast.error(message); + } finally { + if (fileInputRef.current) fileInputRef.current.value = ""; } - toast.success("Database restored. Reloading..."); - setTimeout(() => window.location.reload(), 1500); - } catch (err) { - const message = err instanceof Error ? err.message : "Restore failed"; - toast.error(message); - } finally { - setRestoring(false); - if (fileInputRef.current) fileInputRef.current.value = ""; - } + }); } return ( @@ -116,10 +110,10 @@ export function BackupRestoreSection() { diff --git a/app/(pages)/settings/_components/system-health-section.tsx b/app/(pages)/settings/_components/system-health-section.tsx index 865ff13..bdecb31 100644 --- a/app/(pages)/settings/_components/system-health-section.tsx +++ b/app/(pages)/settings/_components/system-health-section.tsx @@ -9,7 +9,9 @@ import { IconPlayerPlay, IconRefresh, } from "@tabler/icons-react"; -import { useEffect, useState } from "react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { useHydrateAtoms } from "jotai/utils"; +import { useState } from "react"; import { toast } from "sonner"; import { StatusDot } from "@/components/status-dot"; import { Button } from "@/components/ui/button"; @@ -34,8 +36,15 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useSystemHealth } from "@/hooks/use-system-health"; import { useTimeAgo } from "@/hooks/use-time-ago"; +import { + getSystemHealthAction, + triggerJobAction, +} from "@/lib/actions/settings"; +import { + systemHealthDataAtom, + systemHealthRefreshingAtom, +} from "@/lib/atoms/system-health"; import type { SystemHealthData } from "@/lib/services/system-health"; const JOB_LABELS: Record = { @@ -96,7 +105,7 @@ function formatDuration(ms: number): string { return `${Math.round(ms / 60000)}m`; } -function SkeletonCards() { +export function SkeletonCards() { return (
{["status", "jobs", "storage"].map((s) => ( @@ -128,237 +137,47 @@ function LiveTimeAgo({ return <>{text}; } -/** Renders 3 separate cards: System status, Background jobs, Storage */ -export function SystemHealthCards() { - const { data, error, isLoading, isValidating, refresh } = useSystemHealth(); +function useSystemHealthRefresh() { + const [isRefreshing, setRefreshing] = useAtom(systemHealthRefreshingAtom); + const setData = useSetAtom(systemHealthDataAtom); - useEffect(() => { - if (error && !isLoading) { + async function refresh() { + setRefreshing(true); + try { + const newData = await getSystemHealthAction(); + setData(newData); + } catch { toast.error("Failed to refresh system health"); + } finally { + setRefreshing(false); } - }, [error, isLoading]); + } - if (isLoading) return ; - if (!data) return null; + return { isRefreshing, refresh }; +} + +/** Hydrates the system health atom and renders the 3 cards */ +export function SystemHealthCards({ + initialData, +}: { + initialData: SystemHealthData; +}) { + useHydrateAtoms([ + [systemHealthDataAtom, initialData], + [systemHealthRefreshingAtom, false], + ]); return (
- {/* ── Card 1: System Status ── */} - - -
-
-
- -
-
- Health status - - Checked - -
-
- -
-
- - {/* Database */} - -
- - Database - - - {formatBytes(data.database.dbSizeBytes)} - {data.database.walSizeBytes > 0 && - ` + ${formatBytes(data.database.walSizeBytes)} WAL`} - -
-
- - {/* TMDB */} - -
- - TMDB API - - {!data.tmdb.tokenConfigured ? ( - <> - - - Not configured - - - ) : data.tmdb.connected && data.tmdb.tokenValid ? ( - <> - - Connected - - {data.tmdb.responseTimeMs}ms - - - ) : data.tmdb.connected && !data.tmdb.tokenValid ? ( - <> - - Invalid token - - ) : ( - <> - - Unreachable - {data.tmdb.error && ( - - {data.tmdb.error} - - )} - - )} -
-
- - {/* Environment */} - -
- - Environment - {data.environment.dataDirWritable ? ( - - ) : ( - - )} - -
- {data.environment.envVars - .filter((env) => env.value !== null) - .map((env) => ( -
- - {env.name}= - - - {env.value} - -
- ))} -
-
-
-
- - {/* ── Card 2: Background Jobs ── */} - - - {/* ── Card 3: Storage ── */} - - -
-
-
- -
-
- Storage - - Image cache and backup disk usage - -
-
- -
-
- - {/* Image cache */} - -
- - Image cache - - {data.imageCache.enabled ? ( - - {formatBytes(data.imageCache.totalSizeBytes)} - - ) : null} -
- {data.imageCache.enabled ? ( - <> -

- {data.imageCache.imageCount.toLocaleString()} cached images -

-

- {Object.entries(data.imageCache.categories) - .map(([name, cat]) => `${name} ${cat.count}`) - .join(" · ")} -

- - ) : ( -

- - Disabled -

- )} -
- - {/* Backup summary */} - -
- - Backups - - {data.backups.backupCount > 0 && ( - - {formatBytes(data.backups.totalSizeBytes)} - - )} -
- {data.backups.backupCount > 0 ? ( -

- {data.backups.backupCount} backups · last{" "} - -

- ) : ( -

- - No backups yet -

- )} -
-
+ + +
); } -function RefreshButton({ - isValidating, - onRefresh, -}: { - isValidating: boolean; - onRefresh: () => void; -}) { +function RefreshButton() { + const { isRefreshing, refresh } = useSystemHealthRefresh(); return ( } > - {isValidating ? : } + {isRefreshing ? : } Refresh ); } -/** Background Jobs card with table layout and manual trigger */ -function BackgroundJobsCard({ - jobs, - isValidating, - onRefresh, -}: { - jobs: SystemHealthData["jobs"]; - isValidating: boolean; - onRefresh: () => void; -}) { +function SystemStatusCard() { + const data = useAtomValue(systemHealthDataAtom); + + return ( + + +
+
+
+ +
+
+ Health status + + Checked + +
+
+ +
+
+ + {/* Database */} + +
+ + Database + + + {formatBytes(data.database.dbSizeBytes)} + {data.database.walSizeBytes > 0 && + ` + ${formatBytes(data.database.walSizeBytes)} WAL`} + +
+
+ + {/* TMDB */} + +
+ + TMDB API + + {!data.tmdb.tokenConfigured ? ( + <> + + + Not configured + + + ) : data.tmdb.connected && data.tmdb.tokenValid ? ( + <> + + Connected + + {data.tmdb.responseTimeMs}ms + + + ) : data.tmdb.connected && !data.tmdb.tokenValid ? ( + <> + + Invalid token + + ) : ( + <> + + Unreachable + {data.tmdb.error && ( + + {data.tmdb.error} + + )} + + )} +
+
+ + {/* Environment */} + +
+ + Environment + {data.environment.dataDirWritable ? ( + + ) : ( + + )} + +
+ {data.environment.envVars + .filter((env) => env.value !== null) + .map((env) => ( +
+ {env.name}= + + {env.value} + +
+ ))} +
+
+
+
+ ); +} + +function BackgroundJobsCard() { + const data = useAtomValue(systemHealthDataAtom); + const { refresh } = useSystemHealthRefresh(); const [triggeringJob, setTriggeringJob] = useState(null); const handleTrigger = async (jobName: string) => { setTriggeringJob(jobName); try { - const res = await fetch("/api/admin/jobs/trigger", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jobName }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Failed to trigger job"); - } + await triggerJobAction(jobName); toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`); // Refresh after a brief delay so the run shows up - setTimeout(onRefresh, 1500); + setTimeout(refresh, 1500); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to trigger job"); } finally { @@ -414,16 +332,14 @@ function BackgroundJobsCard({ } }; - const sortedJobs = [...jobs].sort((a, b) => { - // Disabled jobs go to the bottom + const sortedJobs = [...data.jobs].sort((a, b) => { if (a.disabled !== b.disabled) return a.disabled ? 1 : -1; - // Active jobs sorted by next run time (soonest first, null last) if (!a.nextRunAt && !b.nextRunAt) return 0; if (!a.nextRunAt) return 1; if (!b.nextRunAt) return -1; return new Date(a.nextRunAt).getTime() - new Date(b.nextRunAt).getTime(); }); - const activeJobs = jobs.filter((j) => !j.disabled); + const activeJobs = data.jobs.filter((j) => !j.disabled); const healthyCount = activeJobs.filter( (j) => j.lastStatus === "success", ).length; @@ -446,7 +362,7 @@ function BackgroundJobsCard({
- + @@ -618,3 +534,90 @@ function BackgroundJobsCard({ ); } + +function StorageCard() { + const data = useAtomValue(systemHealthDataAtom); + + return ( + + +
+
+
+ +
+
+ Storage + + Image cache and backup disk usage + +
+
+ +
+
+ + {/* Image cache */} + +
+ + Image cache + + {data.imageCache.enabled ? ( + + {formatBytes(data.imageCache.totalSizeBytes)} + + ) : null} +
+ {data.imageCache.enabled ? ( + <> +

+ {data.imageCache.imageCount.toLocaleString()} cached images +

+

+ {Object.entries(data.imageCache.categories) + .map(([name, cat]) => `${name} ${cat.count}`) + .join(" · ")} +

+ + ) : ( +

+ + Disabled +

+ )} +
+ + {/* Backup summary */} + +
+ + Backups + + {data.backups.backupCount > 0 && ( + + {formatBytes(data.backups.totalSizeBytes)} + + )} +
+ {data.backups.backupCount > 0 ? ( +

+ {data.backups.backupCount} backups · last{" "} + +

+ ) : ( +

+ + No backups yet +

+ )} +
+
+ ); +} diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx index 8919c44..a8af027 100644 --- a/app/(pages)/settings/page.tsx +++ b/app/(pages)/settings/page.tsx @@ -5,6 +5,7 @@ import { } from "@tabler/icons-react"; import { desc, eq } from "drizzle-orm"; import { redirect } from "next/navigation"; +import { Suspense } from "react"; import { TmdbLogo } from "@/components/tmdb-logo"; import { Card } from "@/components/ui/card"; import { getSession } from "@/lib/auth/session"; @@ -12,6 +13,7 @@ import { db } from "@/lib/db/client"; import { integrationEvents, integrations } from "@/lib/db/schema"; import { listBackups } from "@/lib/services/backup"; import { getSetting } from "@/lib/services/settings"; +import { getSystemHealth } from "@/lib/services/system-health"; import { getCachedUpdateCheck, isUpdateCheckEnabled, @@ -23,7 +25,10 @@ import { BackupSection } from "./_components/backup-section"; import { IntegrationsSection } from "./_components/integrations-section"; import { RegistrationSection } from "./_components/registration-section"; import { SettingsShell } from "./_components/settings-shell"; -import { SystemHealthCards } from "./_components/system-health-section"; +import { + SkeletonCards, + SystemHealthCards, +} from "./_components/system-health-section"; import { UpdateCheckSection } from "./_components/update-check-section"; export default async function SettingsPage() { @@ -193,7 +198,9 @@ export default async function SettingsPage() { Admin only - + }> + + {/* Security */} @@ -261,3 +268,8 @@ export default async function SettingsPage() { ); } + +async function SystemHealthLoader() { + const data = await getSystemHealth(); + return ; +} diff --git a/app/api/admin/jobs/trigger/route.ts b/app/api/admin/jobs/trigger/route.ts deleted file mode 100644 index 3948ad8..0000000 --- a/app/api/admin/jobs/trigger/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { triggerJob } from "@/lib/cron"; - -export async function POST(request: Request) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (session.user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } - const jobName = (body as { jobName?: unknown })?.jobName; - - if (!jobName || typeof jobName !== "string") { - return NextResponse.json( - { error: "Missing jobName in request body" }, - { status: 400 }, - ); - } - - const triggered = await triggerJob(jobName); - if (!triggered) { - return NextResponse.json({ error: "Job not found" }, { status: 404 }); - } - - return NextResponse.json({ ok: true, jobName }); -} diff --git a/app/api/admin/system-health/route.ts b/app/api/admin/system-health/route.ts deleted file mode 100644 index 44b2fa0..0000000 --- a/app/api/admin/system-health/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { getSystemHealth } from "@/lib/services/system-health"; - -export async function GET() { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (session.user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const health = await getSystemHealth(); - return NextResponse.json(health); -} diff --git a/app/api/admin/update-check/route.ts b/app/api/admin/update-check/route.ts deleted file mode 100644 index 1bb6255..0000000 --- a/app/api/admin/update-check/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { getCachedUpdateCheck } from "@/lib/services/update-check"; - -export async function GET() { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (session.user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const result = getCachedUpdateCheck(); - return NextResponse.json(result); -} diff --git a/app/api/backup/restore/route.ts b/app/api/backup/restore/route.ts deleted file mode 100644 index b1e1331..0000000 --- a/app/api/backup/restore/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { restoreFromBackup } from "@/lib/services/backup"; - -const MAX_SIZE = 500 * 1024 * 1024; // 500MB - -export async function POST(req: Request) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (session.user.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - - const formData = await req.formData(); - const file = formData.get("file") as File | null; - if (!file) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); - } - - if (file.size > MAX_SIZE) { - return NextResponse.json({ error: "File too large" }, { status: 413 }); - } - - const buffer = Buffer.from(await file.arrayBuffer()); - - try { - await restoreFromBackup(buffer); - return NextResponse.json({ success: true }); - } catch (err) { - const message = err instanceof Error ? err.message : "Restore failed"; - return NextResponse.json({ error: message }, { status: 400 }); - } -} diff --git a/app/api/person/[id]/filmography/route.ts b/app/api/person/[id]/filmography/route.ts deleted file mode 100644 index 51ee3a0..0000000 --- a/app/api/person/[id]/filmography/route.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { fetchFullFilmography } from "@/lib/services/person"; - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - const filmography = await fetchFullFilmography(id); - - return NextResponse.json({ filmography }); -} diff --git a/app/api/person/[id]/route.ts b/app/api/person/[id]/route.ts deleted file mode 100644 index 9568aae..0000000 --- a/app/api/person/[id]/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { - getLocalFilmography, - getOrFetchPerson, - getOrFetchPersonByTmdbId, -} from "@/lib/services/person"; - -const TMDB_PATTERN = /^tmdb-(\d+)$/; - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { id } = await params; - - const tmdbMatch = TMDB_PATTERN.exec(id); - const person = tmdbMatch - ? await getOrFetchPersonByTmdbId(Number(tmdbMatch[1])) - : await getOrFetchPerson(id); - - if (!person) { - return NextResponse.json({ error: "Person not found" }, { status: 404 }); - } - - const filmography = getLocalFilmography(person.id); - - return NextResponse.json({ person, filmography }); -} diff --git a/app/api/registration/status/route.ts b/app/api/registration/status/route.ts deleted file mode 100644 index aee1a0c..0000000 --- a/app/api/registration/status/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextResponse } from "next/server"; -import { - getOidcProviderName, - isOidcConfigured, - isPasswordLoginDisabled, -} from "@/lib/config"; -import { isRegistrationOpen } from "@/lib/services/settings"; - -export async function GET() { - return NextResponse.json({ - registrationOpen: await isRegistrationOpen(), - oidcEnabled: isOidcConfigured(), - oidcProviderName: isOidcConfigured() ? getOidcProviderName() : null, - passwordLoginDisabled: isPasswordLoginDisabled(), - }); -} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts deleted file mode 100644 index 4293dc8..0000000 --- a/app/api/stats/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSession } from "@/lib/auth/session"; -import { - getWatchCount, - getWatchHistory, - type TimePeriod, -} from "@/lib/services/discovery"; - -const validTypes = ["movies", "episodes"] as const; -const validPeriods: TimePeriod[] = [ - "today", - "this_week", - "this_month", - "this_year", -]; - -export async function GET(request: Request) { - const session = await getSession(); - if (!session) - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - const { searchParams } = new URL(request.url); - const type = searchParams.get("type"); - const period = searchParams.get("period"); - - if ( - !type || - !period || - !validTypes.includes(type as (typeof validTypes)[number]) || - !validPeriods.includes(period as TimePeriod) - ) { - return NextResponse.json({ error: "Invalid parameters" }, { status: 400 }); - } - - const typedType = type as "movies" | "episodes"; - const typedPeriod = period as TimePeriod; - const count = getWatchCount(session.user.id, typedType, typedPeriod); - - if (searchParams.get("history") === "true") { - const history = getWatchHistory(session.user.id, typedType, typedPeriod); - return NextResponse.json({ count, history }); - } - - return NextResponse.json({ count }); -} diff --git a/app/api/titles/import/route.ts b/app/api/titles/import/route.ts deleted file mode 100644 index b134b2b..0000000 --- a/app/api/titles/import/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getSession } from "@/lib/auth/session"; -import { importTitle } from "@/lib/services/metadata"; - -const bodySchema = z.object({ - tmdbId: z.coerce.number().int().positive(), - type: z.enum(["movie", "tv"]), -}); - -export async function POST(req: NextRequest) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const result = bodySchema.safeParse(await req.json().catch(() => null)); - if (!result.success) { - return NextResponse.json( - { error: "tmdbId (positive integer) and type (movie|tv) are required" }, - { status: 400 }, - ); - } - - try { - const title = await importTitle(result.data.tmdbId, result.data.type); - return NextResponse.json(title); - } catch { - return NextResponse.json( - { error: "Failed to import title" }, - { status: 502 }, - ); - } -} diff --git a/app/api/titles/resolve/route.ts b/app/api/titles/resolve/route.ts deleted file mode 100644 index bbe5b08..0000000 --- a/app/api/titles/resolve/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { z } from "zod"; -import { getSession } from "@/lib/auth/session"; -import { importTitle } from "@/lib/services/metadata"; - -const bodySchema = z.object({ - tmdbId: z.coerce.number().int().positive(), - type: z.enum(["movie", "tv"]), -}); - -export async function POST(req: NextRequest) { - const session = await getSession(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const result = bodySchema.safeParse(await req.json().catch(() => null)); - if (!result.success) { - return NextResponse.json( - { error: "tmdbId (positive integer) and type (movie|tv) are required" }, - { status: 400 }, - ); - } - - try { - const title = await importTitle(result.data.tmdbId, result.data.type); - return NextResponse.json({ id: title?.id }); - } catch { - return NextResponse.json( - { error: "Failed to resolve title" }, - { status: 502 }, - ); - } -} diff --git a/app/layout.tsx b/app/layout.tsx index 41c9f79..20724da 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,7 +25,6 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "Sofa", description: "Track your movies and TV shows", - themeColor: "#090706", }; export const viewport: Viewport = { @@ -34,6 +33,7 @@ export const viewport: Viewport = { maximumScale: 1, userScalable: false, viewportFit: "cover", + themeColor: "#090706", }; export default function RootLayout({ diff --git a/components/update-toast.tsx b/components/update-toast.tsx index a2bdee6..26171a2 100644 --- a/components/update-toast.tsx +++ b/components/update-toast.tsx @@ -3,6 +3,7 @@ import { useAtom } from "jotai"; import { useEffect } from "react"; import { toast } from "sonner"; +import { getUpdateCheckAction } from "@/lib/actions/settings"; import { updateToastShownAtom } from "@/lib/atoms/update-check"; export function UpdateToast() { @@ -11,38 +12,21 @@ export function UpdateToast() { useEffect(() => { if (shown) return; - async function check() { - try { - const res = await fetch("/api/admin/update-check"); - if (!res.ok) return; + getUpdateCheckAction().then((data) => { + if (!data?.updateAvailable) return; - const data = (await res.json()) as { - updateAvailable: boolean; - currentVersion: string; - latestVersion: string | null; - releaseUrl: string | null; - }; - - if (data.updateAvailable) { - setShown(true); - toast.info(`Sofa v${data.latestVersion} is available`, { - description: `You're running v${data.currentVersion}.`, - duration: 15_000, - action: data.releaseUrl - ? { - label: "View release", - onClick: () => - window.open(data.releaseUrl as string, "_blank"), - } - : undefined, - }); - } - } catch { - // Silently ignore network errors - } - } - - check(); + setShown(true); + toast.info(`Sofa v${data.latestVersion} is available`, { + description: `You're running v${data.currentVersion}.`, + duration: 15_000, + action: data.releaseUrl + ? { + label: "View release", + onClick: () => window.open(data.releaseUrl as string, "_blank"), + } + : undefined, + }); + }); }, [shown, setShown]); return null; diff --git a/hooks/use-system-health.ts b/hooks/use-system-health.ts deleted file mode 100644 index 3fc5155..0000000 --- a/hooks/use-system-health.ts +++ /dev/null @@ -1,19 +0,0 @@ -import useSWR from "swr"; -import type { SystemHealthData } from "@/lib/services/system-health"; -import { fetcher } from "@/lib/swr/fetcher"; - -export function useSystemHealth() { - const { data, error, isLoading, isValidating, mutate } = - useSWR("/api/admin/system-health", fetcher, { - revalidateOnFocus: false, - dedupingInterval: 10_000, - }); - - return { - data: data ?? null, - error, - isLoading, - isValidating, - refresh: () => mutate(), - }; -} diff --git a/lib/actions/settings.ts b/lib/actions/settings.ts index a800bc9..7662d49 100644 --- a/lib/actions/settings.ts +++ b/lib/actions/settings.ts @@ -3,7 +3,7 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { requireAdmin, requireSession } from "@/lib/auth/session"; -import { type BackupFrequency, rescheduleBackup } from "@/lib/cron"; +import { type BackupFrequency, rescheduleBackup, triggerJob } from "@/lib/cron"; import { db } from "@/lib/db/client"; import { integrations } from "@/lib/db/schema"; import { @@ -11,8 +11,17 @@ import { createBackup, deleteBackup, listBackups, + restoreFromBackup, } from "@/lib/services/backup"; import { getSetting, setSetting } from "@/lib/services/settings"; +import { + getSystemHealth, + type SystemHealthData, +} from "@/lib/services/system-health"; +import { + getCachedUpdateCheck, + type UpdateCheckResult, +} from "@/lib/services/update-check"; const providerSchema = z.enum(["plex", "jellyfin", "emby", "sonarr", "radarr"]); @@ -207,3 +216,48 @@ export async function setBackupScheduleAction( setSetting("backupScheduleDow", String(parsed.dayOfWeek)); rescheduleBackup(); } + +// --- System health actions --- + +export async function getSystemHealthAction(): Promise { + await requireAdmin(); + return getSystemHealth(); +} + +// --- Job trigger action --- + +export async function triggerJobAction( + jobName: string, +): Promise<{ ok: boolean }> { + await requireAdmin(); + if (!jobName || typeof jobName !== "string") { + throw new Error("Missing job name"); + } + const triggered = await triggerJob(jobName); + if (!triggered) throw new Error("Job not found"); + return { ok: true }; +} + +// --- Backup restore action --- + +const MAX_RESTORE_SIZE = 500 * 1024 * 1024; // 500MB + +export async function restoreBackupAction(formData: FormData): Promise { + await requireAdmin(); + const file = formData.get("file") as File | null; + if (!file) throw new Error("No file provided"); + if (file.size > MAX_RESTORE_SIZE) throw new Error("File too large"); + const buffer = Buffer.from(await file.arrayBuffer()); + await restoreFromBackup(buffer); +} + +// --- Update check action --- + +export async function getUpdateCheckAction(): Promise { + try { + await requireAdmin(); + return getCachedUpdateCheck(); + } catch { + return null; + } +} diff --git a/lib/actions/watchlist.ts b/lib/actions/watchlist.ts index 935ef47..a25a26a 100644 --- a/lib/actions/watchlist.ts +++ b/lib/actions/watchlist.ts @@ -1,9 +1,16 @@ "use server"; import { and, eq } from "drizzle-orm"; +import { z } from "zod"; import { getSession, requireSession } from "@/lib/auth/session"; import { db } from "@/lib/db/client"; import { userTitleStatus } from "@/lib/db/schema"; +import { + getWatchCount, + getWatchHistory, + type HistoryBucket, + type TimePeriod, +} from "@/lib/services/discovery"; import { importTitle } from "@/lib/services/metadata"; import { getEpisodeProgressByTmdbIds, @@ -55,3 +62,19 @@ export async function quickAddToWatchlist( setTitleStatus(userId, title.id, "watchlist"); return { success: true, titleId: title.id, alreadyAdded: false }; } + +const statsSchema = z.object({ + type: z.enum(["movies", "episodes"]), + period: z.enum(["today", "this_week", "this_month", "this_year"]), +}); + +export async function getStatsAction( + type: "movies" | "episodes", + period: TimePeriod, +): Promise<{ count: number; history: HistoryBucket[] }> { + const session = await requireSession(); + const parsed = statsSchema.parse({ type, period }); + const count = getWatchCount(session.user.id, parsed.type, parsed.period); + const history = getWatchHistory(session.user.id, parsed.type, parsed.period); + return { count, history }; +} diff --git a/lib/atoms/stats.ts b/lib/atoms/stats.ts index 3a291e1..b835e68 100644 --- a/lib/atoms/stats.ts +++ b/lib/atoms/stats.ts @@ -1,28 +1,19 @@ import { atom } from "jotai"; import { unwrap } from "jotai/utils"; -import type { HistoryBucket, TimePeriod } from "@/lib/services/discovery"; +import { getStatsAction } from "@/lib/actions/watchlist"; +import type { TimePeriod } from "@/lib/services/discovery"; export const moviePeriodAtom = atom("this_month"); export const episodePeriodAtom = atom("this_week"); -async function fetchStats( - type: "movies" | "episodes", - period: TimePeriod, -): Promise<{ count: number; history: HistoryBucket[] }> { - const res = await fetch( - `/api/stats?type=${type}&period=${period}&history=true`, - ); - return res.json(); -} - const movieStatsAsyncAtom = atom(async (get) => { const period = get(moviePeriodAtom); - return fetchStats("movies", period); + return getStatsAction("movies", period); }); const episodeStatsAsyncAtom = atom(async (get) => { const period = get(episodePeriodAtom); - return fetchStats("episodes", period); + return getStatsAction("episodes", period); }); export const movieStatsAtom = unwrap(movieStatsAsyncAtom); diff --git a/lib/atoms/system-health.ts b/lib/atoms/system-health.ts new file mode 100644 index 0000000..227c320 --- /dev/null +++ b/lib/atoms/system-health.ts @@ -0,0 +1,7 @@ +import { atom } from "jotai"; +import type { SystemHealthData } from "@/lib/services/system-health"; + +export const systemHealthDataAtom = atom( + undefined as unknown as SystemHealthData, +); +export const systemHealthRefreshingAtom = atom(false);