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.
This commit is contained in:
yusufipk
2026-07-10 20:04:18 +07:00
parent cbaecb92cc
commit 34e72f6cbb
4 changed files with 98 additions and 37 deletions
@@ -18,11 +18,18 @@ import {
Download,
Loader2,
Trash2,
ChevronDown,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
@@ -167,13 +174,17 @@ export function ProjectContentClient({
}, []);
const startProjectDownload = useCallback(
async (videoIds?: string[]) => {
async (videoIds?: string[], options?: { allVersions?: boolean }) => {
if (!canDownloadProject || isDownloading) return;
const query =
videoIds && videoIds.length > 0
? `?videoIds=${encodeURIComponent(videoIds.join(','))}`
: '';
const searchParams = new URLSearchParams();
if (videoIds && videoIds.length > 0) {
searchParams.set('videoIds', videoIds.join(','));
}
if (options?.allVersions) {
searchParams.set('versions', 'all');
}
const query = searchParams.toString() ? `?${searchParams.toString()}` : '';
setIsDownloading(true);
try {
@@ -325,19 +336,27 @@ export function ProjectContentClient({
)}
</Button>
{canDownloadProject && localVideos.length > 0 && !selectionMode && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload()}
disabled={isDownloading}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download project
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isDownloading}>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download project
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startProjectDownload()}>
Latest version only
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startProjectDownload(undefined, { allVersions: true })}>
All versions
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{canEdit && (
<Button variant="outline" size="sm" asChild>
@@ -398,19 +417,33 @@ export function ProjectContentClient({
Cancel
</Button>
{canDownloadProject && (
<Button
variant="outline"
size="sm"
onClick={() => startProjectDownload(selectedVideoIds)}
disabled={isDownloading || selectedCount === 0}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download selected
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={isDownloading || selectedCount === 0}
>
{isDownloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download selected
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startProjectDownload(selectedVideoIds)}>
Latest version only
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => startProjectDownload(selectedVideoIds, { allVersions: true })}
>
All versions
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{canEdit && (
<Button
@@ -22,6 +22,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
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');
@@ -92,7 +93,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}
}
const manifest = buildProjectDownloadManifest(project.name, videos);
const manifest = buildProjectDownloadManifest(project.name, videos, { includeAllVersions });
const validationError = validateProjectDownloadManifest(manifest);
if (validationError) {
return apiErrors.badRequest(validationError);
@@ -315,8 +315,13 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Download is currently supported for Bunny versions only');
}
if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') {
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed');
if (
sourceParam !== null &&
sourceParam !== 'auto' &&
sourceParam !== 'original' &&
sourceParam !== 'compressed'
) {
return apiErrors.badRequest('Invalid source. Allowed values: auto, original, compressed');
}
if (
+25 -3
View File
@@ -119,6 +119,15 @@ type VideoRow = {
assets: AssetRow[];
};
function selectLatestVersion(versions: VersionRow[]): VersionRow[] {
if (versions.length === 0) return [];
let latest = versions[0]!;
for (const version of versions) {
if (version.versionNumber > latest.versionNumber) latest = version;
}
return [latest];
}
function makeUniqueName(baseName: string, usedNames: Set<string>): string {
if (!usedNames.has(baseName)) {
usedNames.add(baseName);
@@ -175,7 +184,9 @@ function buildAssetFileName(videoIndex: number, videoTitle: string, asset: Asset
function versionDownloadUrl(version: VersionRow): string | null {
if (version.providerId === 'bunny' && version.videoId) {
return `/api/versions/${version.id}/download?source=auto`;
// Always fetch the original (uncompressed) file for bulk/project downloads so
// quality never drops. 'auto' could silently fall back to a compressed MP4.
return `/api/versions/${version.id}/download?source=original`;
}
if (version.providerId === 'r2') {
if (version.originalUrl.startsWith('/api/upload/video/')) {
@@ -209,10 +220,17 @@ function bigintToSafeNumber(value: bigint): number | null {
return Number(value);
}
export type BuildProjectDownloadManifestOptions = {
/** Include every version of each video. Defaults to latest version only. */
includeAllVersions?: boolean;
};
export function buildProjectDownloadManifest(
projectName: string,
videos: VideoRow[]
videos: VideoRow[],
options: BuildProjectDownloadManifestOptions = {}
): ProjectDownloadManifest {
const { includeAllVersions = false } = options;
const files: ProjectDownloadManifestFile[] = [];
const usedNames = new Set<string>();
@@ -224,7 +242,11 @@ export function buildProjectDownloadManifest(
const videoIndex = index + 1;
const videoTitle = sanitizeFileName(video.title) || `video-${videoIndex}`;
for (const version of video.versions) {
const versionsToInclude = includeAllVersions
? video.versions
: selectLatestVersion(video.versions);
for (const version of versionsToInclude) {
const url = versionDownloadUrl(version);
if (!url) continue;