mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: show live progress while downloading named files
Bunny/cross-origin downloads are fetched into a blob before saving, which on large files or slow connections looked stuck (spinner only). Stream the body through a counting transform and show real byte progress in a toast: per-file percent for single downloads and file N/M + percent for bulk. - Progress is measured from Content-Length + received bytes (not estimated). - The blob is assembled by the browser from the stream (can be disk-backed), so we don't accumulate chunks in the JS heap. - Only the blob path shows a toast; same-origin (R2/S3/MinIO) and the >10 GB fallback use the browser's native download UI.
This commit is contained in:
@@ -222,9 +222,26 @@ export function ProjectContentClient({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.info(`Starting download of ${manifest.totalFiles} files…`);
|
const downloadToastId = `project-download-${projectId}`;
|
||||||
await runProjectDownloadManifest(manifest);
|
toast.loading(`Downloading ${manifest.totalFiles} files…`, {
|
||||||
toast.success(`Started ${manifest.totalFiles} file downloads`);
|
id: downloadToastId,
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
await runProjectDownloadManifest(manifest, (p) => {
|
||||||
|
const pct =
|
||||||
|
p.totalBytes && p.totalBytes > 0
|
||||||
|
? ` · ${Math.min(100, Math.floor((p.receivedBytes / p.totalBytes) * 100))}%`
|
||||||
|
: '';
|
||||||
|
toast.loading(`Downloading file ${p.index}/${p.total}`, {
|
||||||
|
id: downloadToastId,
|
||||||
|
description: `${p.fileName}${pct}`,
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
toast.success(`Downloaded ${manifest.totalFiles} files`, {
|
||||||
|
id: downloadToastId,
|
||||||
|
duration: 4000,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to start project download');
|
toast.error('Failed to start project download');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ 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';
|
import {
|
||||||
|
downloadNamedFile,
|
||||||
|
downloadProgressLabel,
|
||||||
|
extensionFromUrl,
|
||||||
|
navigateDownload,
|
||||||
|
} from '@/lib/client/download-file';
|
||||||
|
|
||||||
function sanitizeDownloadFileName(value: string): string {
|
function sanitizeDownloadFileName(value: string): string {
|
||||||
return value
|
return value
|
||||||
@@ -143,8 +148,25 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
|
|||||||
(activeVersion.providerId === 'direct'
|
(activeVersion.providerId === 'direct'
|
||||||
? extensionFromUrl(activeVersion.originalUrl)
|
? extensionFromUrl(activeVersion.originalUrl)
|
||||||
: '') || 'mp4';
|
: '') || 'mp4';
|
||||||
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`);
|
|
||||||
if (!saved) {
|
// The file is pulled into the browser before it can be saved, which on
|
||||||
|
// a big file / slow connection takes a while with no native download UI
|
||||||
|
// — show live progress so it doesn't look stuck.
|
||||||
|
const toastId = `download-${activeVersion.id}`;
|
||||||
|
toast.loading(`Downloading “${baseName}”…`, { id: toastId, duration: Infinity });
|
||||||
|
const saved = await downloadNamedFile(downloadUrl, `${baseName}.${fallbackExt}`, (p) => {
|
||||||
|
toast.loading(`Downloading “${baseName}”`, {
|
||||||
|
id: toastId,
|
||||||
|
description: downloadProgressLabel(p),
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (saved) {
|
||||||
|
toast.success(`“${baseName}” downloaded`, { id: toastId, duration: 4000 });
|
||||||
|
} else {
|
||||||
|
// Too large to buffer (or fetch blocked): let the browser download it
|
||||||
|
// directly (its own progress UI, CDN filename).
|
||||||
|
toast.dismiss(toastId);
|
||||||
navigateDownload(downloadUrl);
|
navigateDownload(downloadUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ const MIME_EXTENSION_MAP: Record<string, string> = {
|
|||||||
'video/x-msvideo': 'avi',
|
'video/x-msvideo': 'avi',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DownloadProgress = {
|
||||||
|
receivedBytes: number;
|
||||||
|
/** null when the server didn't send a Content-Length. */
|
||||||
|
totalBytes: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function extensionFromUrl(url: string): string {
|
export function extensionFromUrl(url: string): string {
|
||||||
const path = url.split('?')[0] ?? url;
|
const path = url.split('?')[0] ?? url;
|
||||||
const dot = path.lastIndexOf('.');
|
const dot = path.lastIndexOf('.');
|
||||||
@@ -27,6 +33,22 @@ function replaceExtension(fileName: string, ext: string): string {
|
|||||||
return `${stem}.${ext}`;
|
return `${stem}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadProgressLabel(progress: DownloadProgress): string {
|
||||||
|
const { receivedBytes, totalBytes } = progress;
|
||||||
|
if (totalBytes && totalBytes > 0) {
|
||||||
|
const pct = Math.min(100, Math.floor((receivedBytes / totalBytes) * 100));
|
||||||
|
return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`;
|
||||||
|
}
|
||||||
|
return formatBytes(receivedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
export function saveBlobAs(blob: Blob, fileName: string): void {
|
export function saveBlobAs(blob: Blob, fileName: string): void {
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
const anchor = document.createElement('a');
|
const anchor = document.createElement('a');
|
||||||
@@ -45,12 +67,20 @@ export function saveBlobAs(blob: Blob, fileName: string): void {
|
|||||||
* CDN sources we pull the bytes (CORS is open on them) and save the blob, which
|
* 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.
|
* lets us control the name and derive the real extension from the content type.
|
||||||
*
|
*
|
||||||
|
* The body is streamed through a counting transform so `onProgress` can report
|
||||||
|
* live progress; the Blob itself is assembled by the browser (which can back
|
||||||
|
* large blobs on disk) rather than accumulated in the JS heap.
|
||||||
|
*
|
||||||
* Returns `false` (without downloading) when the file is larger than
|
* Returns `false` (without downloading) when the file is larger than
|
||||||
* MAX_NAMED_DOWNLOAD_BYTES — buffering that in memory would be unsafe — or when
|
* MAX_NAMED_DOWNLOAD_BYTES — buffering that would be unsafe — or when the fetch
|
||||||
* the fetch isn't usable, so the caller can fall back to a plain navigation.
|
* isn't usable, so the caller can fall back to a plain navigation. Returns
|
||||||
* Returns `true` when the named blob was saved.
|
* `true` when the named blob was saved.
|
||||||
*/
|
*/
|
||||||
export async function downloadNamedFile(url: string, fileName: string): Promise<boolean> {
|
export async function downloadNamedFile(
|
||||||
|
url: string,
|
||||||
|
fileName: string,
|
||||||
|
onProgress?: (progress: DownloadProgress) => void
|
||||||
|
): Promise<boolean> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(url, { cache: 'no-store' });
|
res = await fetch(url, { cache: 'no-store' });
|
||||||
@@ -59,20 +89,52 @@ export async function downloadNamedFile(url: string, fileName: string): Promise<
|
|||||||
}
|
}
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
|
|
||||||
const contentLength = Number(res.headers.get('content-length'));
|
const contentLengthRaw = Number(res.headers.get('content-length'));
|
||||||
if (Number.isFinite(contentLength) && contentLength > MAX_NAMED_DOWNLOAD_BYTES) {
|
const totalBytes =
|
||||||
|
Number.isFinite(contentLengthRaw) && contentLengthRaw > 0 ? contentLengthRaw : null;
|
||||||
|
if (totalBytes !== null && totalBytes > MAX_NAMED_DOWNLOAD_BYTES) {
|
||||||
await res.body?.cancel().catch(() => {});
|
await res.body?.cancel().catch(() => {});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension comes from the response Content-Type (a stream-built Blob has no
|
||||||
|
// type), falling back to the URL.
|
||||||
|
const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? '';
|
||||||
|
|
||||||
|
let streamed: ReadableStream<Uint8Array<ArrayBufferLike>> | null = res.body;
|
||||||
|
if (res.body && onProgress) {
|
||||||
|
let received = 0;
|
||||||
|
let lastMarker = -1;
|
||||||
|
const counter = new TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
received += chunk.byteLength;
|
||||||
|
// Safety net for streams without a Content-Length.
|
||||||
|
if (received > MAX_NAMED_DOWNLOAD_BYTES) {
|
||||||
|
controller.error(new Error('File exceeds the in-memory download limit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Throttle: emit on each whole-percent change (or per ~2 MB if unknown).
|
||||||
|
const marker = totalBytes
|
||||||
|
? Math.floor((received / totalBytes) * 100)
|
||||||
|
: Math.floor(received / (2 * 1024 * 1024));
|
||||||
|
if (marker !== lastMarker) {
|
||||||
|
lastMarker = marker;
|
||||||
|
onProgress({ receivedBytes: received, totalBytes });
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
streamed = res.body.pipeThrough(counter);
|
||||||
|
}
|
||||||
|
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
try {
|
try {
|
||||||
blob = await res.blob();
|
blob = streamed ? await new Response(streamed).blob() : await res.blob();
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeExt = MIME_EXTENSION_MAP[blob.type];
|
const mimeExt = MIME_EXTENSION_MAP[contentType];
|
||||||
saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName);
|
saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { downloadNamedFile, navigateDownload } from '@/lib/client/download-file'
|
|||||||
|
|
||||||
const DOWNLOAD_STAGGER_MS = 500;
|
const DOWNLOAD_STAGGER_MS = 500;
|
||||||
|
|
||||||
|
export type ManifestDownloadProgress = {
|
||||||
|
/** 1-based index of the file currently downloading. */
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
fileName: string;
|
||||||
|
receivedBytes: number;
|
||||||
|
totalBytes: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectDownloadManifestFile = {
|
export type ProjectDownloadManifestFile = {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -21,10 +30,14 @@ function sleep(ms: number): Promise<void> {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promise<void> {
|
async function triggerBrowserDownload(
|
||||||
|
file: ProjectDownloadManifestFile,
|
||||||
|
onProgress?: (received: number, total: number | null) => void
|
||||||
|
): Promise<void> {
|
||||||
// Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the
|
// Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the
|
||||||
// download attribute applies and the browser streams straight to disk, so the
|
// download attribute applies and the browser streams straight to disk, so the
|
||||||
// name is correct at any size with no memory cost.
|
// name is correct at any size with no memory cost (browser shows its own
|
||||||
|
// progress, so we don't track bytes here).
|
||||||
if (file.url.startsWith('/api/upload/video/')) {
|
if (file.url.startsWith('/api/upload/video/')) {
|
||||||
navigateDownload(file.url, file.fileName);
|
navigateDownload(file.url, file.fileName);
|
||||||
return;
|
return;
|
||||||
@@ -33,17 +46,39 @@ async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promis
|
|||||||
// Bunny (CDN redirect) and external direct hosts are cross-origin, so the name
|
// 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
|
// 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).
|
// to 10 GB; larger ones fall back to a plain navigation (CDN filename).
|
||||||
const saved = await downloadNamedFile(file.url, file.fileName);
|
const saved = await downloadNamedFile(file.url, file.fileName, (p) =>
|
||||||
|
onProgress?.(p.receivedBytes, p.totalBytes)
|
||||||
|
);
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
navigateDownload(file.url, file.fileName);
|
navigateDownload(file.url, file.fileName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise<void> {
|
export async function runProjectDownloadManifest(
|
||||||
for (let index = 0; index < manifest.files.length; index += 1) {
|
manifest: ProjectDownloadManifest,
|
||||||
|
onProgress?: (progress: ManifestDownloadProgress) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const total = manifest.files.length;
|
||||||
|
for (let index = 0; index < total; index += 1) {
|
||||||
|
const file = manifest.files[index]!;
|
||||||
|
onProgress?.({
|
||||||
|
index: index + 1,
|
||||||
|
total,
|
||||||
|
fileName: file.fileName,
|
||||||
|
receivedBytes: 0,
|
||||||
|
totalBytes: file.sizeBytes,
|
||||||
|
});
|
||||||
// Sequential so at most one file is buffered in memory at a time.
|
// Sequential so at most one file is buffered in memory at a time.
|
||||||
await triggerBrowserDownload(manifest.files[index]!);
|
await triggerBrowserDownload(file, (received, fileTotal) =>
|
||||||
if (index < manifest.files.length - 1) {
|
onProgress?.({
|
||||||
|
index: index + 1,
|
||||||
|
total,
|
||||||
|
fileName: file.fileName,
|
||||||
|
receivedBytes: received,
|
||||||
|
totalBytes: fileTotal,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (index < total - 1) {
|
||||||
await sleep(DOWNLOAD_STAGGER_MS);
|
await sleep(DOWNLOAD_STAGGER_MS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user