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:
@@ -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<string, string>; // { url, format: 'audio'|'video', absDir }
|
||||
inputs: Record<string, string>; // { 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<vo
|
||||
if (!absDir) throw new Error('download job missing target directory');
|
||||
const audioOnly = format !== 'video'; // default to audio
|
||||
|
||||
const progress: DownloadProgress = { phase: 'expanding', done: 0, failed: 0, total: 0 };
|
||||
const progress: DownloadProgress = {
|
||||
phase: 'expanding',
|
||||
meta: { done: 0, failed: 0, total: 0 },
|
||||
dl: { done: 0, failed: 0, total: 0 },
|
||||
};
|
||||
let lastEmit = 0;
|
||||
const emit = (force = false) => {
|
||||
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<vo
|
||||
throw new Error('download job missing url(s)');
|
||||
}
|
||||
|
||||
// ── Download: one request per item, skip failures ──
|
||||
progress.phase = 'download';
|
||||
progress.total = urls.length;
|
||||
// ── Phase 1: metadata (title + validity) ──
|
||||
progress.phase = 'metadata';
|
||||
progress.meta.total = urls.length;
|
||||
emit(true);
|
||||
const valid: Array<{ url: string; title: string }> = [];
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user