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
|
||||
|
||||
Reference in New Issue
Block a user