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:
yusufipk
2026-07-10 21:58:09 +07:00
parent bede216081
commit 8845c2c643
3 changed files with 141 additions and 21 deletions
+92
View File
@@ -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();
}
+19 -10
View File
@@ -1,5 +1,7 @@
'use client';
import { downloadNamedFile, navigateDownload } from '@/lib/client/download-file';
const DOWNLOAD_STAGGER_MS = 500;
export type ProjectDownloadManifestFile = {
@@ -19,21 +21,28 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function triggerBrowserDownload(file: ProjectDownloadManifestFile): void {
const anchor = document.createElement('a');
anchor.href = file.url;
anchor.rel = 'noopener';
if (file.url.startsWith('/')) {
anchor.download = file.fileName;
async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promise<void> {
// Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the
// download attribute applies and the browser streams straight to disk, so the
// name is correct at any size with no memory cost.
if (file.url.startsWith('/api/upload/video/')) {
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> {
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) {
await sleep(DOWNLOAD_STAGGER_MS);
}