From 297eb6b55096e7216b2f079ef37852766b93b916 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 15 Mar 2026 10:37:35 -0400 Subject: [PATCH] feat(admin): add cache purge controls to settings Add `purgeMetadataCache` and `purgeImageCache` procedures that let admins free disk space on demand. Metadata purge removes un-enriched stub titles not in any user's library plus orphaned person records; image purge deletes all cached TMDB files from disk. Expose both as POST endpoints under `/admin/cache/` via the oRPC contract, implement the core logic in a new `@sofa/core/cache` module, and surface them in a new "Danger Zone" section on the Settings page with individual confirmation dialogs and a combined "Purge all" action. --- apps/server/src/orpc/procedures/admin.ts | 14 ++ apps/server/src/orpc/router.ts | 2 + .../components/settings/danger-section.tsx | 197 ++++++++++++++++++ apps/web/src/routes/_app/settings.tsx | 21 ++ docs/content/docs/api/admin.mdx | 20 +- docs/openapi.json | 84 ++++++++ packages/api/src/contract.ts | 26 +++ packages/api/src/schemas.ts | 22 ++ packages/core/package.json | 1 + packages/core/src/cache.ts | 149 +++++++++++++ packages/core/test/image-cache.test.ts | 13 +- packages/db/src/helpers.ts | 2 + 12 files changed, 544 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/components/settings/danger-section.tsx create mode 100644 packages/core/src/cache.ts diff --git a/apps/server/src/orpc/procedures/admin.ts b/apps/server/src/orpc/procedures/admin.ts index 06bafc6..23e8cd1 100644 --- a/apps/server/src/orpc/procedures/admin.ts +++ b/apps/server/src/orpc/procedures/admin.ts @@ -8,6 +8,10 @@ import { listBackups, restoreFromBackup, } from "@sofa/core/backup"; +import { + purgeImageCache as purgeImagesFn, + purgeMetadataCache as purgeMetadataFn, +} from "@sofa/core/cache"; import { getSetting, setSetting } from "@sofa/core/settings"; import { isTelemetryEnabled } from "@sofa/core/telemetry"; import { @@ -159,3 +163,13 @@ export const triggerJob = os.admin.triggerJob } return { ok: true as const }; }); + +// ─── Purge ──────────────────────────────────────────────────── + +export const purgeMetadataCache = os.admin.purgeMetadataCache + .use(admin) + .handler(() => purgeMetadataFn()); + +export const purgeImageCache = os.admin.purgeImageCache + .use(admin) + .handler(async () => purgeImagesFn()); diff --git a/apps/server/src/orpc/router.ts b/apps/server/src/orpc/router.ts index 086b6c6..ca4c973 100644 --- a/apps/server/src/orpc/router.ts +++ b/apps/server/src/orpc/router.ts @@ -81,6 +81,8 @@ export const implementedRouter = { telemetry: admin.telemetry, toggleTelemetry: admin.toggleTelemetry, triggerJob: admin.triggerJob, + purgeMetadataCache: admin.purgeMetadataCache, + purgeImageCache: admin.purgeImageCache, }, account: { updateName: account.updateName, diff --git a/apps/web/src/components/settings/danger-section.tsx b/apps/web/src/components/settings/danger-section.tsx new file mode 100644 index 0000000..d01223c --- /dev/null +++ b/apps/web/src/components/settings/danger-section.tsx @@ -0,0 +1,197 @@ +import { IconDatabase, IconPhoto, IconTrash } from "@tabler/icons-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { client, orpc } from "@/lib/orpc/client"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export function CacheSection() { + const queryClient = useQueryClient(); + + const invalidateHealth = () => + queryClient.invalidateQueries({ queryKey: orpc.system.health.key() }); + + const purgeMetadata = useMutation( + orpc.admin.purgeMetadataCache.mutationOptions({ + onSuccess: (data) => { + toast.success( + `Purged ${data.deletedTitles} stale title${data.deletedTitles !== 1 ? "s" : ""} and ${data.deletedPersons} orphaned person${data.deletedPersons !== 1 ? "s" : ""}`, + ); + invalidateHealth(); + }, + onError: () => toast.error("Failed to purge metadata cache"), + }), + ); + + const purgeImages = useMutation( + orpc.admin.purgeImageCache.mutationOptions({ + onSuccess: (data) => { + toast.success( + `Deleted ${data.deletedFiles.toLocaleString()} file${data.deletedFiles !== 1 ? "s" : ""}, freed ${formatBytes(data.freedBytes)}`, + ); + invalidateHealth(); + }, + onError: () => toast.error("Failed to purge image cache"), + }), + ); + + const purgeAll = useMutation({ + mutationFn: () => + Promise.all([ + client.admin.purgeMetadataCache(), + client.admin.purgeImageCache(), + ]), + onSuccess: ([metaResult, imageResult]) => { + toast.success( + `Purged ${metaResult.deletedTitles} title${metaResult.deletedTitles !== 1 ? "s" : ""}, ${metaResult.deletedPersons} person${metaResult.deletedPersons !== 1 ? "s" : ""}, ${imageResult.deletedFiles.toLocaleString()} file${imageResult.deletedFiles !== 1 ? "s" : ""} (${formatBytes(imageResult.freedBytes)} freed)`, + ); + invalidateHealth(); + }, + onError: () => toast.error("Failed to purge caches"), + }); + + const disabled = + purgeMetadata.isPending || purgeImages.isPending || purgeAll.isPending; + + return ( + +
+
+ +
+
+ Cache management + + Free up disk space by clearing cached metadata and images + + +
+ {/* Purge metadata */} + + + } + > + {purgeMetadata.isPending ? ( + + ) : ( + + )} + {purgeMetadata.isPending ? "Purging…" : "Purge metadata"} + + + + Purge metadata cache? + + This will delete un-enriched stub titles that aren't in any + user's library and clean up orphaned person records. Deleted + titles will be re-imported if accessed again. + + + + Cancel + purgeMetadata.mutate()} + > + Purge metadata + + + + + + {/* Purge images */} + + + } + > + {purgeImages.isPending ? ( + + ) : ( + + )} + {purgeImages.isPending ? "Purging…" : "Purge images"} + + + + Purge image cache? + + This will delete all cached TMDB images from disk. Images + will be re-downloaded automatically as needed. + + + + Cancel + purgeImages.mutate()} + > + Purge images + + + + + + {/* Purge all */} + + + } + > + {purgeAll.isPending ? ( + + ) : ( + + )} + {purgeAll.isPending ? "Purging…" : "Purge all"} + + + + Purge all caches? + + This will delete all un-enriched stub titles and all cached + images from disk. Everything will be re-imported and + re-downloaded as needed. + + + + Cancel + purgeAll.mutate()} + > + Purge all + + + + +
+
+
+
+ ); +} diff --git a/apps/web/src/routes/_app/settings.tsx b/apps/web/src/routes/_app/settings.tsx index e67a226..7801ff3 100644 --- a/apps/web/src/routes/_app/settings.tsx +++ b/apps/web/src/routes/_app/settings.tsx @@ -1,4 +1,5 @@ import { + IconAlertTriangle, IconDatabaseExport, IconServer2, IconShieldLock, @@ -9,6 +10,7 @@ import { BackupRestoreSection } from "@/components/settings/backup-restore-secti import { BackupScheduleSection } from "@/components/settings/backup-schedule-section"; import { BackupSection } from "@/components/settings/backup-section"; import { ChangePasswordSection } from "@/components/settings/change-password-section"; +import { CacheSection } from "@/components/settings/danger-section"; import { IntegrationsSection } from "@/components/settings/integrations-section"; import { RegistrationSection } from "@/components/settings/registration-section"; import { SettingsShell } from "@/components/settings/settings-shell"; @@ -154,6 +156,25 @@ function SettingsPage() { + + {/* Cache */} +
+
+ +

+ Danger Zone +

+ + Admin only + +
+ + + +
)} diff --git a/docs/content/docs/api/admin.mdx b/docs/content/docs/api/admin.mdx index 2baa64a..1b3bf62 100644 --- a/docs/content/docs/api/admin.mdx +++ b/docs/content/docs/api/admin.mdx @@ -43,6 +43,12 @@ _openapi: - depth: 2 title: Trigger cron job url: '#trigger-cron-job' + - depth: 2 + title: Purge metadata cache + url: '#purge-metadata-cache' + - depth: 2 + title: Purge image cache + url: '#purge-image-cache' structuredData: headings: - content: List backups @@ -71,6 +77,10 @@ _openapi: id: toggle-telemetry - content: Trigger cron job id: trigger-cron-job + - content: Purge metadata cache + id: purge-metadata-cache + - content: Purge image cache + id: purge-image-cache contents: - content: Fetch all database backups with their sizes and creation times. heading: list-backups @@ -110,8 +120,16 @@ _openapi: Manually trigger a background cron job by name. The job runs asynchronously. heading: trigger-cron-job + - content: >- + Delete un-enriched stub titles not in any user's library and clean up + orphaned person records. + heading: purge-metadata-cache + - content: >- + Delete all cached TMDB images from disk. Images will be re-downloaded + on demand. + heading: purge-image-cache --- {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} - \ No newline at end of file + \ No newline at end of file diff --git a/docs/openapi.json b/docs/openapi.json index b3e7001..2469d8c 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5584,6 +5584,90 @@ ] } }, + "/admin/cache/purge-metadata": { + "post": { + "operationId": "admin.purgeMetadataCache", + "summary": "Purge metadata cache", + "description": "Delete un-enriched stub titles not in any user's library and clean up orphaned person records.", + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Counts of deleted titles and persons", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deletedTitles": { + "type": "number", + "description": "Number of un-enriched stub titles deleted" + }, + "deletedPersons": { + "type": "number", + "description": "Number of orphaned person records deleted" + } + }, + "required": [ + "deletedTitles", + "deletedPersons" + ], + "description": "Result of purging un-enriched metadata from the database" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ] + } + }, + "/admin/cache/purge-images": { + "post": { + "operationId": "admin.purgeImageCache", + "summary": "Purge image cache", + "description": "Delete all cached TMDB images from disk. Images will be re-downloaded on demand.", + "tags": [ + "Admin" + ], + "responses": { + "200": { + "description": "Count of deleted files and bytes freed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "deletedFiles": { + "type": "number", + "description": "Number of image files deleted from disk" + }, + "freedBytes": { + "type": "number", + "description": "Total bytes freed from disk" + } + }, + "required": [ + "deletedFiles", + "freedBytes" + ], + "description": "Result of purging the image cache from disk" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ] + } + }, "/account/name": { "put": { "operationId": "account.updateName", diff --git a/packages/api/src/contract.ts b/packages/api/src/contract.ts index 1209351..6ebdec2 100644 --- a/packages/api/src/contract.ts +++ b/packages/api/src/contract.ts @@ -26,6 +26,8 @@ import { PopularOutput, ProviderParam, PublicInfoOutput, + PurgeImageCacheOutput, + PurgeMetadataCacheOutput, QuickAddOutput, RegistrationOutput, RestoreBackupInput, @@ -666,6 +668,30 @@ export const contract = { .errors({ NOT_FOUND: { message: "Job not found" }, }), + purgeMetadataCache: oc + .route({ + method: "POST", + path: "/admin/cache/purge-metadata", + tags: ["Admin"], + summary: "Purge metadata cache", + description: + "Delete un-enriched stub titles not in any user's library and clean up orphaned person records.", + successDescription: "Counts of deleted titles and persons", + }) + .input(z.void()) + .output(PurgeMetadataCacheOutput), + purgeImageCache: oc + .route({ + method: "POST", + path: "/admin/cache/purge-images", + tags: ["Admin"], + summary: "Purge image cache", + description: + "Delete all cached TMDB images from disk. Images will be re-downloaded on demand.", + successDescription: "Count of deleted files and bytes freed", + }) + .input(z.void()) + .output(PurgeImageCacheOutput), }, account: { updateName: oc diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index 7e5b6ff..fa1cb9f 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -997,6 +997,28 @@ export const TriggerJobOutput = z.object({ ok: z.literal(true).describe("Always true on success"), }); +export const PurgeMetadataCacheOutput = z + .object({ + deletedTitles: z + .number() + .describe("Number of un-enriched stub titles deleted"), + deletedPersons: z + .number() + .describe("Number of orphaned person records deleted"), + }) + .meta({ + description: "Result of purging un-enriched metadata from the database", + }); + +export const PurgeImageCacheOutput = z + .object({ + deletedFiles: z + .number() + .describe("Number of image files deleted from disk"), + freedBytes: z.number().describe("Total bytes freed from disk"), + }) + .meta({ description: "Result of purging the image cache from disk" }); + // ─── Quick add output ────────────────────────────────────────── export const QuickAddOutput = z diff --git a/packages/core/package.json b/packages/core/package.json index 0f15a7c..c20933b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,7 @@ "exports": { "./availability": "./src/availability.ts", "./backup": "./src/backup.ts", + "./cache": "./src/cache.ts", "./colors": "./src/colors.ts", "./credits": "./src/credits.ts", "./discovery": "./src/discovery.ts", diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts new file mode 100644 index 0000000..6020f94 --- /dev/null +++ b/packages/core/src/cache.ts @@ -0,0 +1,149 @@ +import { readdir, stat, unlink } from "node:fs/promises"; +import path from "node:path"; +import { CACHE_DIR } from "@sofa/config"; +import { db } from "@sofa/db/client"; +import { inArray, isNull, notInArray } from "@sofa/db/helpers"; +import { persons, titleCast, titles, userTitleStatus } from "@sofa/db/schema"; +import { createLogger } from "@sofa/logger"; + +const log = createLogger("purge"); + +const BATCH_SIZE = 500; + +/** + * Delete un-enriched "shell" titles that aren't in any user's library, + * then clean up orphaned person records. + */ +export function purgeMetadataCache(): { + deletedTitles: number; + deletedPersons: number; +} { + return db.transaction(() => { + // Find shell titles (never fully fetched) not in any user's library + const shellTitles = db + .select({ id: titles.id }) + .from(titles) + .where(isNull(titles.lastFetchedAt)) + .all(); + + if (shellTitles.length === 0) { + log.info("No un-enriched titles to purge"); + return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() }; + } + + // Filter out titles that are in any user's library + const libraryTitleIds = new Set( + db + .select({ titleId: userTitleStatus.titleId }) + .from(userTitleStatus) + .all() + .map((r) => r.titleId), + ); + + const toDelete = shellTitles + .map((t) => t.id) + .filter((id) => !libraryTitleIds.has(id)); + + if (toDelete.length === 0) { + log.info("All un-enriched titles are in user libraries, skipping"); + return { deletedTitles: 0, deletedPersons: purgeOrphanedPersons() }; + } + + // Delete in batches to respect SQLite variable limits + for (let i = 0; i < toDelete.length; i += BATCH_SIZE) { + const batch = toDelete.slice(i, i + BATCH_SIZE); + db.delete(titles).where(inArray(titles.id, batch)).run(); + } + const deletedTitles = toDelete.length; + + log.info(`Purged ${deletedTitles} un-enriched titles`); + + const deletedPersons = purgeOrphanedPersons(); + + return { deletedTitles, deletedPersons }; + }); +} + +/** + * Delete person records that have no remaining titleCast references. + */ +function purgeOrphanedPersons(): number { + const orphanedPersons = db + .select({ id: persons.id }) + .from(persons) + .where( + notInArray( + persons.id, + db.selectDistinct({ personId: titleCast.personId }).from(titleCast), + ), + ) + .all(); + + if (orphanedPersons.length === 0) return 0; + + const ids = orphanedPersons.map((p) => p.id); + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batch = ids.slice(i, i + BATCH_SIZE); + db.delete(persons).where(inArray(persons.id, batch)).run(); + } + const deletedPersons = ids.length; + + log.info(`Purged ${deletedPersons} orphaned persons`); + return deletedPersons; +} + +/** Image cache subdirectories to scan */ +const IMAGE_CATEGORIES = [ + "posters", + "backdrops", + "stills", + "logos", + "profiles", +] as const; + +/** + * Delete all cached images from disk. + * Returns the count of files deleted and total bytes freed. + */ +export async function purgeImageCache(): Promise<{ + deletedFiles: number; + freedBytes: number; +}> { + let deletedFiles = 0; + let freedBytes = 0; + + for (const category of IMAGE_CATEGORIES) { + const dir = path.join(CACHE_DIR, category); + + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + // Directory doesn't exist yet, skip + continue; + } + + for (const filename of entries) { + // Skip hidden files and temp files + if (filename.startsWith(".")) continue; + + const filePath = path.join(dir, filename); + try { + const fileStat = await stat(filePath); + if (!fileStat.isFile()) continue; + + freedBytes += fileStat.size; + await unlink(filePath); + deletedFiles++; + } catch (err) { + log.warn(`Failed to delete ${filePath}:`, err); + } + } + } + + log.info( + `Purged image cache: ${deletedFiles} files, ${(freedBytes / (1024 * 1024)).toFixed(1)} MB freed`, + ); + + return { deletedFiles, freedBytes }; +} diff --git a/packages/core/test/image-cache.test.ts b/packages/core/test/image-cache.test.ts index 0f7e97d..5440c0c 100644 --- a/packages/core/test/image-cache.test.ts +++ b/packages/core/test/image-cache.test.ts @@ -1,27 +1,26 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { loadImageBuffer } from "../src/image-cache"; -const originalFetch = globalThis.fetch; - beforeEach(() => { process.env.IMAGE_CACHE_ENABLED = "false"; }); afterEach(() => { - globalThis.fetch = originalFetch; delete process.env.IMAGE_CACHE_ENABLED; }); describe("loadImageBuffer", () => { test("uses category-specific TMDB sizes when cache is disabled", async () => { const urls: string[] = []; - globalThis.fetch = mock(async (input: string | URL | Request) => { + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async ( + input: string | URL | Request, + ) => { urls.push(String(input)); return new Response(new Uint8Array([1, 2, 3]), { status: 200, headers: { "content-type": "image/jpeg" }, }); - }) as unknown as typeof fetch; + }) as typeof fetch); await loadImageBuffer("/poster.jpg", "posters"); await loadImageBuffer("/profile.jpg", "profiles"); @@ -30,5 +29,7 @@ describe("loadImageBuffer", () => { "https://image.tmdb.org/t/p/w500/poster.jpg", "https://image.tmdb.org/t/p/w185/profile.jpg", ]); + + fetchSpy.mockRestore(); }); }); diff --git a/packages/db/src/helpers.ts b/packages/db/src/helpers.ts index df732ce..6c4625e 100644 --- a/packages/db/src/helpers.ts +++ b/packages/db/src/helpers.ts @@ -6,7 +6,9 @@ export { gte, inArray, isNotNull, + isNull, lt, + notInArray, or, sql, } from "drizzle-orm";