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
+94 -22
View File
@@ -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