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
+27 -39
View File
@@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { PROBE_MAX_BUFFER, PROBE_TIMEOUT_MS } from './constants'
import { fmtBytes } from '@shared/format'
import { cleanError } from './log'
@@ -114,52 +114,40 @@ function buildPlaylist(data: RawInfo): PlaylistInfo {
* its format list) or, when the URL is a playlist, the flat list of its entries.
* `--flat-playlist` is a no-op for a lone video, so one call covers both cases.
*/
export function probeMedia(url: string): Promise<ProbeResult> {
export async function probeMedia(url: string): Promise<ProbeResult> {
const ytdlp = getYtdlpPath()
if (!existsSync(ytdlp)) {
return Promise.resolve({
ok: false,
error: YTDLP_MISSING_MSG
})
return { ok: false, error: YTDLP_MISSING_MSG }
}
let target: string
try {
target = assertHttpUrl(url) // normalised form (audit F5)
} catch (e) {
return Promise.resolve({ ok: false, error: (e as Error).message })
return { ok: false, error: (e as Error).message }
}
return new Promise((resolve) => {
execFile(
ytdlp,
// `--` terminates option parsing so the URL can never be read as a flag.
['-J', '--flat-playlist', '--no-warnings', '--', target],
{ windowsHide: true, maxBuffer: PROBE_MAX_BUFFER, timeout: PROBE_TIMEOUT_MS },
(err, stdout, stderr) => {
if (err) {
// execFile sets `killed` when it terminated the process on timeout.
const msg = (err as { killed?: boolean }).killed
? 'Timed out fetching video info. Check the link or your connection.'
: cleanError(stderr) || err.message
resolve({ ok: false, error: msg })
return
}
try {
const data = JSON.parse(stdout) as RawInfo
if (data._type === 'playlist' || Array.isArray(data.entries)) {
const playlist = buildPlaylist(data)
if (playlist.count === 0) {
resolve({ ok: false, error: 'This playlist has no downloadable entries.' })
return
}
resolve({ ok: true, kind: 'playlist', playlist })
} else {
resolve({ ok: true, kind: 'video', info: buildInfo(data) })
}
} catch {
resolve({ ok: false, error: 'Could not parse video info from yt-dlp.' })
}
}
)
// `--` terminates option parsing so the URL can never be read as a flag.
const r = await execFileAsync(ytdlp, ['-J', '--flat-playlist', '--no-warnings', '--', target], {
maxBuffer: PROBE_MAX_BUFFER,
timeout: PROBE_TIMEOUT_MS
})
if (!r.ok) {
const msg = r.timedOut
? 'Timed out fetching video info. Check the link or your connection.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
try {
const data = JSON.parse(r.stdout) as RawInfo
if (data._type === 'playlist' || Array.isArray(data.entries)) {
const playlist = buildPlaylist(data)
if (playlist.count === 0) {
return { ok: false, error: 'This playlist has no downloadable entries.' }
}
return { ok: true, kind: 'playlist', playlist }
}
return { ok: true, kind: 'video', info: buildInfo(data) }
} catch {
return { ok: false, error: 'Could not parse video info from yt-dlp.' }
}
}