download job: write each item's description as a sidecar .txt next to the media

The metadata phase already fetches ReClip's description (ReClip now forwards it in
/api/info). Carry it into the download phase and write it to a text file with the
same base name as the media — "Song Name.mp3" → "Song Name.txt".

reclipDownloadOne gains an onFilename callback that fires the moment the final
filename is known (before the file transfers), so the executor writes the sidecar
in parallel with the download stream, and the exact name guarantees they pair up.
Empty descriptions write nothing; the write is best-effort (never fails a download).

Verified: correct base name + .txt, exact content, and no sidecar for an empty
description.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 19:26:16 +00:00
co-authored by Claude Opus 4.8
parent 459b8ab730
commit 7c43ff2291
2 changed files with 23 additions and 2 deletions
+20 -2
View File
@@ -1,5 +1,20 @@
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { reclipInfo, reclipPlaylist, reclipDownloadOne, type ReclipInfo } from '../../reclip-client';
// Write a per-item description sidecar next to its media file — same base name, `.txt` (e.g.
// "Song Name.mp3" → "Song Name.txt"). Best-effort; skipped when the description is empty.
async function writeDescriptionSidecar(dir: string, mediaFilename: string, description: string): Promise<void> {
const desc = description.trim();
if (!desc) return;
const base = mediaFilename.replace(/\.[^./\\]+$/, ''); // strip the media extension only
try {
await writeFile(join(dir, `${base}.txt`), desc, 'utf8');
} catch {
/* best-effort — a missing sidecar shouldn't fail the download */
}
}
// 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
@@ -80,12 +95,12 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise<vo
progress.phase = 'metadata';
progress.meta.total = urls.length;
emit(true);
const valid: Array<{ url: string; title: string }> = [];
const valid: Array<{ url: string; title: string; description: string }> = [];
for (const u of urls) {
checkAbort();
const info = await reclipInfo(u).catch((): ReclipInfo => ({ error: 'fetch failed' }));
if (info && !info.error) {
valid.push({ url: u, title: info.title ?? '' });
valid.push({ url: u, title: info.title ?? '', description: info.description ?? '' });
progress.meta.done++;
} else {
progress.meta.failed++;
@@ -109,6 +124,9 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise<vo
audioOnly,
title: item.title,
signal: params.abortSignal,
// Filename is known before the file transfers — write the description sidecar alongside it, in
// parallel with the download stream. Same base name guarantees they pair up.
onFilename: (filename) => void writeDescriptionSidecar(absDir, filename, item.description),
});
progress.dl.done++;
} catch {
+3
View File
@@ -10,6 +10,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export type ReclipInfo = {
title?: string;
description?: string;
thumbnail?: string;
duration?: number;
uploader?: string;
@@ -47,6 +48,7 @@ type DownloadOpts = {
audioOnly: boolean;
title?: string; // optional override; omit and ReClip names the file from the video title itself
onPhase?: (phase: 'transferring') => void;
onFilename?: (filename: string) => void; // fired as soon as the final filename is known (before streaming)
signal?: { aborted: boolean };
};
@@ -91,6 +93,7 @@ export async function reclipDownloadOne(opts: DownloadOpts): Promise<string> {
}
}
opts.onFilename?.(filename); // exact name known — the caller can write a sidecar while we stream
opts.onPhase?.('transferring');
// Stream the finished file into the destination folder (filename is title-sanitized by ReClip).