download job: restore the metadata phase — ReClip needs the title for filenames

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:17:27 +00:00
co-authored by Claude Opus 4.8
parent 7d149bd4d1
commit 459b8ab730
3 changed files with 154 additions and 64 deletions
@@ -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 (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-duck-dark dark:text-foreground">Downloaded</span>
<span
className={`font-medium ${active ? 'text-duck-dark dark:text-foreground' : 'text-duck-dark/50 dark:text-foreground/50'}`}
>
{label}
</span>
<span className="tabular-nums text-duck-dark/50 dark:text-foreground/50">
{processed}/{p.total || '—'}
{processed}/{c.total || '—'}
</span>
</div>
<div className="h-2.5 overflow-hidden rounded-full bg-duck-dark/10 dark:bg-foreground/10">
<div className="h-full rounded-full bg-duck-teal transition-all duration-300" style={{ width: `${pct}%` }} />
</div>
<div className="flex gap-3 text-[11px] text-duck-dark/50 dark:text-foreground/50">
<span className="text-duck-teal">{p.done} saved</span>
{p.failed > 0 && <span className="text-red-500/80">{p.failed} skipped</span>}
<span className="text-duck-teal">
{c.done} {savedLabel}
</span>
{c.failed > 0 && (
<span className="text-red-500/80">
{c.failed} {failedLabel}
</span>
)}
</div>
</div>
);
@@ -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 (
<div className="flex-1 flex flex-col min-h-0">
@@ -177,7 +205,20 @@ export const DownloadJobDetail = () => {
<div className="flex-1 min-h-0 overflow-y-auto p-5">
<div className="mx-auto flex max-w-md flex-col gap-5">
<div className="text-sm text-duck-dark/60 dark:text-foreground/60">{phaseLabel}</div>
<DownloadBar p={p ?? { phase: 'expanding', done: 0, failed: 0, total: 0 }} />
<PhaseBar
label="Titles"
c={p?.meta ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'metadata'}
savedLabel="found"
failedLabel="skipped"
/>
<PhaseBar
label="Download"
c={p?.dl ?? { done: 0, failed: 0, total: 0 }}
active={p?.phase === 'download'}
savedLabel="saved"
failedLabel="failed"
/>
{running && p?.phase === 'download' && p.current && (
<div className="truncate text-xs text-duck-dark/50 dark:text-foreground/50" title={p.current}>
<Loader2 className="mr-1.5 inline h-3 w-3 animate-spin" />