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, Download,
Loader2, Loader2,
Trash2, Trash2,
ChevronDown,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -167,13 +174,17 @@ export function ProjectContentClient({
}, []); }, []);
const startProjectDownload = useCallback( const startProjectDownload = useCallback(
async (videoIds?: string[]) => { async (videoIds?: string[], options?: { allVersions?: boolean }) => {
if (!canDownloadProject || isDownloading) return; if (!canDownloadProject || isDownloading) return;
const query = const searchParams = new URLSearchParams();
videoIds && videoIds.length > 0 if (videoIds && videoIds.length > 0) {
? `?videoIds=${encodeURIComponent(videoIds.join(','))}` searchParams.set('videoIds', videoIds.join(','));
: ''; }
if (options?.allVersions) {
searchParams.set('versions', 'all');
}
const query = searchParams.toString() ? `?${searchParams.toString()}` : '';
setIsDownloading(true); setIsDownloading(true);
try { try {
@@ -325,19 +336,27 @@ export function ProjectContentClient({
)} )}
</Button> </Button>
{canDownloadProject && localVideos.length > 0 && !selectionMode && ( {canDownloadProject && localVideos.length > 0 && !selectionMode && (
<Button <DropdownMenu>
variant="outline" <DropdownMenuTrigger asChild>
size="sm" <Button variant="outline" size="sm" disabled={isDownloading}>
onClick={() => startProjectDownload()} {isDownloading ? (
disabled={isDownloading} <Loader2 className="h-4 w-4 mr-2 animate-spin" />
> ) : (
{isDownloading ? ( <Download className="h-4 w-4 mr-2" />
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> )}
) : ( Download project
<Download className="h-4 w-4 mr-2" /> <ChevronDown className="h-4 w-4 ml-1" />
)} </Button>
Download project </DropdownMenuTrigger>
</Button> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startProjectDownload()}>
Latest version only
</DropdownMenuItem>
<DropdownMenuItem onClick={() => startProjectDownload(undefined, { allVersions: true })}>
All versions
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)} )}
{canEdit && ( {canEdit && (
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
@@ -398,19 +417,33 @@ export function ProjectContentClient({
Cancel Cancel
</Button> </Button>
{canDownloadProject && ( {canDownloadProject && (
<Button <DropdownMenu>
variant="outline" <DropdownMenuTrigger asChild>
size="sm" <Button
onClick={() => startProjectDownload(selectedVideoIds)} variant="outline"
disabled={isDownloading || selectedCount === 0} size="sm"
> disabled={isDownloading || selectedCount === 0}
{isDownloading ? ( >
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> {isDownloading ? (
) : ( <Loader2 className="h-4 w-4 mr-2 animate-spin" />
<Download className="h-4 w-4 mr-2" /> ) : (
)} <Download className="h-4 w-4 mr-2" />
Download selected )}
</Button> 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 && ( {canEdit && (
<Button <Button
@@ -22,6 +22,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const session = await auth(); const session = await auth();
const { projectId } = await params; const { projectId } = await params;
const requestedVideoIds = parseRequestedVideoIds(request.nextUrl.searchParams.get('videoIds')); const requestedVideoIds = parseRequestedVideoIds(request.nextUrl.searchParams.get('videoIds'));
const includeAllVersions = request.nextUrl.searchParams.get('versions') === 'all';
if (requestedVideoIds && requestedVideoIds.length === 0) { if (requestedVideoIds && requestedVideoIds.length === 0) {
return apiErrors.badRequest('At least one video must be selected for download'); 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); const validationError = validateProjectDownloadManifest(manifest);
if (validationError) { if (validationError) {
return apiErrors.badRequest(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'); return apiErrors.badRequest('Download is currently supported for Bunny versions only');
} }
if (sourceParam !== null && sourceParam !== 'original' && sourceParam !== 'compressed') { if (
return apiErrors.badRequest('Invalid source. Allowed values: original, compressed'); sourceParam !== null &&
sourceParam !== 'auto' &&
sourceParam !== 'original' &&
sourceParam !== 'compressed'
) {
return apiErrors.badRequest('Invalid source. Allowed values: auto, original, compressed');
} }
if ( if (
+25 -3
View File
@@ -119,6 +119,15 @@ type VideoRow = {
assets: AssetRow[]; 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 { function makeUniqueName(baseName: string, usedNames: Set<string>): string {
if (!usedNames.has(baseName)) { if (!usedNames.has(baseName)) {
usedNames.add(baseName); usedNames.add(baseName);
@@ -175,7 +184,9 @@ function buildAssetFileName(videoIndex: number, videoTitle: string, asset: Asset
function versionDownloadUrl(version: VersionRow): string | null { function versionDownloadUrl(version: VersionRow): string | null {
if (version.providerId === 'bunny' && version.videoId) { 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.providerId === 'r2') {
if (version.originalUrl.startsWith('/api/upload/video/')) { if (version.originalUrl.startsWith('/api/upload/video/')) {
@@ -209,10 +220,17 @@ function bigintToSafeNumber(value: bigint): number | null {
return Number(value); return Number(value);
} }
export type BuildProjectDownloadManifestOptions = {
/** Include every version of each video. Defaults to latest version only. */
includeAllVersions?: boolean;
};
export function buildProjectDownloadManifest( export function buildProjectDownloadManifest(
projectName: string, projectName: string,
videos: VideoRow[] videos: VideoRow[],
options: BuildProjectDownloadManifestOptions = {}
): ProjectDownloadManifest { ): ProjectDownloadManifest {
const { includeAllVersions = false } = options;
const files: ProjectDownloadManifestFile[] = []; const files: ProjectDownloadManifestFile[] = [];
const usedNames = new Set<string>(); const usedNames = new Set<string>();
@@ -224,7 +242,11 @@ export function buildProjectDownloadManifest(
const videoIndex = index + 1; const videoIndex = index + 1;
const videoTitle = sanitizeFileName(video.title) || `video-${videoIndex}`; 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); const url = versionDownloadUrl(version);
if (!url) continue; if (!url) continue;