mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: name video downloads by title + version
Downloads now save as "<video title> <version label>" (or "<title> vN" when no label), with the real extension derived from the file's content type, instead of the CDN's generic "original" name. - Bunny (cross-origin CDN redirect) files are fetched and saved as a named blob, but only up to 10 GB; larger files fall back to a plain navigation so the browser streams to disk without buffering in memory. - R2 / S3 / MinIO uploads are same-origin (/api/upload/video/...), so the download attribute names them correctly at any size, no buffering. - Applies to both single-video and bulk/project downloads; bulk downloads run sequentially so at most one file is buffered at a time. - Shared helper in lib/client/download-file.ts.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
|||||||
VideoData,
|
VideoData,
|
||||||
} from '@/components/video-page/types';
|
} from '@/components/video-page/types';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
import { downloadNamedFile, extensionFromUrl, navigateDownload } from '@/lib/client/download-file';
|
||||||
|
|
||||||
function sanitizeDownloadFileName(value: string): string {
|
function sanitizeDownloadFileName(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -117,18 +118,36 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
|||||||
throw new Error('Missing download URL');
|
throw new Error('Missing download URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionLabel =
|
// File name: "<video title> <version label>" if the editor set a label
|
||||||
activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
|
// for this version, otherwise "<video title> v<number>".
|
||||||
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
|
const versionLabel = activeVersion.versionLabel?.trim();
|
||||||
const a = document.createElement('a');
|
const baseName =
|
||||||
a.href = downloadUrl;
|
sanitizeDownloadFileName(
|
||||||
if (activeVersion.providerId === 'direct' || activeVersion.providerId === 'r2') {
|
versionLabel
|
||||||
const ext = activeVersion.originalUrl.split('.').pop()?.toLowerCase() || 'mp4';
|
? `${video.title} ${versionLabel}`
|
||||||
a.download = `${baseName}.${ext}`;
|
: `${video.title} v${activeVersion.versionNumber}`
|
||||||
|
) || 'video';
|
||||||
|
|
||||||
|
if (activeVersion.providerId === 'r2') {
|
||||||
|
// Same-origin proxy: the download attribute applies and streams
|
||||||
|
// without buffering the whole file in memory (any size).
|
||||||
|
const ext = extensionFromUrl(activeVersion.originalUrl) || 'mp4';
|
||||||
|
navigateDownload(downloadUrl, `${baseName}.${ext}`);
|
||||||
|
} else {
|
||||||
|
// Bunny (CDN redirect) and direct hosts are cross-origin, so the
|
||||||
|
// download attribute is ignored on a plain navigation. Fetch the bytes
|
||||||
|
// (CORS is open) and save them with our filename — unless the file is
|
||||||
|
// over 10 GB, in which case downloadNamedFile returns false and we fall
|
||||||
|
// back to a plain navigation (streams to disk with the CDN's name).
|
||||||
|
const fallbackExt =
|
||||||
|
(activeVersion.providerId === 'direct'
|
||||||
|
? extensionFromUrl(activeVersion.originalUrl)
|
||||||
|
: '') || 'mp4';
|
||||||
|
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`);
|
||||||
|
if (!saved) {
|
||||||
|
navigateDownload(downloadUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
a.remove();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start video download:', error);
|
console.error('Failed to start video download:', error);
|
||||||
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
|
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Above this size we don't buffer the file in memory to rename it — the caller
|
||||||
|
// falls back to a plain navigation so the browser streams it straight to disk
|
||||||
|
// (with the CDN's own filename). 10 GiB.
|
||||||
|
export const MAX_NAMED_DOWNLOAD_BYTES = 10 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
const MIME_EXTENSION_MAP: Record<string, string> = {
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
'video/x-matroska': 'mkv',
|
||||||
|
'video/x-msvideo': 'avi',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extensionFromUrl(url: string): string {
|
||||||
|
const path = url.split('?')[0] ?? url;
|
||||||
|
const dot = path.lastIndexOf('.');
|
||||||
|
if (dot === -1) return '';
|
||||||
|
const ext = path.slice(dot + 1).toLowerCase();
|
||||||
|
return ext.length >= 1 && ext.length <= 5 ? ext : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceExtension(fileName: string, ext: string): string {
|
||||||
|
const dot = fileName.lastIndexOf('.');
|
||||||
|
const stem = dot > 0 ? fileName.slice(0, dot) : fileName;
|
||||||
|
return `${stem}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBlobAs(blob: Blob, fileName: string): void {
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = objectUrl;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
// Revoke after the download has had a chance to start.
|
||||||
|
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a (possibly cross-origin) file and save it under `fileName`. The
|
||||||
|
* browser's `download` attribute is ignored across origins / redirects, so for
|
||||||
|
* CDN sources we pull the bytes (CORS is open on them) and save the blob, which
|
||||||
|
* lets us control the name and derive the real extension from the content type.
|
||||||
|
*
|
||||||
|
* Returns `false` (without downloading) when the file is larger than
|
||||||
|
* MAX_NAMED_DOWNLOAD_BYTES — buffering that in memory would be unsafe — or when
|
||||||
|
* the fetch isn't usable, so the caller can fall back to a plain navigation.
|
||||||
|
* Returns `true` when the named blob was saved.
|
||||||
|
*/
|
||||||
|
export async function downloadNamedFile(url: string, fileName: string): Promise<boolean> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { cache: 'no-store' });
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!res.ok) return false;
|
||||||
|
|
||||||
|
const contentLength = Number(res.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > MAX_NAMED_DOWNLOAD_BYTES) {
|
||||||
|
await res.body?.cancel().catch(() => {});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let blob: Blob;
|
||||||
|
try {
|
||||||
|
blob = await res.blob();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeExt = MIME_EXTENSION_MAP[blob.type];
|
||||||
|
saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain navigation download (streams to disk; filename controlled only for
|
||||||
|
* same-origin URLs via the download attribute). */
|
||||||
|
export function navigateDownload(url: string, sameOriginFileName?: string): void {
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.rel = 'noopener';
|
||||||
|
if (sameOriginFileName && url.startsWith('/')) {
|
||||||
|
anchor.download = sameOriginFileName;
|
||||||
|
}
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { downloadNamedFile, navigateDownload } from '@/lib/client/download-file';
|
||||||
|
|
||||||
const DOWNLOAD_STAGGER_MS = 500;
|
const DOWNLOAD_STAGGER_MS = 500;
|
||||||
|
|
||||||
export type ProjectDownloadManifestFile = {
|
export type ProjectDownloadManifestFile = {
|
||||||
@@ -19,21 +21,28 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerBrowserDownload(file: ProjectDownloadManifestFile): void {
|
async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promise<void> {
|
||||||
const anchor = document.createElement('a');
|
// Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the
|
||||||
anchor.href = file.url;
|
// download attribute applies and the browser streams straight to disk, so the
|
||||||
anchor.rel = 'noopener';
|
// name is correct at any size with no memory cost.
|
||||||
if (file.url.startsWith('/')) {
|
if (file.url.startsWith('/api/upload/video/')) {
|
||||||
anchor.download = file.fileName;
|
navigateDownload(file.url, file.fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bunny (CDN redirect) and external direct hosts are cross-origin, so the name
|
||||||
|
// only applies if we fetch the bytes. downloadNamedFile does that for files up
|
||||||
|
// to 10 GB; larger ones fall back to a plain navigation (CDN filename).
|
||||||
|
const saved = await downloadNamedFile(file.url, file.fileName);
|
||||||
|
if (!saved) {
|
||||||
|
navigateDownload(file.url, file.fileName);
|
||||||
}
|
}
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise<void> {
|
export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise<void> {
|
||||||
for (let index = 0; index < manifest.files.length; index += 1) {
|
for (let index = 0; index < manifest.files.length; index += 1) {
|
||||||
triggerBrowserDownload(manifest.files[index]!);
|
// Sequential so at most one file is buffered in memory at a time.
|
||||||
|
await triggerBrowserDownload(manifest.files[index]!);
|
||||||
if (index < manifest.files.length - 1) {
|
if (index < manifest.files.length - 1) {
|
||||||
await sleep(DOWNLOAD_STAGGER_MS);
|
await sleep(DOWNLOAD_STAGGER_MS);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user