mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user