From bc1b799a270e4edb2b15276c982a40ea7c2b886d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 28 Jul 2026 18:20:07 +0000 Subject: [PATCH] =?UTF-8?q?jobs:=20download-job=20UI=20=E2=80=94=20panel?= =?UTF-8?q?=20decision=20screen=20+=20live=20progress=20+=20/jobs=20render?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Front half of the download-job feature. Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known, a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all as a job" (POST /jobs/download), or "fetch inline to pick individually" (the existing card grid). A single video still goes straight to the inline card. The job phase shows live two-phase progress (polled from the job) + a "View in Jobs" link; it notes the job runs server-side so closing the panel is fine, and it refreshes the browser as each file lands. /jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact two-phase readout — Metadata and Download bars (processed/total, found/skipped and saved/failed) + the current item — polled from the job's progress, with a Stop. Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases read the same `(done+failed)/total`. Co-Authored-By: Claude Opus 4.8 --- .../Dashboard/Jobs/DownloadJobDetail.tsx | 233 ++++++++++++++ .../Screens/Dashboard/Jobs/JobsPage.tsx | 102 ++++-- src/servers/api/tasks/execute-download.ts | 9 +- .../apps/FileBrowser/VideoDownloadPanel.tsx | 304 ++++++++++++++++-- 4 files changed, 595 insertions(+), 53 deletions(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx new file mode 100644 index 00000000..eb8eaf50 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/DownloadJobDetail.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router'; +import { Loader2, CheckCircle2, XCircle, Ban, Clock, ArrowLeft, Square, Film, Music } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +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' | 'metadata' | 'download' | 'done'; + meta: Counts; + dl: Counts; + current?: string; +}; +type DownloadJob = { + id: string; + mode: string; + taskName: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted'; + error: string | null; + cwd: string | null; + inputs?: Record | null; + progress: DownloadProgress | null; + createdAt: string; +}; + +const POLL_MS = 1500; +const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted'; + +const statusBadge = (status: string) => { + switch (status) { + case 'running': + return { + icon: , + label: 'Running', + cls: 'text-amber-600 dark:text-amber-500', + }; + case 'pending': + return { icon: , label: 'Queued', cls: 'text-blue-600 dark:text-blue-400' }; + case 'completed': + return { icon: , label: 'Completed', cls: 'text-duck-teal' }; + case 'failed': + return { icon: , label: 'Failed', cls: 'text-red-600 dark:text-red-400' }; + case 'stopped': + return { icon: , label: 'Stopped', cls: 'text-duck-dark/50 dark:text-foreground/50' }; + default: + return { + icon: , + label: 'Interrupted', + cls: 'text-orange-600 dark:text-orange-400', + }; + } +}; + +// A labelled progress bar: fill = processed/total; the caption states 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 ( +
+
+ + {label} + + + {processed}/{c.total || '—'} + +
+
+
+
+
+ + {c.done} {savedLabel} + + {c.failed > 0 && ( + + {c.failed} {failedLabel} + + )} +
+
+ ); +}; + +export const DownloadJobDetail = () => { + const { id } = useParams<{ id: string }>(); + const client = useClient(); + const [job, setJob] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!id) return; + let cancelled = false; + const load = async () => { + try { + const j = await client.get(`/jobs/${id}`); + if (!cancelled) setJob(j); + return j; + } catch { + return null; + } finally { + if (!cancelled) setLoading(false); + } + }; + void load(); + const timer = setInterval(async () => { + const j = await load(); + if (j && isTerminal(j.status)) clearInterval(timer); + }, POLL_MS); + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [id]); + + const stop = async () => { + if (!id) return; + try { + await client.post(`/jobs/${id}/stop`, {}); + toast.success('Stop requested'); + } catch { + toast.error('Failed to stop'); + } + }; + + if (loading) + return ( +
+ +
+ ); + if (!job) + return ( +
+ Job not found. + + Back to jobs + +
+ ); + + const badge = statusBadge(job.status); + const running = !isTerminal(job.status); + const p = job.progress; + const isAudio = job.inputs?.format !== 'video'; + const phaseLabel = + !p || p.phase === 'expanding' + ? 'Preparing…' + : p.phase === 'metadata' + ? 'Fetching metadata' + : p.phase === 'download' + ? 'Downloading' + : 'Done'; + + return ( +
+
+ + + +
+ {isAudio ? ( + + ) : ( + + )} +
+ {job.taskName} + {job.cwd && → {job.cwd}} +
+
+ + {badge.icon} {badge.label} + + {running && ( + + )} +
+ +
+
+
{phaseLabel}
+ + + {running && p?.phase === 'download' && p.current && ( +
+ + {p.current} +
+ )} + {job.error && !running &&
{job.error}
} +
+
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx index 7bd8bf34..d5a8d11a 100644 --- a/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Jobs/JobsPage.tsx @@ -1,11 +1,23 @@ import { useState, useEffect, useCallback, type ReactNode, type MouseEvent } from 'react'; import { useParams, useNavigate } from 'react-router'; -import { Search, CheckCircle2, AlertCircle, Clock, Loader2, StopCircle, AlertTriangle, Inbox, Square, Trash2 } from 'lucide-react'; +import { + Search, + CheckCircle2, + AlertCircle, + Clock, + Loader2, + StopCircle, + AlertTriangle, + Inbox, + Square, + Trash2, +} from 'lucide-react'; import { WorkspaceLayout } from 'officerdev'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { useClient } from 'hooks/useClient'; import { Card } from '@/components/Card'; import { ScriptJobDetail } from './ScriptJobDetail'; +import { DownloadJobDetail } from './DownloadJobDetail'; import { PipelineJobDetail } from './JobDetail'; type JobSummary = { @@ -28,12 +40,18 @@ const formatDate = (iso: string) => const StatusIcon = ({ status }: { status: JobSummary['status'] }) => { switch (status) { - case 'completed': return ; - case 'failed': return ; - case 'running': return ; - case 'stopped': return ; - case 'interrupted': return ; - case 'pending': return ; + case 'completed': + return ; + case 'failed': + return ; + case 'running': + return ; + case 'stopped': + return ; + case 'interrupted': + return ; + case 'pending': + return ; } }; @@ -43,7 +61,14 @@ const useJobsData = () => { const [jobs, setJobs] = useState([]); const [isLoading, setIsLoading] = useState(true); const load = useCallback( - () => client.get('/jobs').then((d) => { setJobs(d); setIsLoading(false); }).catch(() => setIsLoading(false)), + () => + client + .get('/jobs') + .then((d) => { + setJobs(d); + setIsLoading(false); + }) + .catch(() => setIsLoading(false)), [client], ); useEffect(() => { @@ -60,7 +85,12 @@ const useJobsData = () => { }, [client, load], ); - const clearHistory = useCallback(() => { client.delete('/jobs/history').then(() => load()).catch(() => {}); }, [client, load]); + const clearHistory = useCallback(() => { + client + .delete('/jobs/history') + .then(() => load()) + .catch(() => {}); + }, [client, load]); return { jobs, isLoading, act, clearHistory }; }; @@ -71,12 +101,21 @@ const JobRow = ({ job, onAction }: JobRowProps) => { const isRunning = job.status === 'running'; const target = basename(job.target); return ( -
- )} @@ -162,7 +205,9 @@ const HistoryJobsPanel = () => { {isLoading ? (
Loading…
) : history.length === 0 ? ( -
{search ? 'No matches' : 'No finished jobs'}
+
+ {search ? 'No matches' : 'No finished jobs'} +
) : ( history.map((job) => ) )} @@ -179,9 +224,15 @@ const JobDetailPanel = () => { const [mode, setMode] = useState(null); useEffect(() => { - if (!id) { setMode(null); return; } + if (!id) { + setMode(null); + return; + } setMode(null); - client.get<{ mode?: string }>(`/jobs/${id}`).then((j) => setMode(j.mode ?? 'pipeline')).catch(() => setMode('notfound')); + client + .get<{ mode?: string }>(`/jobs/${id}`) + .then((j) => setMode(j.mode ?? 'pipeline')) + .catch(() => setMode('notfound')); }, [id]); if (!id) { @@ -197,7 +248,9 @@ const JobDetailPanel = () => { if (mode === null) { return (
- + + +
); } @@ -208,11 +261,22 @@ const JobDetailPanel = () => {
); } - // Pipeline detail brings its own full chrome; the script terminal gets a card background here. + // Pipeline detail brings its own full chrome; the script terminal + download progress get a card here. if (mode === 'script') { return (
- + + + +
+ ); + } + if (mode === 'download') { + return ( +
+ + +
); } diff --git a/src/servers/api/tasks/execute-download.ts b/src/servers/api/tasks/execute-download.ts index 3deb8ff8..1381cc70 100644 --- a/src/servers/api/tasks/execute-download.ts +++ b/src/servers/api/tasks/execute-download.ts @@ -73,9 +73,12 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise ({ error: 'fetch failed' })); - if (info && !info.error) valid.push({ url: u, title: info.title ?? '' }); - else progress.meta.failed++; - progress.meta.done++; + if (info && !info.error) { + valid.push({ url: u, title: info.title ?? '' }); + progress.meta.done++; // done = kept; failed = skipped (both phases read `(done+failed)/total`) + } else { + progress.meta.failed++; + } emit(); } emit(true); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx index 2dd297f9..2d2d228e 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx @@ -1,10 +1,31 @@ -import { useRef, useState } from 'react'; -import { useSearchParams } from 'react-router'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; import { usePanelChannel } from 'hooks/usePanelChannel'; +import { useClient } from 'hooks/useClient'; import { Checkbox } from '@/components/ui/checkbox'; -import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music, Film, Folder } from 'lucide-react'; +import { + Download, + Loader2, + ChevronLeft, + AlertCircle, + Check, + Music, + Film, + Folder, + ExternalLink, + CheckCircle2, +} from 'lucide-react'; import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI'; +// Compact progress shape mirrored from the download-job executor (done = successes, failed = skipped). +type JobCounts = { done: number; failed: number; total: number }; +type JobProgress = { + phase: 'expanding' | 'metadata' | 'download' | 'done'; + meta: JobCounts; + dl: JobCounts; + current?: string; +}; + // Ephemeral side-panel video downloader. Opens on the `download` search param (target folder) with // `downloadRoot` the file-browser root. Self-contained: prefetches metadata (ReClip via the platform // proxy), then downloads per entry in a chosen FORMAT (video or audio) as background jobs, bumping the @@ -137,18 +158,27 @@ export const VideoDownloadPanel = () => { const basePath = searchParams.get('download') ?? '/'; const root = searchParams.get('downloadRoot') ?? 'home'; const files = useFilesAPI(root); + const client = useClient(); + const navigate = useNavigate(); const [, setRefreshSignal] = usePanelChannel('files:refresh-signal', 0); - const [phase, setPhase] = useState<'input' | 'preview'>('input'); + const [phase, setPhase] = useState<'input' | 'decide' | 'preview' | 'job'>('input'); const [url, setUrl] = useState(''); + const [expandedUrls, setExpandedUrls] = useState([]); // playlist items (count + inline) const [subfolder, setSubfolder] = useState(''); const [fetching, setFetching] = useState(false); + const [inputError, setInputError] = useState(''); const [entries, setEntries] = useState([]); const [mode, setMode] = useState<'normal' | 'select'>('normal'); const [sel, setSel] = useState>({}); const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null); + const [jobFormat, setJobFormat] = useState('audio'); + const [jobId, setJobId] = useState(null); + const [jobProg, setJobProg] = useState(null); + const [jobStatus, setJobStatus] = useState('running'); const entriesRef = useRef(entries); entriesRef.current = entries; + const lastDlDone = useRef(0); const isPlaylist = entries.length > 1; const anyReadyVideo = entries.some((e) => e.status === 'ready' && e.hasVideo); @@ -160,36 +190,36 @@ export const VideoDownloadPanel = () => { const patchFmt = (i: number, fmt: Fmt, p: Partial) => setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, [fmt]: { ...e[fmt], ...p } } : e))); - const fetchMeta = async () => { + // Fetch = expand. A playlist goes to the decision screen (inline vs job) now that we know the count; a + // single video goes straight to the inline card. + const onFetch = async () => { const u = url.trim(); if (!u) return; - setFetching(true); + setInputError(''); setEntries([]); setMode('normal'); setBulk(null); setSel({}); - - let urls = [u]; if (u.includes('list=')) { + setFetching(true); const pl = await files.videoPlaylist(u).catch(() => null); - if (pl?.error) { - setEntries([ - { - url: u, - status: 'error', - error: pl.error, - hasVideo: false, - video: { phase: 'idle' }, - audio: { phase: 'idle' }, - }, - ]); - setPhase('preview'); - setFetching(false); - return; - } - if (pl?.urls?.length) urls = pl.urls; + setFetching(false); + if (pl?.error || !pl?.urls?.length) return setInputError(pl?.error || 'No videos found in that playlist'); + setExpandedUrls(pl.urls); + setPhase('decide'); + } else { + setExpandedUrls([u]); + setPhase('preview'); + void fetchInline([u]); } + }; + // Interactive path: fetch each item's metadata sequentially, populating the cards. + const fetchInline = async (urls: string[]) => { + setFetching(true); + setMode('normal'); + setBulk(null); + setSel({}); setEntries( urls.map((v) => ({ url: v, @@ -199,9 +229,6 @@ export const VideoDownloadPanel = () => { audio: { phase: 'idle' }, })), ); - setPhase('preview'); - - // Sequentially (ReClip runs yt-dlp per video); cards fill in as they resolve. for (let i = 0; i < urls.length; i++) { const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' })); if (info.error) patch(i, { status: 'error', error: info.error, hasVideo: false }); @@ -218,6 +245,51 @@ export const VideoDownloadPanel = () => { setFetching(false); }; + // Job path: hand the whole playlist to a server-side two-phase download job (own lane, survives the + // panel closing). The server re-expands + fetches metadata (phase 1) then downloads survivors (phase 2). + const startJob = async () => { + try { + const res = await client.post<{ jobId: string; status: string }>('/jobs/download', { + url: url.trim(), + format: jobFormat, + dir: targetDir(), + root, + label: `${jobFormat === 'audio' ? 'Audio' : 'Video'} · ${expandedUrls.length} items`, + }); + lastDlDone.current = 0; + setJobId(res.jobId); + setJobStatus(res.status === 'pending' ? 'pending' : 'running'); + setJobProg(null); + setPhase('job'); + } catch { + setInputError('Could not start the job'); + } + }; + + // Poll the running job's progress; refresh the browser as each file lands. + useEffect(() => { + if (phase !== 'job' || !jobId) return; + let cancelled = false; + const poll = async () => { + const j = await client.get<{ status: string; progress: JobProgress | null }>(`/jobs/${jobId}`).catch(() => null); + if (cancelled || !j) return; + setJobStatus(j.status); + setJobProg(j.progress); + 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); + }; + void poll(); + const timer = setInterval(poll, 2000); + return () => { + cancelled = true; + clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [phase, jobId]); + const targetDir = () => { const s = sanitizeFolder(subfolder); if (!s) return basePath; @@ -284,11 +356,11 @@ export const VideoDownloadPanel = () => { Saving to {folderLabel}
- {phase === 'input' ? ( + {phase === 'input' && (
{ ev.preventDefault(); - void fetchMeta(); + void onFetch(); }} className="flex flex-col gap-3" > @@ -299,6 +371,7 @@ export const VideoDownloadPanel = () => { placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…" className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50" /> + {inputError &&

{inputError}

}
- ) : ( + )} + + {phase === 'decide' && ( +
+ +

+ Found {expandedUrls.length} items. +

+ setSubfolder(ev.target.value)} + placeholder="Subfolder (optional) — leave blank for this folder" + className="h-9 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50" + /> +
+ {(['audio', 'video'] as Fmt[]).map((f) => ( + + ))} +
+ + {inputError &&

{inputError}

} + +
+ )} + + {phase === 'job' && ( + jobId && navigate(`/jobs/${jobId}`)} + onNew={() => { + setPhase('input'); + setUrl(''); + setJobId(null); + setJobProg(null); + }} + /> + )} + + {phase === 'preview' && (
+ +
+

Runs on the server — you can close this panel; it keeps going.

+
+ ); +}; + // ── Cards ── const GridCard = ({