From 459b8ab730176375c2cac55d628e851de6704e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 19:17:27 +0000 Subject: [PATCH] =?UTF-8?q?download=20job:=20restore=20the=20metadata=20ph?= =?UTF-8?q?ase=20=E2=80=94=20ReClip=20needs=20the=20title=20for=20filename?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against live ReClip: POST /api/download with title:"" → a hash filename (b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip names the file from the title WE send (falling back to a hash) — it does not self-name. The title is mandatory, which means a metadata pass is required. Back to two phases: 1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip errors. 2. download — download each survivor passing its title, so files land with real names; skip download errors. Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per- item download. Progress is two counters again (Titles + Download); UI shows two bars. ~2 requests/item is inherent to needing the title (per the user's call: correctness over speed). Verified two-phase filtering + title passthrough + skip-on-error with a mock. Co-Authored-By: Claude Opus 4.8 --- .../Dashboard/Jobs/DownloadJobDetail.tsx | 69 ++++++++++++---- src/servers/api/tasks/execute-download.ts | 80 +++++++++++++------ .../apps/FileBrowser/VideoDownloadPanel.tsx | 69 +++++++++------- 3 files changed, 154 insertions(+), 64 deletions(-) diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx index 4ac074f2..b18ba6e5 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx @@ -7,11 +7,11 @@ import { toast } from 'sonner'; // Detail view for a `download` job — a compact two-phase progress readout (metadata → download), polled // from the job's persisted progress (the executor emits counter snapshots, not per-item events). +type Counts = { done: number; failed: number; total: number }; type DownloadProgress = { - phase: 'expanding' | 'download' | 'done'; - done: number; - failed: number; - total: number; + phase: 'expanding' | 'metadata' | 'download' | 'done'; + meta: Counts; + dl: Counts; current?: string; }; type DownloadJob = { @@ -54,24 +54,46 @@ const statusBadge = (status: string) => { } }; -// The download progress bar: fill = processed/total; caption states saved vs skipped. -const DownloadBar = ({ p }: { p: DownloadProgress }) => { - const processed = p.done + p.failed; - const pct = p.total ? (processed / p.total) * 100 : 0; +// A labelled progress bar: fill = processed/total; caption states kept/saved vs skipped/failed. +const PhaseBar = ({ + label, + c, + active, + savedLabel, + failedLabel, +}: { + label: string; + c: Counts; + active: boolean; + savedLabel: string; + failedLabel: string; +}) => { + const processed = c.done + c.failed; + const pct = c.total ? (processed / c.total) * 100 : 0; return (
- Downloaded + + {label} + - {processed}/{p.total || '—'} + {processed}/{c.total || '—'}
- {p.done} saved - {p.failed > 0 && {p.failed} skipped} + + {c.done} {savedLabel} + + {c.failed > 0 && ( + + {c.failed} {failedLabel} + + )}
); @@ -139,7 +161,13 @@ export const DownloadJobDetail = () => { const p = job.progress; const isAudio = job.inputs?.format !== 'video'; const phaseLabel = - !p || p.phase === 'expanding' ? 'Expanding playlist…' : p.phase === 'download' ? 'Downloading' : 'Done'; + !p || p.phase === 'expanding' + ? 'Preparing…' + : p.phase === 'metadata' + ? 'Fetching titles' + : p.phase === 'download' + ? 'Downloading' + : 'Done'; return (
@@ -177,7 +205,20 @@ export const DownloadJobDetail = () => {
{phaseLabel}
- + + {running && p?.phase === 'download' && p.current && (
diff --git a/src/servers/api/tasks/execute-download.ts b/src/servers/api/tasks/execute-download.ts index 4935458b..3f26f255 100644 --- a/src/servers/api/tasks/execute-download.ts +++ b/src/servers/api/tasks/execute-download.ts @@ -1,19 +1,22 @@ -import { reclipPlaylist, reclipDownloadOne } from '../../reclip-client'; +import { reclipInfo, reclipPlaylist, reclipDownloadOne, type ReclipInfo } from '../../reclip-client'; -// 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. +// The download-job executor — pure scripting, no agent. Two phases: +// 1. metadata — fetch each item's info (title + validity); keep the ones that resolve, skip the errors +// (private / deleted / unavailable). The title is required: ReClip names the output file +// from it (no title → a hash filename), so we cannot skip this pass. +// 2. download — download every survivor in the chosen format (audio/video), passing its title; skip +// anything that fails. +// The item list comes from the caller (inputs.urls — the exact list the panel expanded, so a Mix/radio +// playlist that returns a different set each call can't drift); falls back to expanding inputs.url. 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. +type Counts = { done: number; failed: number; total: number }; export type DownloadProgress = { - phase: 'expanding' | 'download' | 'done'; - done: number; // downloaded successfully - failed: number; // skipped (unavailable / download error) - total: number; - current?: string; // url of the item currently downloading + phase: 'expanding' | 'metadata' | 'download' | 'done'; + meta: Counts; // done = kept (title fetched), failed = skipped + dl: Counts; // done = saved, failed = failed + current?: string; // title of the item currently downloading }; export type DownloadEvent = { type: 'download:progress'; progress: DownloadProgress }; @@ -23,7 +26,7 @@ export type ExecuteDownloadParams = { userId: number; email: string; username?: string | null; - inputs: Record; // { url, format: 'audio'|'video', absDir } + inputs: Record; // { urls (JSON) | url, format: 'audio'|'video', absDir } cwd?: string; abortSignal: { aborted: boolean }; emit: (event: DownloadEvent) => void; @@ -36,13 +39,20 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise { const now = Date.now(); if (!force && now - lastEmit < EMIT_THROTTLE_MS) return; lastEmit = now; - params.emit({ type: 'download:progress', progress: { ...progress } }); + params.emit({ + type: 'download:progress', + progress: { ...progress, meta: { ...progress.meta }, dl: { ...progress.dl } }, + }); }; const checkAbort = () => { if (params.abortSignal.aborted) throw new Error('aborted'); @@ -66,20 +76,44 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise = []; for (const u of urls) { checkAbort(); - progress.current = u; + const info = await reclipInfo(u).catch((): ReclipInfo => ({ error: 'fetch failed' })); + if (info && !info.error) { + valid.push({ url: u, title: info.title ?? '' }); + progress.meta.done++; + } else { + progress.meta.failed++; + } + emit(); + } + emit(true); + + // ── Phase 2: download survivors (title → real filename) ── + progress.phase = 'download'; + progress.dl.total = valid.length; + emit(true); + for (const item of valid) { + checkAbort(); + progress.current = item.title || item.url; emit(true); try { - await reclipDownloadOne({ url: u, destDir: absDir, audioOnly, signal: params.abortSignal }); - progress.done++; + await reclipDownloadOne({ + url: item.url, + destDir: absDir, + audioOnly, + title: item.title, + signal: params.abortSignal, + }); + progress.dl.done++; } catch { checkAbort(); // an abort surfaces as a throw here — re-check so it stops instead of counting as a skip - progress.failed++; + progress.dl.failed++; } emit(); } diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx index cf25d334..ebe3ccd4 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx @@ -17,12 +17,12 @@ import { } from 'lucide-react'; import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI'; -// Compact progress shape mirrored from the download-job executor (done = saved, failed = skipped). +// Compact progress shape mirrored from the download-job executor (done = kept/saved, failed = skipped). +type JobCounts = { done: number; failed: number; total: number }; type JobProgress = { - phase: 'expanding' | 'download' | 'done'; - done: number; - failed: number; - total: number; + phase: 'expanding' | 'metadata' | 'download' | 'done'; + meta: JobCounts; + dl: JobCounts; current?: string; }; @@ -275,8 +275,8 @@ export const VideoDownloadPanel = () => { if (cancelled || !j) return; setJobStatus(j.status); setJobProg(j.progress); - if (j.progress && j.progress.done > lastDlDone.current) { - lastDlDone.current = j.progress.done; + if (j.progress && j.progress.dl.done > lastDlDone.current) { + lastDlDone.current = j.progress.dl.done; setRefreshSignal((n) => n + 1); } if (['completed', 'failed', 'stopped', 'interrupted'].includes(j.status)) clearInterval(timer); @@ -570,11 +570,39 @@ const JobView = ({ }) => { const terminal = ['completed', 'failed', 'stopped', 'interrupted'].includes(status); const phaseLabel = - !prog || prog.phase === 'expanding' ? 'Expanding playlist…' : prog.phase === 'download' ? 'Downloading' : 'Done'; - const done = prog?.done ?? 0; - const failed = prog?.failed ?? 0; - const total = prog?.total ?? 0; - const pct = total ? ((done + failed) / total) * 100 : 0; + !prog || prog.phase === 'expanding' + ? 'Preparing…' + : prog.phase === 'metadata' + ? 'Fetching titles' + : prog.phase === 'download' + ? 'Downloading' + : 'Done'; + const Bar = ({ label, c, saved, failed }: { label: string; c: JobCounts; saved: string; failed: string }) => { + const pct = c.total ? ((c.done + c.failed) / c.total) * 100 : 0; + return ( +
+
+ {label} + + {c.done + c.failed}/{c.total || '—'} + +
+
+
+
+
+ + {c.done} {saved} + + {c.failed > 0 && ( + + {c.failed} {failed} + + )} +
+
+ ); + }; return (
@@ -592,21 +620,8 @@ const JobView = ({ {audio ? 'Audio' : 'Video'}
-
-
- Downloaded - - {done + failed}/{total || '—'} - -
-
-
-
-
- {done} saved - {failed > 0 && {failed} skipped} -
-
+ + {!terminal && prog?.phase === 'download' && prog.current && (

{prog.current}