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}