jobs: video/audio download as a two-phase job (backend)
Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.
- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
(single download → streams the file to a dir, abort-aware). Extracted so both
the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
errors), phase 2 download (each survivor in the chosen format; skip download
errors). Emits a compact `download:progress` snapshot (counters, not per-item
events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
download:progress; adds execution LANES (download vs default) so the two run
independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
getPendingJobs() query for lane filling.
Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,7 +43,7 @@ function getUserDataDir(email: string): string {
|
||||
return join(DATA_PATH, email);
|
||||
}
|
||||
|
||||
function getRootDir(user: UserCtx, root?: string): string {
|
||||
export function getRootDir(user: UserCtx, root?: string): string {
|
||||
if (!root || root === 'home') return getOwnerHomeDir(user.email);
|
||||
if (root === 'user-data') return getUserDataDir(user.email);
|
||||
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
|
||||
@@ -54,7 +54,7 @@ function getRootDir(user: UserCtx, root?: string): string {
|
||||
// passes a `/home/br` check — which is how `..` segments escaped.
|
||||
const isInside = (root: string, target: string): boolean => target === root || target.startsWith(root + sep);
|
||||
|
||||
function resolveUserPath(rootDir: string, relPath: string): string {
|
||||
export function resolveUserPath(rootDir: string, relPath: string): string {
|
||||
const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
|
||||
if (!isInside(rootDir, resolved)) throw errors.FORBIDDEN('Path outside root directory');
|
||||
return resolved;
|
||||
|
||||
Reference in New Issue
Block a user