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:
jake committed 2026-03-15 10:37:35 -04:00
1 parent 33fd871114
commit 297eb6b550
12 files changed
+544 -7

No files matched your search

+1
View File
@@ -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",
+149
View File
@@ -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 };
}
+7 -6
View File
@@ -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();
});
});