mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Two problems with running these unattended, both found while wiring the Bunny cleanup up to a Coolify scheduled task against production. A dry run reported a count and nothing else. "Orphaned: 31" is not something anyone can approve: it says how many objects would go, never which. Both scripts now list every orphan they would delete, and print the same list when deleting, so a real run is auditable afterwards too. Each line carries who the object belongs to, as far as each provider can answer: - R2 reads the owner out of `videoUploadSession`, which keeps `objectKey` alongside the initiating and billed user and survives an upload that never became a video. That is the case producing orphans, so this is an answer rather than a guess. - Bunny has no equivalent. `bunny-init` sends the provider a title and nothing else, and an orphan by definition has no row pointing at it, so there is nothing authoritative to look up. The title is matched against titles still in the database instead, which catches the common shape (a version upload that failed and was retried successfully leaves a live row with the same title). A hit prints as "possibly", because it is a hint. The grace periods were also too short to be safe: - Bunny counted a video abandoned after 24 hours. - R2 counted an object abandoned after 15 minutes, which is shorter than a slow multipart upload of a large file. An object still being written, or written but not yet finalised into a row, looked abandoned and could be deleted out from under the upload creating it. Both are seven days now: long enough that no upload, retry or delayed finalisation can still be in flight.
385 lines
12 KiB
TypeScript
385 lines
12 KiB
TypeScript
import { db, disconnectDb } from '../lib/db';
|
|
import { cleanupExpiredBillingWorkspaces } from './expired-billing-cleanup';
|
|
import { logError } from '@/lib/logger';
|
|
|
|
const BUNNY_API_BASE = 'https://video.bunnycdn.com';
|
|
const BUNNY_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/;
|
|
const ITEMS_PER_PAGE = 100;
|
|
const MAX_PAGES = 200;
|
|
const CHUNK_SIZE = 500;
|
|
// Seven days, not one. A video only counts as abandoned once nothing has claimed it for
|
|
// long enough that no upload, retry or delayed finalisation could still be in flight, and
|
|
// a day is short enough that an upload interrupted overnight looks abandoned by morning.
|
|
const DEFAULT_GRACE_HOURS = 7 * 24;
|
|
|
|
type BunnyConfig = {
|
|
apiKey: string;
|
|
libraryId: string;
|
|
};
|
|
|
|
type BunnyVideo = {
|
|
id: string;
|
|
uploadedAt: Date;
|
|
title: string | null;
|
|
};
|
|
|
|
function getBunnyConfig(): BunnyConfig {
|
|
const apiKey = process.env.BUNNY_STREAM_API_KEY;
|
|
const libraryId =
|
|
process.env.BUNNY_STREAM_LIBRARY_ID || process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID;
|
|
|
|
if (!apiKey || !libraryId) {
|
|
throw new Error('Missing BUNNY_STREAM_API_KEY or BUNNY_STREAM_LIBRARY_ID.');
|
|
}
|
|
|
|
return { apiKey, libraryId };
|
|
}
|
|
|
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== 'object') return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function parseVideoId(item: unknown): string | null {
|
|
const record = toRecord(item);
|
|
if (!record) return null;
|
|
|
|
const candidates = [record.guid, record.videoId, record.id];
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'string') {
|
|
const trimmed = candidate.trim();
|
|
if (BUNNY_VIDEO_ID_PATTERN.test(trimmed)) return trimmed;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseTitle(item: unknown): string | null {
|
|
const record = toRecord(item);
|
|
if (!record) return null;
|
|
|
|
for (const candidate of [record.title, record.Title]) {
|
|
if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseUploadedAt(item: unknown): Date | null {
|
|
const record = toRecord(item);
|
|
if (!record) return null;
|
|
|
|
const candidates = [
|
|
record.dateUploaded,
|
|
record.DateUploaded,
|
|
record.dateCreated,
|
|
record.DateCreated,
|
|
record.createdAt,
|
|
record.CreatedAt,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate !== 'string') continue;
|
|
const parsed = new Date(candidate);
|
|
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function fetchBunnyPage(
|
|
config: BunnyConfig,
|
|
page: number
|
|
): Promise<{ items: unknown[]; totalItems: number | null }> {
|
|
const response = await fetch(
|
|
`${BUNNY_API_BASE}/library/${config.libraryId}/videos?page=${page}&itemsPerPage=${ITEMS_PER_PAGE}`,
|
|
{
|
|
headers: {
|
|
AccessKey: config.apiKey,
|
|
Accept: 'application/json',
|
|
},
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Bunny list API failed (${response.status}): ${body.slice(0, 300)}`);
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const record = toRecord(payload);
|
|
if (!record) return { items: [], totalItems: null };
|
|
|
|
const items = Array.isArray(record.items)
|
|
? record.items
|
|
: Array.isArray(record.Items)
|
|
? record.Items
|
|
: [];
|
|
|
|
const totalItems =
|
|
typeof record.totalItems === 'number'
|
|
? record.totalItems
|
|
: typeof record.TotalItems === 'number'
|
|
? record.TotalItems
|
|
: null;
|
|
|
|
return { items, totalItems };
|
|
}
|
|
|
|
async function listBunnyVideos(
|
|
config: BunnyConfig
|
|
): Promise<{ videos: BunnyVideo[]; scanned: number; skippedInvalid: number }> {
|
|
const videos: BunnyVideo[] = [];
|
|
let scanned = 0;
|
|
let skippedInvalid = 0;
|
|
let page = 1;
|
|
|
|
while (page <= MAX_PAGES) {
|
|
const { items, totalItems } = await fetchBunnyPage(config, page);
|
|
if (items.length === 0) break;
|
|
|
|
scanned += items.length;
|
|
for (const item of items) {
|
|
const id = parseVideoId(item);
|
|
const uploadedAt = parseUploadedAt(item);
|
|
if (!id || !uploadedAt) {
|
|
skippedInvalid += 1;
|
|
continue;
|
|
}
|
|
videos.push({ id, uploadedAt, title: parseTitle(item) });
|
|
}
|
|
|
|
if (totalItems !== null && page * ITEMS_PER_PAGE >= totalItems) break;
|
|
page += 1;
|
|
}
|
|
|
|
return { videos, scanned, skippedInvalid };
|
|
}
|
|
|
|
function chunk<T>(items: T[], size: number): T[][] {
|
|
const out: T[][] = [];
|
|
for (let i = 0; i < items.length; i += size) {
|
|
out.push(items.slice(i, i + size));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function findReferencedVideoIds(videoIds: string[]): Promise<Set<string>> {
|
|
const referenced = new Set<string>();
|
|
|
|
for (const group of chunk(videoIds, CHUNK_SIZE)) {
|
|
const [versionRows, assetRows] = await Promise.all([
|
|
db.videoVersion.findMany({
|
|
where: {
|
|
providerId: 'bunny',
|
|
videoId: { in: group },
|
|
},
|
|
select: { videoId: true },
|
|
}),
|
|
db.videoAsset.findMany({
|
|
where: {
|
|
provider: 'BUNNY',
|
|
providerVideoId: { in: group },
|
|
},
|
|
select: { providerVideoId: true },
|
|
}),
|
|
]);
|
|
versionRows.forEach((row) => {
|
|
if (row.videoId) referenced.add(row.videoId);
|
|
});
|
|
assetRows.forEach((row) => {
|
|
if (row.providerVideoId) referenced.add(row.providerVideoId);
|
|
});
|
|
}
|
|
|
|
return referenced;
|
|
}
|
|
|
|
/**
|
|
* Best-effort owner for an orphan, by matching its Bunny title against titles still in the
|
|
* database.
|
|
*
|
|
* An orphan is by definition a video nothing in the database points at, so there is no
|
|
* authoritative owner to look up: `bunny-init` sends Bunny a title and nothing else, no
|
|
* user id and no email. What is left is the title, and it is worth matching because the
|
|
* common way an orphan appears is a version upload that failed and was then retried
|
|
* successfully, which leaves a live row carrying the same title.
|
|
*
|
|
* A hit is therefore a hint, not a fact, and the output says so. A miss means the video
|
|
* cannot be attributed at all from what Bunny and the database hold today.
|
|
*/
|
|
async function findOwnerHintsByTitle(titles: string[]): Promise<Map<string, string[]>> {
|
|
const hints = new Map<string, Set<string>>();
|
|
const unique = [...new Set(titles.filter((title): title is string => Boolean(title)))];
|
|
|
|
const remember = (title: string | null, emails: Array<string | null | undefined>) => {
|
|
if (!title) return;
|
|
const existing = hints.get(title) ?? new Set<string>();
|
|
for (const email of emails) {
|
|
if (email) existing.add(email);
|
|
}
|
|
if (existing.size > 0) hints.set(title, existing);
|
|
};
|
|
|
|
const ownerSelect = {
|
|
project: {
|
|
select: {
|
|
owner: { select: { email: true } },
|
|
workspace: { select: { owner: { select: { email: true } } } },
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
for (const group of chunk(unique, CHUNK_SIZE)) {
|
|
const [videos, versions, assets] = await Promise.all([
|
|
db.video.findMany({
|
|
where: { title: { in: group } },
|
|
select: { title: true, ...ownerSelect },
|
|
}),
|
|
db.videoVersion.findMany({
|
|
where: { title: { in: group } },
|
|
select: { title: true, video: { select: ownerSelect } },
|
|
}),
|
|
db.videoAsset.findMany({
|
|
where: { displayName: { in: group } },
|
|
select: { displayName: true, video: { select: ownerSelect } },
|
|
}),
|
|
]);
|
|
|
|
for (const row of videos) {
|
|
remember(row.title, [row.project.owner?.email, row.project.workspace.owner?.email]);
|
|
}
|
|
for (const row of versions) {
|
|
const project = row.video.project;
|
|
remember(row.title, [project.owner?.email, project.workspace.owner?.email]);
|
|
}
|
|
for (const row of assets) {
|
|
const project = row.video.project;
|
|
remember(row.displayName, [project.owner?.email, project.workspace.owner?.email]);
|
|
}
|
|
}
|
|
|
|
return new Map([...hints].map(([title, emails]) => [title, [...emails].sort()]));
|
|
}
|
|
|
|
async function deleteBunnyVideo(
|
|
config: BunnyConfig,
|
|
videoId: string
|
|
): Promise<'deleted' | 'already_missing'> {
|
|
const response = await fetch(
|
|
`${BUNNY_API_BASE}/library/${config.libraryId}/videos/${encodeURIComponent(videoId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
AccessKey: config.apiKey,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (response.status === 404) return 'already_missing';
|
|
if (response.ok) return 'deleted';
|
|
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(
|
|
`Bunny delete API failed for ${videoId} (${response.status}): ${body.slice(0, 300)}`
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const dryRun = process.argv.includes('--dry-run');
|
|
const graceHours = DEFAULT_GRACE_HOURS;
|
|
const graceMs = graceHours * 60 * 60 * 1000;
|
|
const cutoff = Date.now() - graceMs;
|
|
|
|
const config = getBunnyConfig();
|
|
console.log(`[bunny-orphan-cleanup] Starting (${dryRun ? 'dry-run' : 'delete mode'})`);
|
|
console.log(`[bunny-orphan-cleanup] Grace period: ${graceHours}h`);
|
|
|
|
const expiredBillingCleanup = await cleanupExpiredBillingWorkspaces({ dryRun });
|
|
console.log(
|
|
`[bunny-orphan-cleanup] Expired owner workspaces scanned: ${expiredBillingCleanup.scanned}`
|
|
);
|
|
console.log(
|
|
`[bunny-orphan-cleanup] Expired owner workspaces deleted: ${expiredBillingCleanup.deleted}`
|
|
);
|
|
|
|
const { videos, scanned, skippedInvalid } = await listBunnyVideos(config);
|
|
const eligible = videos.filter((video) => video.uploadedAt.getTime() <= cutoff);
|
|
console.log(`[bunny-orphan-cleanup] Scanned: ${scanned}`);
|
|
console.log(`[bunny-orphan-cleanup] Skipped invalid metadata: ${skippedInvalid}`);
|
|
console.log(`[bunny-orphan-cleanup] Eligible (old enough): ${eligible.length}`);
|
|
|
|
if (eligible.length === 0) {
|
|
console.log('[bunny-orphan-cleanup] No eligible Bunny videos found');
|
|
return;
|
|
}
|
|
|
|
const eligibleIds = eligible.map((video) => video.id);
|
|
const referenced = await findReferencedVideoIds(eligibleIds);
|
|
|
|
const orphanIds = eligibleIds.filter((id) => !referenced.has(id));
|
|
|
|
// A dry run that only reports a count cannot be acted on: the point of it is to see
|
|
// what would go before anything does. Deleting prints the same list, so a real run is
|
|
// auditable after the fact too.
|
|
if (orphanIds.length > 0) {
|
|
const byId = new Map(eligible.map((video) => [video.id, video]));
|
|
const orphanTitles = orphanIds
|
|
.map((orphanId) => byId.get(orphanId)?.title)
|
|
.filter((title): title is string => Boolean(title));
|
|
const ownerHints = await findOwnerHintsByTitle(orphanTitles);
|
|
|
|
console.log(
|
|
`[bunny-orphan-cleanup] Orphans ${dryRun ? 'that would be deleted' : 'to delete'}:`
|
|
);
|
|
for (const orphanId of orphanIds) {
|
|
const video = byId.get(orphanId);
|
|
const uploadedAt = video?.uploadedAt.toISOString() ?? 'unknown date';
|
|
const title = video?.title ?? 'untitled';
|
|
const emails = video?.title ? (ownerHints.get(video.title) ?? []) : [];
|
|
// "possibly" is load-bearing: this is a title match, not a stored owner.
|
|
const owner =
|
|
emails.length > 0 ? `possibly ${emails.join(', ')}` : 'owner unknown (no title match)';
|
|
console.log(`[bunny-orphan-cleanup] ${orphanId} ${uploadedAt} ${title} ${owner}`);
|
|
}
|
|
}
|
|
|
|
let deleted = 0;
|
|
let alreadyMissing = 0;
|
|
let failed = 0;
|
|
|
|
for (const orphanId of orphanIds) {
|
|
if (dryRun) continue;
|
|
|
|
try {
|
|
const result = await deleteBunnyVideo(config, orphanId);
|
|
if (result === 'already_missing') {
|
|
alreadyMissing += 1;
|
|
} else {
|
|
deleted += 1;
|
|
}
|
|
} catch (error) {
|
|
failed += 1;
|
|
logError(`[bunny-orphan-cleanup] Failed deleting ${orphanId}:`, error);
|
|
}
|
|
}
|
|
|
|
console.log('[bunny-orphan-cleanup] Summary');
|
|
console.log(`[bunny-orphan-cleanup] Referenced: ${referenced.size}`);
|
|
console.log(`[bunny-orphan-cleanup] Orphaned: ${orphanIds.length}`);
|
|
console.log(`[bunny-orphan-cleanup] Deleted: ${deleted}`);
|
|
console.log(`[bunny-orphan-cleanup] Already missing: ${alreadyMissing}`);
|
|
console.log(`[bunny-orphan-cleanup] Failed: ${failed}`);
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
logError('[bunny-orphan-cleanup] Fatal error:', error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(async () => {
|
|
await disconnectDb();
|
|
});
|