import { db } from "@/lib/db"; import { deleteFromHetzner } from "@/lib/storage"; import { getConfigNumber } from "./config"; /** * Housekeeping for per-version high-quality media — the High quality review * MP4 (`hqFileUrl`) and the ProRes delivery MOV (`highResKey`). The worker * uploads both for every version, and at UHD they are 5–10× the Standard MP4, * so versions nobody can need any more have them removed. The Standard MP4 is * never touched: every version stays reviewable. */ export interface HighResCandidate { isLatest: boolean; isClientVisible: boolean; approvalStatus: string; createdAt: Date; } /** * Whether a version still needs its high-quality media. Kept while it is: * - the latest version of its task — what everyone is looking at; * - shared with the client — they may download it; * - approved — the version of record; * - younger than the retention window — supervisors compare recent takes. */ export function shouldKeepHighRes(v: HighResCandidate, now: Date, retentionDays: number): boolean { if (v.isLatest || v.isClientVisible || v.approvalStatus === "APPROVED") return true; const ageMs = now.getTime() - v.createdAt.getTime(); return ageMs < Math.max(0, retentionDays) * 24 * 3600 * 1000; } /** Object-storage keys behind a version's high-quality media. */ export function highResStorageKeys(v: { hqFileUrl?: string | null; highResKey?: string | null }): string[] { const keys: string[] = []; if (v.highResKey) keys.push(v.highResKey); if (v.hqFileUrl?.startsWith("/api/files/")) keys.push(v.hqFileUrl.slice("/api/files/".length)); return keys; } /** Best-effort deletes; a failure only leaves an orphan, never a broken link. */ export async function deleteStorageKeys(keys: string[]): Promise { for (const key of keys) { await deleteFromHetzner(key).catch((err) => console.error(`[high-res] could not delete ${key} from storage`, err) ); } } const candidateSelect = { id: true, isLatest: true, isClientVisible: true, approvalStatus: true, createdAt: true, hqFileUrl: true, highResKey: true, } as const; type Candidate = HighResCandidate & { id: string; hqFileUrl: string | null; highResKey: string | null }; /** * Clears the row first, then deletes the files: if the delete fails the file * is merely orphaned, whereas the other order could leave a download pointing * at nothing. */ async function prune(candidates: Candidate[], now: Date, retentionDays: number): Promise { let pruned = 0; for (const v of candidates) { if (shouldKeepHighRes(v, now, retentionDays)) continue; const keys = highResStorageKeys(v); await db.version.update({ where: { id: v.id }, data: { hqFileUrl: null, highResKey: null, highResFilename: null, highResSize: null, highResPrunedAt: now, }, }); await deleteStorageKeys(keys); pruned++; } return pruned; } const hasHighRes = { OR: [{ hqFileUrl: { not: null } }, { highResKey: { not: null } }] }; /** Run after a new version lands: its predecessors may now be redundant. */ export async function pruneHighResForTask(taskId: string): Promise { const retentionDays = await getConfigNumber("preview.highResRetentionDays"); const candidates = await db.version.findMany({ where: { taskId, ...hasHighRes }, select: candidateSelect, }); return prune(candidates, new Date(), retentionDays); } /** * Studio-wide pass, for versions that age out of the retention window or are * unshared/unapproved after the fact. Bounded per run; the reaper calls it * hourly, so a backlog drains over a few runs. */ export async function pruneHighResSweep(batch = 200): Promise { const retentionDays = await getConfigNumber("preview.highResRetentionDays"); const now = new Date(); const cutoff = new Date(now.getTime() - Math.max(0, retentionDays) * 24 * 3600 * 1000); const candidates = await db.version.findMany({ where: { ...hasHighRes, isLatest: false, isClientVisible: false, approvalStatus: { not: "APPROVED" }, createdAt: { lt: cutoff }, }, select: candidateSelect, orderBy: { createdAt: "asc" }, take: batch, }); const pruned = await prune(candidates, now, retentionDays); if (pruned > 0) console.log(`[high-res] housekeeping removed high-quality media from ${pruned} version(s)`); return pruned; }