refactor(main): execFileAsync helper — unify the 5 spawn-and-read sites

Replace the hand-rolled `new Promise((resolve) => execFile(…, cb))` wrappers in
probe / ytdlp (×2) / ffmpeg / indexer.probeFlat / download.probeMeta with one
lib/exec.ts execFileAsync returning a normalized
{ok,stdout,stderr,timedOut,error} that never rejects. Each caller keeps its own
error mapping (timedOut -> "Timed out …" vs stderr) and JSON parsing; behaviour
is unchanged. windowsHide defaults on; per-call timeout/maxBuffer preserved.

Foundation for CC4/CC5 — the stdout line-buffer half lands with SIMP5, the
net.request half with SIMP16, at which point those checkboxes close.

Unit-tested (exec.test.ts) against the node binary: ok / stderr+nonzero-exit /
missing-binary / timeout. typecheck + 262 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 09:06:08 -04:00
parent 66eaaac8fc
commit 3baba3b7bc
7 changed files with 192 additions and 151 deletions
+23 -26
View File
@@ -14,6 +14,7 @@ import {
YTDLP_MISSING_MSG
} from './binaries'
import { getSettings } from './settings'
import { execFileAsync } from './lib/exec'
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
import { ensureManagedYtdlp } from './ytdlp'
import { materializeCookies, hasStoredCookies } from './cookies'
@@ -123,32 +124,28 @@ function logFailure(opts: StartDownloadOptions, title: string | undefined, error
// itself contains a newline, since each --print field is emitted on its own line.
const META_SEP = '\u001f'
function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
return new Promise((resolve) => {
execFile(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
'--',
url
],
{ windowsHide: true, maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS },
(err, stdout) => {
if (err) return resolve(null)
const [title, uploader, duration] = stdout.split(META_SEP).map((l) => l.trim())
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
resolve({
title: clean(title),
channel: clean(uploader),
durationLabel: clean(duration)
})
}
)
})
async function probeMeta(ytdlp: string, url: string): Promise<DownloadMeta | null> {
const r = await execFileAsync(
ytdlp,
[
'--no-playlist',
'--no-warnings',
'--skip-download',
'--print',
`%(title)s${META_SEP}%(uploader)s${META_SEP}%(duration_string)s`,
'--',
url
],
{ maxBuffer: META_MAX_BUFFER, timeout: META_PROBE_TIMEOUT_MS }
)
if (!r.ok) return null
const [title, uploader, duration] = r.stdout.split(META_SEP).map((l) => l.trim())
const clean = (v?: string): string | undefined => (v && v !== 'NA' ? v : undefined)
return {
title: clean(title),
channel: clean(uploader),
durationLabel: clean(duration)
}
}
// --- Argv construction (shared by startDownload and the command preview) ---