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'; import { useTranslation } from '@/lib/i18n'; type TFn = ReturnType['t']; // 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). export type Counts = { done: number; failed: number; total: number }; export 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'; // Takes `t` because it is a module-level helper, not a component — the alternative is a hook // call outside a component body. const statusBadge = (status: string, t: TFn) => { switch (status) { case 'running': return { icon: , label: t('jobs.status.running'), cls: 'text-amber-600 dark:text-amber-500', }; case 'pending': return { icon: , label: t('jobs.status.queued'), cls: 'text-blue-600 dark:text-blue-400', }; case 'completed': return { icon: , label: t('jobs.status.completed'), cls: 'text-duck-teal' }; case 'failed': return { icon: , label: t('jobs.status.failed'), cls: 'text-red-600 dark:text-red-400', }; case 'stopped': return { icon: , label: t('jobs.status.stopped'), cls: 'text-duck-dark/50 dark:text-foreground/50', }; default: return { icon: , label: t('jobs.status.interrupted'), cls: 'text-orange-600 dark:text-orange-400', }; } }; // A labelled progress bar: fill = processed/total; caption states kept/saved vs skipped/failed. export 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 { t } = useTranslation(); 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(t('jobs.stopRequested')); } catch { toast.error(t('jobs.stopFailed')); } }; if (loading) return (
); if (!job) return (
{t('jobs.notFound')} {t('jobs.backToJobs')}
); const badge = statusBadge(job.status, t); 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 titles' : 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}
}
); };