From 7d149bd4d1c87f321edb201c808470c796d4874a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 18:44:45 +0000 Subject: [PATCH] download job: use the panel's exact url list (no server re-expansion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every /api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to re-expand the playlist server-side, so it would download a different list than the count shown on the decision screen. The panel now passes the already-expanded `urls[]` into the job, and the executor uses them verbatim (falling back to expanding `url` only when no list is given). The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as inputs.urls) or `url`. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/tasks/execute-download.ts | 36 ++++++++++++------- src/servers/api/tasks/pipeline-jobs-routes.ts | 15 +++++--- .../apps/FileBrowser/VideoDownloadPanel.tsx | 2 +- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/servers/api/tasks/execute-download.ts b/src/servers/api/tasks/execute-download.ts index 2e1b3cd2..4935458b 100644 --- a/src/servers/api/tasks/execute-download.ts +++ b/src/servers/api/tasks/execute-download.ts @@ -1,10 +1,12 @@ import { reclipPlaylist, reclipDownloadOne } from '../../reclip-client'; -// The download-job executor — pure scripting, no agent. One phase: expand the playlist, then one download -// request per item in the chosen format (audio/video), skipping anything that fails (private / deleted / -// download error). No metadata prefetch — ReClip names the file from the video title itself. Emits a -// compact `download:progress` snapshot (counters, not per-item events — a playlist can be thousands of -// items). Throws on abort or a fatal error (playlist expansion); per-item errors are counted + skipped. +// The download-job executor — pure scripting, no agent. One download request per item in the chosen +// format (audio/video), skipping anything that fails (private / deleted / download error). No metadata +// prefetch — ReClip names the file from the video title itself. The item list comes from the caller +// (inputs.urls — the exact list the panel already expanded, so a Mix/radio playlist that returns a +// different set each call can't drift); it falls back to expanding inputs.url server-side. Emits a compact +// `download:progress` snapshot (counters, not per-item events — a playlist can be thousands of items). +// Throws on abort or a fatal error; per-item errors are counted + skipped. export type DownloadProgress = { phase: 'expanding' | 'download' | 'done'; @@ -30,8 +32,8 @@ export type ExecuteDownloadParams = { const EMIT_THROTTLE_MS = 750; export async function executeDownload(params: ExecuteDownloadParams): Promise { - const { url, format, absDir } = params.inputs; - if (!url || !absDir) throw new Error('download job missing url or target directory'); + const { url, urls: urlsJson, format, absDir } = params.inputs; + if (!absDir) throw new Error('download job missing target directory'); const audioOnly = format !== 'video'; // default to audio const progress: DownloadProgress = { phase: 'expanding', done: 0, failed: 0, total: 0 }; @@ -48,12 +50,20 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise { }); // POST /download — enqueue a video/audio download job (ReClip). Runs in its own lane, needs no capability -// task. Body: { url (video or playlist), format:'audio'|'video', dir (target folder, home-relative), -// root?, label? }. The job expands + fetches metadata (phase 1) then downloads survivors (phase 2). +// task. Body: { urls[] (the exact list to download — preferred), OR url (expanded server-side), +// format:'audio'|'video', dir (target folder, home-relative), root?, label? }. Passing `urls` locks the +// job to the list the panel already expanded, so a Mix/radio playlist can't drift between fetch + job. pipelineJobsRouter.post('/download', async (c) => { const user = c.get('user'); const body = await c.req.json<{ - url: string; + url?: string; + urls?: string[]; format?: 'audio' | 'video'; dir?: string; root?: string; label?: string; }>(); - if (!body.url) throw errors.BAD_REQUEST('url is required'); + if (!body.url && !body.urls?.length) throw errors.BAD_REQUEST('url or urls is required'); const rootDir = getRootDir(user, body.root); const absDir = resolveUserPath(rootDir, body.dir ?? '/'); // traversal-guarded const format = body.format === 'video' ? 'video' : 'audio'; + const inputs: Record = body.urls?.length + ? { urls: JSON.stringify(body.urls), format, absDir } + : { url: body.url!, format, absDir }; const { jobId, status } = await jobManager.enqueueJob( { userId: user.id, @@ -95,7 +100,7 @@ pipelineJobsRouter.post('/download', async (c) => { mode: 'download', taskDirName: 'video-download', taskName: body.label || (format === 'audio' ? 'Audio download' : 'Video download'), - inputs: { url: body.url, format, absDir }, + inputs, cwd: body.dir ?? '/', config: {}, }, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx index b8dfd66d..cf25d334 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx @@ -250,7 +250,7 @@ export const VideoDownloadPanel = () => { const startJob = async () => { try { const res = await client.post<{ jobId: string; status: string }>('/jobs/download', { - url: url.trim(), + urls: expandedUrls, // the exact list we counted — a Mix playlist won't re-expand to a different set format: jobFormat, dir: targetDir(), root,