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";