mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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.
This commit is contained in:
@@ -8,6 +8,10 @@ import {
|
|||||||
listBackups,
|
listBackups,
|
||||||
restoreFromBackup,
|
restoreFromBackup,
|
||||||
} from "@sofa/core/backup";
|
} from "@sofa/core/backup";
|
||||||
|
import {
|
||||||
|
purgeImageCache as purgeImagesFn,
|
||||||
|
purgeMetadataCache as purgeMetadataFn,
|
||||||
|
} from "@sofa/core/cache";
|
||||||
import { getSetting, setSetting } from "@sofa/core/settings";
|
import { getSetting, setSetting } from "@sofa/core/settings";
|
||||||
import { isTelemetryEnabled } from "@sofa/core/telemetry";
|
import { isTelemetryEnabled } from "@sofa/core/telemetry";
|
||||||
import {
|
import {
|
||||||
@@ -159,3 +163,13 @@ export const triggerJob = os.admin.triggerJob
|
|||||||
}
|
}
|
||||||
return { ok: true as const };
|
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());
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export const implementedRouter = {
|
|||||||
telemetry: admin.telemetry,
|
telemetry: admin.telemetry,
|
||||||
toggleTelemetry: admin.toggleTelemetry,
|
toggleTelemetry: admin.toggleTelemetry,
|
||||||
triggerJob: admin.triggerJob,
|
triggerJob: admin.triggerJob,
|
||||||
|
purgeMetadataCache: admin.purgeMetadataCache,
|
||||||
|
purgeImageCache: admin.purgeImageCache,
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
updateName: account.updateName,
|
updateName: account.updateName,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<IconTrash aria-hidden={true} className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle>Cache management</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Free up disk space by clearing cached metadata and images
|
||||||
|
</CardDescription>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{/* Purge metadata */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{purgeMetadata.isPending ? (
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconDatabase aria-hidden={true} />
|
||||||
|
)}
|
||||||
|
{purgeMetadata.isPending ? "Purging…" : "Purge metadata"}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Purge metadata cache?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
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.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => purgeMetadata.mutate()}
|
||||||
|
>
|
||||||
|
Purge metadata
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Purge images */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{purgeImages.isPending ? (
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconPhoto aria-hidden={true} />
|
||||||
|
)}
|
||||||
|
{purgeImages.isPending ? "Purging…" : "Purge images"}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Purge image cache?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all cached TMDB images from disk. Images
|
||||||
|
will be re-downloaded automatically as needed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => purgeImages.mutate()}
|
||||||
|
>
|
||||||
|
Purge images
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Purge all */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="destructive" size="sm" disabled={disabled} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{purgeAll.isPending ? (
|
||||||
|
<Spinner className="size-3" />
|
||||||
|
) : (
|
||||||
|
<IconTrash aria-hidden={true} />
|
||||||
|
)}
|
||||||
|
{purgeAll.isPending ? "Purging…" : "Purge all"}
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Purge all caches?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all un-enriched stub titles and all cached
|
||||||
|
images from disk. Everything will be re-imported and
|
||||||
|
re-downloaded as needed.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => purgeAll.mutate()}
|
||||||
|
>
|
||||||
|
Purge all
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
IconDatabaseExport,
|
IconDatabaseExport,
|
||||||
IconServer2,
|
IconServer2,
|
||||||
IconShieldLock,
|
IconShieldLock,
|
||||||
@@ -9,6 +10,7 @@ import { BackupRestoreSection } from "@/components/settings/backup-restore-secti
|
|||||||
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
|
import { BackupScheduleSection } from "@/components/settings/backup-schedule-section";
|
||||||
import { BackupSection } from "@/components/settings/backup-section";
|
import { BackupSection } from "@/components/settings/backup-section";
|
||||||
import { ChangePasswordSection } from "@/components/settings/change-password-section";
|
import { ChangePasswordSection } from "@/components/settings/change-password-section";
|
||||||
|
import { CacheSection } from "@/components/settings/danger-section";
|
||||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||||
import { RegistrationSection } from "@/components/settings/registration-section";
|
import { RegistrationSection } from "@/components/settings/registration-section";
|
||||||
import { SettingsShell } from "@/components/settings/settings-shell";
|
import { SettingsShell } from "@/components/settings/settings-shell";
|
||||||
@@ -154,6 +156,25 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cache */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<IconAlertTriangle
|
||||||
|
aria-hidden={true}
|
||||||
|
className="size-4 text-destructive"
|
||||||
|
/>
|
||||||
|
<h2 className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
||||||
|
Danger Zone
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-[10px] text-primary">
|
||||||
|
Admin only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Card className="border-l-2 border-l-primary/30">
|
||||||
|
<CacheSection />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ _openapi:
|
|||||||
- depth: 2
|
- depth: 2
|
||||||
title: Trigger cron job
|
title: Trigger cron job
|
||||||
url: '#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:
|
structuredData:
|
||||||
headings:
|
headings:
|
||||||
- content: List backups
|
- content: List backups
|
||||||
@@ -71,6 +77,10 @@ _openapi:
|
|||||||
id: toggle-telemetry
|
id: toggle-telemetry
|
||||||
- content: Trigger cron job
|
- content: Trigger cron job
|
||||||
id: trigger-cron-job
|
id: trigger-cron-job
|
||||||
|
- content: Purge metadata cache
|
||||||
|
id: purge-metadata-cache
|
||||||
|
- content: Purge image cache
|
||||||
|
id: purge-image-cache
|
||||||
contents:
|
contents:
|
||||||
- content: Fetch all database backups with their sizes and creation times.
|
- content: Fetch all database backups with their sizes and creation times.
|
||||||
heading: list-backups
|
heading: list-backups
|
||||||
@@ -110,8 +120,16 @@ _openapi:
|
|||||||
Manually trigger a background cron job by name. The job runs
|
Manually trigger a background cron job by name. The job runs
|
||||||
asynchronously.
|
asynchronously.
|
||||||
heading: trigger-cron-job
|
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. */}
|
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||||
|
|
||||||
<APIPage document={"./openapi.json"} webhooks={[]} operations={[{"path":"/admin/backups","method":"get"},{"path":"/admin/backups","method":"post"},{"path":"/admin/backups/{filename}","method":"delete"},{"path":"/admin/backups/restore","method":"post"},{"path":"/admin/backups/schedule","method":"get"},{"path":"/admin/backups/schedule","method":"put"},{"path":"/admin/registration","method":"get"},{"path":"/admin/registration","method":"put"},{"path":"/admin/update-check","method":"get"},{"path":"/admin/update-check","method":"put"},{"path":"/admin/telemetry","method":"get"},{"path":"/admin/telemetry","method":"put"},{"path":"/admin/jobs/trigger","method":"post"}]} showTitle={true} />
|
<APIPage document={"./openapi.json"} webhooks={[]} operations={[{"path":"/admin/backups","method":"get"},{"path":"/admin/backups","method":"post"},{"path":"/admin/backups/{filename}","method":"delete"},{"path":"/admin/backups/restore","method":"post"},{"path":"/admin/backups/schedule","method":"get"},{"path":"/admin/backups/schedule","method":"put"},{"path":"/admin/registration","method":"get"},{"path":"/admin/registration","method":"put"},{"path":"/admin/update-check","method":"get"},{"path":"/admin/update-check","method":"put"},{"path":"/admin/telemetry","method":"get"},{"path":"/admin/telemetry","method":"put"},{"path":"/admin/jobs/trigger","method":"post"},{"path":"/admin/cache/purge-metadata","method":"post"},{"path":"/admin/cache/purge-images","method":"post"}]} showTitle={true} />
|
||||||
@@ -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": {
|
"/account/name": {
|
||||||
"put": {
|
"put": {
|
||||||
"operationId": "account.updateName",
|
"operationId": "account.updateName",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
PopularOutput,
|
PopularOutput,
|
||||||
ProviderParam,
|
ProviderParam,
|
||||||
PublicInfoOutput,
|
PublicInfoOutput,
|
||||||
|
PurgeImageCacheOutput,
|
||||||
|
PurgeMetadataCacheOutput,
|
||||||
QuickAddOutput,
|
QuickAddOutput,
|
||||||
RegistrationOutput,
|
RegistrationOutput,
|
||||||
RestoreBackupInput,
|
RestoreBackupInput,
|
||||||
@@ -666,6 +668,30 @@ export const contract = {
|
|||||||
.errors({
|
.errors({
|
||||||
NOT_FOUND: { message: "Job not found" },
|
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: {
|
account: {
|
||||||
updateName: oc
|
updateName: oc
|
||||||
|
|||||||
@@ -997,6 +997,28 @@ export const TriggerJobOutput = z.object({
|
|||||||
ok: z.literal(true).describe("Always true on success"),
|
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 ──────────────────────────────────────────
|
// ─── Quick add output ──────────────────────────────────────────
|
||||||
|
|
||||||
export const QuickAddOutput = z
|
export const QuickAddOutput = z
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
"./availability": "./src/availability.ts",
|
"./availability": "./src/availability.ts",
|
||||||
"./backup": "./src/backup.ts",
|
"./backup": "./src/backup.ts",
|
||||||
|
"./cache": "./src/cache.ts",
|
||||||
"./colors": "./src/colors.ts",
|
"./colors": "./src/colors.ts",
|
||||||
"./credits": "./src/credits.ts",
|
"./credits": "./src/credits.ts",
|
||||||
"./discovery": "./src/discovery.ts",
|
"./discovery": "./src/discovery.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 };
|
||||||
|
}
|
||||||
@@ -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";
|
import { loadImageBuffer } from "../src/image-cache";
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.IMAGE_CACHE_ENABLED = "false";
|
process.env.IMAGE_CACHE_ENABLED = "false";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
delete process.env.IMAGE_CACHE_ENABLED;
|
delete process.env.IMAGE_CACHE_ENABLED;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("loadImageBuffer", () => {
|
describe("loadImageBuffer", () => {
|
||||||
test("uses category-specific TMDB sizes when cache is disabled", async () => {
|
test("uses category-specific TMDB sizes when cache is disabled", async () => {
|
||||||
const urls: string[] = [];
|
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));
|
urls.push(String(input));
|
||||||
return new Response(new Uint8Array([1, 2, 3]), {
|
return new Response(new Uint8Array([1, 2, 3]), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "image/jpeg" },
|
headers: { "content-type": "image/jpeg" },
|
||||||
});
|
});
|
||||||
}) as unknown as typeof fetch;
|
}) as typeof fetch);
|
||||||
|
|
||||||
await loadImageBuffer("/poster.jpg", "posters");
|
await loadImageBuffer("/poster.jpg", "posters");
|
||||||
await loadImageBuffer("/profile.jpg", "profiles");
|
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/w500/poster.jpg",
|
||||||
"https://image.tmdb.org/t/p/w185/profile.jpg",
|
"https://image.tmdb.org/t/p/w185/profile.jpg",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
fetchSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ export {
|
|||||||
gte,
|
gte,
|
||||||
inArray,
|
inArray,
|
||||||
isNotNull,
|
isNotNull,
|
||||||
|
isNull,
|
||||||
lt,
|
lt,
|
||||||
|
notInArray,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
|
|||||||
Reference in New Issue
Block a user