Files
OpenFrame/app/api/projects/[projectId]/download/route.ts
T
yusufipk 34e72f6cbb fix: bulk video download (original quality, latest version by default)
- Accept source=auto in the version download route (was 400 Bad Request),
  so bulk/project downloads of Bunny videos no longer fail.
- Bulk/project downloads now request the original (uncompressed) Bunny file
  so quality never drops (was source=auto which could fall back to compressed).
- Project/selected downloads default to the latest version of each video and
  add a separate "All versions" option in the download dropdowns.
2026-07-10 20:04:18 +07:00

109 lines
3.4 KiB
TypeScript

import { NextRequest } from 'next/server';
import { auth, checkProjectAccess } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { db } from '@/lib/db';
import { logError } from '@/lib/logger';
import {
buildProjectDownloadManifest,
canDownloadProjectMedia,
parseRequestedVideoIds,
validateProjectDownloadManifest,
} from '@/lib/project-download';
import { rateLimit } from '@/lib/rate-limit';
type RouteParams = { params: Promise<{ projectId: string }> };
// GET /api/projects/[projectId]/download
export async function GET(request: NextRequest, { params }: RouteParams) {
try {
const limited = await rateLimit(request, 'project-download');
if (limited) return limited;
const session = await auth();
const { projectId } = await params;
const requestedVideoIds = parseRequestedVideoIds(request.nextUrl.searchParams.get('videoIds'));
const includeAllVersions = request.nextUrl.searchParams.get('versions') === 'all';
if (requestedVideoIds && requestedVideoIds.length === 0) {
return apiErrors.badRequest('At least one video must be selected for download');
}
const project = await db.project.findUnique({
where: { id: projectId },
select: {
id: true,
name: true,
ownerId: true,
workspaceId: true,
visibility: true,
allowDownloads: true,
},
});
if (!project) {
return apiErrors.notFound('Project');
}
const access = await checkProjectAccess(project, session?.user?.id);
if (!canDownloadProjectMedia(project, access)) {
return apiErrors.forbidden('Project downloads are disabled for viewers');
}
const videos = await db.video.findMany({
where: {
projectId,
...(requestedVideoIds ? { id: { in: requestedVideoIds } } : {}),
},
orderBy: [{ position: 'asc' }, { id: 'asc' }],
select: {
id: true,
title: true,
position: true,
versions: {
orderBy: { versionNumber: 'asc' },
select: {
id: true,
versionNumber: true,
versionLabel: true,
providerId: true,
videoId: true,
originalUrl: true,
sizeBytes: true,
},
},
assets: {
orderBy: { createdAt: 'asc' },
select: {
id: true,
provider: true,
displayName: true,
sourceUrl: true,
providerVideoId: true,
sizeBytes: true,
},
},
},
});
if (requestedVideoIds) {
const foundIds = new Set(videos.map((video) => video.id));
const missing = requestedVideoIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
return apiErrors.badRequest('One or more selected videos do not belong to this project');
}
}
const manifest = buildProjectDownloadManifest(project.name, videos, { includeAllVersions });
const validationError = validateProjectDownloadManifest(manifest);
if (validationError) {
return apiErrors.badRequest(validationError);
}
const response = successResponse(manifest);
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error creating project download manifest:', error);
return apiErrors.internalError('Failed to prepare project download');
}
}