feat: bulk video uploads and S3 asset video support (#18)

Add multi-file drag-and-drop queues for project videos and the assets pane, and route asset video uploads through S3/R2 when direct Bunny uploads are disabled.
This commit is contained in:
yusufipk
2026-06-13 23:24:21 +02:00
parent 52ace1a1a8
commit 00124bc7c2
20 changed files with 1980 additions and 776 deletions
@@ -8,8 +8,10 @@ import { db } from '@/lib/db';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
extractVideoFileNameFromProxyUrl,
getVideoAssetAccessContext,
} from '@/lib/video-assets';
import { buildVideoObjectKey } from '@/lib/video-upload-validation';
import { logError } from '@/lib/logger';
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
@@ -35,6 +37,16 @@ const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
};
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
const VIDEO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
mp4: 'video/mp4',
webm: 'video/webm',
ogg: 'video/ogg',
mov: 'video/quicktime',
m4v: 'video/mp4',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
};
function sanitizeFileName(value: string): string {
const sanitized = value
.replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-')
@@ -137,6 +149,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
});
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const fileName = extractVideoFileNameFromProxyUrl(asset.sourceUrl);
if (!fileName) return apiErrors.badRequest('Invalid video asset URL');
const key = buildVideoObjectKey(fileName);
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.mp4';
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
const contentDisposition = buildContentDisposition(downloadName);
const extKey = ext.replace('.', '');
const contentType = VIDEO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'video/mp4';
return proxyR2MediaObject({
request,
key,
fallbackContentType: contentType,
cacheControl: 'private, no-store',
extraHeaders: {
'Content-Disposition': contentDisposition,
'X-Content-Type-Options': 'nosniff',
},
internalErrorMessage: 'Failed to retrieve video',
});
}
const sourceParam = request.nextUrl.searchParams.get('source');
const rawQuality = request.nextUrl.searchParams.get('quality');
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
@@ -29,6 +29,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
provider: true,
sourceUrl: true,
providerVideoId: true,
thumbnailUrl: true,
uploadedByUserId: true,
uploadedByGuestIdentityId: true,
},
@@ -41,6 +42,8 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
let shouldDeleteImageObject = false;
let shouldDeleteAudioObject = false;
let shouldDeleteVideoObject = false;
let shouldDeleteVideoThumbnail = false;
await db.$transaction(async (tx) => {
await tx.videoAsset.delete({ where: { id: asset.id } });
@@ -59,6 +62,22 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
]);
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const [assetReferenceCount, versionReferenceCount] = await Promise.all([
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
tx.videoVersion.count({ where: { originalUrl: asset.sourceUrl } }),
]);
shouldDeleteVideoObject = assetReferenceCount === 0 && versionReferenceCount === 0;
if (asset.thumbnailUrl) {
const [assetThumbnailCount, commentImageCount] = await Promise.all([
tx.videoAsset.count({ where: { thumbnailUrl: asset.thumbnailUrl } }),
tx.comment.count({ where: { imageUrl: asset.thumbnailUrl } }),
]);
shouldDeleteVideoThumbnail = assetThumbnailCount === 0 && commentImageCount === 0;
}
}
});
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
@@ -68,6 +87,18 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
}
if (asset.provider === VideoAssetProvider.R2_VIDEO) {
const urlsToDelete: string[] = [];
if (shouldDeleteVideoObject && asset.sourceUrl) {
urlsToDelete.push(asset.sourceUrl);
}
if (shouldDeleteVideoThumbnail && asset.thumbnailUrl) {
urlsToDelete.push(asset.thumbnailUrl);
}
if (urlsToDelete.length > 0) {
r2CleanupResult = await deleteMediaFilesBestEffort(urlsToDelete);
}
}
let bunnyCleanupResult:
| Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>>