file browser: download videos via ReClip as a background job

/download-video now delegates to the ReClip service (its own yt-dlp) and runs as
a background job: POST returns a jobId immediately and the client polls
GET /download-video/:jobId, so a large download no longer holds one long request
open (which was 504-ing behind the reverse proxy). the finished file is streamed
into the user's folder with a title-based name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 22:09:43 +00:00
co-authored by Claude Opus 4.8
parent 6ce75db150
commit b3d0de3dcb
3 changed files with 137 additions and 31 deletions
@@ -516,16 +516,41 @@ export const useFileBrowserApp = (
const handleVideoDownload = async () => {
const url = videoUrl.trim();
if (!url) return;
const toastId = toast.loading(audioOnly ? 'Extracting audio...' : 'Downloading video...');
const wasAudio = audioOnly;
const dir = currentPath;
// Close the dialog right away — the download runs as a background job and is tracked via a toast,
// so a large video no longer holds the request open (which was 504-ing behind the reverse proxy).
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
const toastId = toast.loading(wasAudio ? 'Extracting audio…' : 'Downloading video…');
try {
await files.downloadVideo(url, currentPath, audioOnly);
toast.success('Download complete', { id: toastId });
setShowVideoDownload(false);
setVideoUrl('');
setAudioOnly(false);
await refresh();
const { jobId } = await files.downloadVideo(url, dir, wasAudio);
const deadline = Date.now() + 60 * 60 * 1000;
for (;;) {
if (Date.now() > deadline) {
toast.error('Download timed out', { id: toastId });
return;
}
await new Promise((r) => setTimeout(r, 2000));
const st = await files.downloadVideoStatus(jobId).catch(() => null);
if (!st) continue;
if (st.status === 'error') {
toast.error(st.error || 'Download failed', { id: toastId });
return;
}
if (st.status === 'transferring') {
toast.loading('Saving to folder…', { id: toastId });
}
if (st.status === 'done') {
toast.success(st.filename ? `Downloaded ${st.filename}` : 'Download complete', { id: toastId });
await refresh();
return;
}
}
} catch {
toast.error('Download failed', { id: toastId });
toast.error('Could not start the download', { id: toastId });
}
};
@@ -46,7 +46,10 @@ export const useFilesAPI = (root: string = 'home') => {
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
downloadVideo: (url: string, path: string, audioOnly: boolean) =>
client.post(withRoot('/file-browser/download-video'), { url, path, audioOnly }),
client.post<{ jobId: string }>(withRoot('/file-browser/download-video'), { url, path, audioOnly }),
downloadVideoStatus: (jobId: string) =>
client.get<DownloadVideoStatus>(withRoot(`/file-browser/download-video/${jobId}`)),
tts: (path: string, opts?: { saveNextTo?: boolean }) =>
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }),
@@ -139,6 +142,12 @@ export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
// ── Types ──
export type DownloadVideoStatus = {
status: 'downloading' | 'transferring' | 'done' | 'error';
error?: string;
filename?: string;
};
export type AudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
export type SubtitleTrack = { id: number; codec: string; lang: string; title: string };