diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 591a7cd0..1e937c2a 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -1105,7 +1105,87 @@ router.post('/move', async (ctx) => { return ctx.json({ ok: true, results }); }); -// Download video via yt-dlp +// Download video by delegating to the ReClip service (its own yt-dlp). It runs as a background job so a +// large download never holds one long HTTP request open (a reverse proxy would 504 on that). POST +// returns a jobId immediately; the client polls GET /download-video/:jobId until done/error. +const RECLIP_BASE = process.env.RECLIP_URL ?? 'http://localhost:8899'; + +type DownloadJob = { + status: 'downloading' | 'transferring' | 'done' | 'error'; + error?: string; + filename?: string; + at: number; +}; +const downloadJobs = new Map(); + +async function runReclipDownload(jobId: string, url: string, absPath: string, audioOnly: boolean) { + const set = (patch: Partial) => + downloadJobs.set(jobId, { ...downloadJobs.get(jobId)!, ...patch, at: Date.now() }); + try { + // Best-effort metadata for a nice title-based filename (ReClip names by job id otherwise). + let title = ''; + try { + const infoRes = await fetch(`${RECLIP_BASE}/api/info`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(60_000), + }); + if (infoRes.ok) title = ((await infoRes.json()) as { title?: string }).title ?? ''; + } catch { + /* metadata is optional */ + } + + const dlRes = await fetch(`${RECLIP_BASE}/api/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, format: audioOnly ? 'audio' : 'video', title }), + signal: AbortSignal.timeout(30_000), + }).catch(() => { + throw new Error(`Could not reach ReClip at ${RECLIP_BASE}`); + }); + if (!dlRes.ok) throw new Error('ReClip rejected the download request'); + const reclipJob = ((await dlRes.json()) as { job_id?: string }).job_id; + if (!reclipJob) throw new Error('ReClip did not return a job id'); + + // Poll ReClip until done/error (generous deadline; ReClip enforces its own per-download cap). + const deadline = Date.now() + 60 * 60_000; + let filename = ''; + for (;;) { + if (Date.now() > deadline) throw new Error('Download timed out'); + await new Promise((r) => setTimeout(r, 2000)); + const stRes = await fetch(`${RECLIP_BASE}/api/status/${reclipJob}`, { signal: AbortSignal.timeout(15_000) }).catch(() => null); + if (!stRes?.ok) continue; + const st = (await stRes.json()) as { status: string; error?: string | null; filename?: string | null }; + if (st.status === 'error') throw new Error(st.error || 'ReClip download failed'); + if (st.status === 'done') { + filename = st.filename || `${reclipJob}.${audioOnly ? 'mp3' : 'mp4'}`; + break; + } + } + + set({ status: 'transferring', filename }); + + // Stream the finished file into the user's folder (filename is title-sanitized, no path parts). + const fileRes = await fetch(`${RECLIP_BASE}/api/file/${reclipJob}`, { signal: AbortSignal.timeout(600_000) }); + const body = fileRes.body; + if (!fileRes.ok || !body) throw new Error('Failed to fetch the downloaded file from ReClip'); + // Pump the reader manually — Bun.write(path, Response) can deadlock on a streaming fetch body. + const sink = Bun.file(join(absPath, filename)).writer(); + const reader = body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + sink.write(value); + } + await sink.end(); + + set({ status: 'done', filename }); + } catch (err) { + set({ status: 'error', error: err instanceof Error ? err.message : String(err) }); + } +} + router.post('/download-video', async (ctx) => { const user = ctx.get('user'); const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined); @@ -1115,30 +1195,22 @@ router.post('/download-video', async (ctx) => { const absPath = resolveUserPath(rootDir, path); await mkdir(absPath, { recursive: true }); - const ytdlp = Bun.which('yt-dlp') ?? `${process.env.HOME}/.local/bin/yt-dlp`; - const args = [ - ytdlp, - '--remote-components', - 'ejs:github', - '--js-runtimes', - 'node', - '--cookies-from-browser', - 'brave', - '-o', - '%(title)s.%(ext)s', - ]; - if (audioOnly) args.push('-x', '--audio-format', 'mp3'); - args.push(url); - const proc = Bun.spawn(args, { cwd: absPath, stdout: 'ignore', stderr: 'pipe' }); - const stderrText = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; + // Evict finished jobs older than an hour so the map doesn't grow unbounded. + const cutoff = Date.now() - 60 * 60_000; + for (const [id, job] of downloadJobs) if (job.at < cutoff) downloadJobs.delete(id); - if (exitCode !== 0) { - throw errors.BAD_REQUEST(stderrText.trim() || 'yt-dlp download failed'); - } + const jobId = crypto.randomUUID(); + downloadJobs.set(jobId, { status: 'downloading', at: Date.now() }); + void runReclipDownload(jobId, url, absPath, !!audioOnly); - return ctx.json({ ok: true }); + return ctx.json({ jobId }); +}); + +router.get('/download-video/:jobId', (ctx) => { + const job = downloadJobs.get(ctx.req.param('jobId')); + if (!job) return ctx.json({ status: 'error', error: 'unknown or expired job' }); + return ctx.json({ status: job.status, error: job.error, filename: job.filename }); }); // Git clone a repository into a directory diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 0dd1493d..3923788b 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -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 }); } }; diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index e6ebb711..70f9af89 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -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(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; // ── 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 };