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:
@@ -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<string, DownloadJob>();
|
||||
|
||||
async function runReclipDownload(jobId: string, url: string, absPath: string, audioOnly: boolean) {
|
||||
const set = (patch: Partial<DownloadJob>) =>
|
||||
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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user