veodl 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backend.js ADDED
@@ -0,0 +1,520 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream, constants } from 'node:fs';
3
+ import { access, chmod, copyFile, lstat, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { Readable, Transform } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { cacheBase } from './paths.js';
9
+ import { compareVersions } from './version.js';
10
+ import { hasTermuxEjs, installTermuxTools, installMediaTools, runSetup } from './tool-setup.js';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ export const TERMUX_SETUP = 'pkg install python-yt-dlp yt-dlp-ejs ffmpeg';
14
+ export const RELEASE = '2026.08.19';
15
+ const RELEASE_HOST = 'https://github.com/yt-dlp/yt-dlp/releases/download';
16
+ const MAX_BYTES = 256 * 1024 * 1024;
17
+ const DOWNLOAD_TIMEOUT = 180_000;
18
+ const RELEASE_PATTERN = /^\d{4}\.\d{2}\.\d{2}(?:\.\d+)?$/;
19
+ // Official hashes fetched at development time; never trust a runtime checksum download.
20
+ // https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/SHA2-256SUMS
21
+ const HASHES = Object.freeze({
22
+ 'yt-dlp.exe': '66674953fe251b89f4d08c5f0e35e0728679bd67ab3d7d05c0562af101dd3e7a',
23
+ 'yt-dlp_arm64.exe': '05b438997bafc3affdfda9d041353c9d73e04dc842207254b655b0887c4445b0',
24
+ 'yt-dlp_x86.exe': 'a8f91bd41452506bc81ebd2f369b186fea0ee7075413ba00cef9fd346a0a5d0c',
25
+ 'yt-dlp_macos': '0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202',
26
+ 'yt-dlp_linux': '58162f9bfdc27458ea47bfcb311cf47028f17d8154a8bf7d689861d46399230a',
27
+ 'yt-dlp_linux_aarch64': 'b16e4dab368a816cd05d477d698a605a6ae87ccee1c8ffd38fa21d7254141fcc',
28
+ 'yt-dlp_musllinux': 'f3dec9cfeaf304cec98290fe41c6ad465d4b747d302473559643e7af24929722',
29
+ 'yt-dlp_musllinux_aarch64': '17b164c4d258be92bb1ad146cb7c336b783aedb380814aabbcb7d52937f77e57',
30
+ });
31
+
32
+ export function selectAsset(platform = process.platform, arch = process.arch, musl = false) {
33
+ if (platform === 'win32') return { x64: 'yt-dlp.exe', arm64: 'yt-dlp_arm64.exe', ia32: 'yt-dlp_x86.exe' }[arch];
34
+ if (platform === 'darwin' && ['x64', 'arm64'].includes(arch)) return 'yt-dlp_macos';
35
+ if (platform === 'linux' && ['x64', 'arm64'].includes(arch)) return `yt-dlp_${musl ? 'musl' : ''}linux${arch === 'arm64' ? '_aarch64' : ''}`;
36
+ }
37
+
38
+ function cacheDirectory(release = RELEASE) {
39
+ return path.join(cacheBase(), 'backends', release, `${process.platform}-${process.arch}`);
40
+ }
41
+
42
+ export { cacheDirectory as backendCacheDirectory };
43
+
44
+ export function releaseUrl(release, asset) {
45
+ return `${RELEASE_HOST}/${release}/${asset}`;
46
+ }
47
+
48
+ export function isValidRelease(value) {
49
+ return typeof value === 'string' && RELEASE_PATTERN.test(value);
50
+ }
51
+
52
+ /** Path of the state file that records an explicitly installed newer backend. */
53
+ export function backendStateFile({ env = process.env } = {}) {
54
+ return path.join(cacheBase({ env }), 'backend-override.json');
55
+ }
56
+
57
+ export async function readBackendOverride(stateFile = backendStateFile()) {
58
+ try {
59
+ const state = JSON.parse(await readFile(stateFile, 'utf8'));
60
+ return state && typeof state === 'object' && !Array.isArray(state) ? state : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export async function writeBackendOverride(state, stateFile = backendStateFile()) {
67
+ await mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
68
+ const temp = `${stateFile}.${randomUUID()}.tmp`;
69
+ try {
70
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
71
+ await rename(temp, stateFile);
72
+ } finally {
73
+ await rm(temp, { force: true });
74
+ }
75
+ }
76
+
77
+ /** Returns true only when a recorded override was actually removed. */
78
+ export async function clearBackendOverride(stateFile = backendStateFile()) {
79
+ try {
80
+ await rm(stateFile);
81
+ return true;
82
+ } catch (error) {
83
+ if (error.code === 'ENOENT') return false;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The explicitly installed backend only wins when it is newer than the pinned
90
+ * release, matches this platform's asset, and its bytes still hash to the value
91
+ * recorded at install time. Anything else silently falls back to the pinned
92
+ * release, so a tampered or truncated cache never becomes the default.
93
+ */
94
+ export async function activeOverride(asset, { signal, stateFile = backendStateFile(), matchesImpl = matches } = {}) {
95
+ if (!asset) return null;
96
+ const state = await readBackendOverride(stateFile);
97
+ if (!state || state.asset !== asset) return null;
98
+ if (!isValidRelease(state.release) || !/^[a-f0-9]{64}$/.test(String(state.sha256))) return null;
99
+ if (compareVersions(state.release, RELEASE) <= 0) return null;
100
+ const file = path.join(cacheDirectory(state.release), `yt-dlp${exeSuffix()}`);
101
+ if (!await matchesImpl(file, state.sha256, signal)) return null;
102
+ return { release: state.release, asset, sha256: state.sha256, path: file };
103
+ }
104
+
105
+ /**
106
+ * Install a backend release that is newer than the pinned one. The expected
107
+ * hash must come from that release's own checksum list; the caller is
108
+ * responsible for the trust decision and for telling the user about it.
109
+ */
110
+ export async function installBackend({ release, asset, sha256, signal, status = () => {} }) {
111
+ if (!isValidRelease(release)) throw new Error(`Invalid yt-dlp release: ${release}`);
112
+ if (!asset) throw new Error(`No standalone yt-dlp is available for ${process.platform}/${process.arch}.`);
113
+ if (!/^[a-f0-9]{64}$/.test(String(sha256))) throw new Error('The release checksum is missing or malformed.');
114
+ const directory = cacheDirectory(release);
115
+ await mkdir(directory, { recursive: true, mode: 0o700 });
116
+ const destination = await downloadBackend({ asset, release, expected: sha256, directory, signal, status, replace: true });
117
+ return { release, asset, sha256, path: destination };
118
+ }
119
+
120
+ export function exeSuffix(platform = process.platform) {
121
+ return platform === 'win32' ? '.exe' : '';
122
+ }
123
+
124
+ // ffprobe-static terminates the whole process for platforms it does not know,
125
+ // so the static packages may only be required for combos it handles.
126
+ export function staticToolsSupported(platform = process.platform, arch = process.arch) {
127
+ if (platform === 'win32') return ['x64', 'ia32', 'arm64'].includes(arch);
128
+ if (platform === 'darwin') return ['x64', 'arm64'].includes(arch);
129
+ if (platform === 'linux') return ['x64', 'ia32', 'arm', 'arm64'].includes(arch);
130
+ return false;
131
+ }
132
+
133
+ async function isExecutable(file) {
134
+ if (typeof file !== 'string' || !file) return false;
135
+ try {
136
+ if (!(await stat(file)).isFile()) return false;
137
+ await access(file, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
138
+ return true;
139
+ } catch {
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Locate the first matching executable on PATH. Windows on ARM and minimal
146
+ * containers cannot use the bundled static binaries, so a system ffmpeg is a
147
+ * legitimate third source after the override and the managed cache.
148
+ * Git for Windows and WinGet ship ffmpeg in directories such as these but do
149
+ * not always extend the PATH that a Node process inherits.
150
+ */
151
+ export function wellKnownMediaDirectories({ platform = process.platform, env = process.env } = {}) {
152
+ if (platform === 'android') return env.PREFIX ? [path.join(env.PREFIX, 'bin')] : [];
153
+ if (platform !== 'win32') return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
154
+ const local = env.LOCALAPPDATA || '';
155
+ const programFiles = env.ProgramFiles || 'C:\\Program Files';
156
+ return [
157
+ local && path.join(local, 'Microsoft', 'WinGet', 'Links'),
158
+ local && path.join(local, 'Microsoft', 'WindowsApps'),
159
+ path.join(programFiles, 'ffmpeg', 'bin'),
160
+ 'C:\\ffmpeg\\bin',
161
+ 'C:\\ProgramData\\chocolatey\\bin',
162
+ ].filter(Boolean);
163
+ }
164
+
165
+ export async function findOnPath(names, { platform = process.platform, env = process.env, directories } = {}) {
166
+ const raw = platform === 'win32' ? env.PATH || env.Path || '' : env.PATH || '';
167
+ const candidates = directories || [
168
+ ...raw.split(platform === 'win32' ? ';' : ':').map(entry => entry.trim().replace(/^"(.*)"$/, '$1')),
169
+ ...wellKnownMediaDirectories({ platform, env }),
170
+ ];
171
+ const suffixes = platform === 'win32' ? ['', '.exe', '.cmd'] : [''];
172
+ for (const directory of candidates) {
173
+ if (!path.isAbsolute(directory)) continue;
174
+ for (const name of names) {
175
+ for (const suffix of suffixes) {
176
+ const candidate = path.join(directory, `${name}${suffix}`);
177
+ if (await isExecutable(candidate)) return candidate;
178
+ }
179
+ }
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function envPath(name) {
185
+ if (!(name in process.env)) return undefined;
186
+ const value = process.env[name];
187
+ if (!value?.trim() || value.includes('\0')) throw new Error(`${name} must be a non-empty filesystem path.`);
188
+ return path.resolve(value);
189
+ }
190
+
191
+ async function executable(file, label) {
192
+ if (typeof file !== 'string' || !file) throw new Error(`${label} is unavailable on ${process.platform}/${process.arch}.`);
193
+ try {
194
+ if (!(await stat(file)).isFile()) throw new Error('not a regular file');
195
+ await access(file, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
196
+ } catch (cause) {
197
+ throw new Error(`${label} is missing or not executable: ${file}`, { cause });
198
+ }
199
+ return path.resolve(file);
200
+ }
201
+
202
+ async function sha256(file, signal) {
203
+ signal?.throwIfAborted();
204
+ const hash = createHash('sha256');
205
+ let bytes = 0;
206
+ for await (const chunk of createReadStream(file, { signal })) {
207
+ bytes += chunk.length;
208
+ if (bytes > MAX_BYTES) throw new Error(`Backend file exceeds ${MAX_BYTES / 1024 / 1024} MiB: ${file}`);
209
+ hash.update(chunk);
210
+ }
211
+ return hash.digest('hex');
212
+ }
213
+
214
+ async function matches(file, expected, signal) {
215
+ try {
216
+ const info = await lstat(file);
217
+ // Do not accept cached symlinks or special files.
218
+ if (!info.isFile() || info.size > MAX_BYTES) return false;
219
+ return await sha256(file, signal) === expected;
220
+ } catch (error) {
221
+ if (error.code === 'ENOENT') return false;
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ async function publish(temp, destination, expected, signal) {
227
+ signal?.throwIfAborted();
228
+ if (process.platform !== 'win32') await chmod(temp, 0o755);
229
+ try {
230
+ await rename(temp, destination);
231
+ } catch (error) {
232
+ // Another process may have finished the same acquisition on Windows.
233
+ if (['EEXIST', 'EPERM', 'EACCES'].includes(error.code)
234
+ && await matches(destination, expected, signal)) return;
235
+ // A directory at the destination is stale cache from an older layout.
236
+ if (['EEXIST', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EISDIR'].includes(error.code)) {
237
+ const info = await lstat(destination).catch(() => undefined);
238
+ if (info?.isDirectory()) {
239
+ await rm(destination, { recursive: true, force: true });
240
+ try { await rename(temp, destination); return; } catch { /* Fall through. */ }
241
+ }
242
+ }
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Download one yt-dlp asset from its release and publish it only after the
249
+ * SHA-256 of the received bytes matched `expected`. With `replace`, an already
250
+ * present file is removed first, which is safe because the temp copy is fully
251
+ * verified before it is published.
252
+ */
253
+ async function downloadBackend({ asset, release, expected, directory, signal, status, replace = false }) {
254
+ const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
255
+ const temp = `${destination}.${randomUUID()}.tmp`;
256
+ const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT);
257
+ const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
258
+ status(`Downloading yt-dlp ${release}…`);
259
+ try {
260
+ const response = await fetch(`${RELEASE_HOST}/${release}/${asset}`, { signal: downloadSignal });
261
+ if (!response.ok || !response.body) {
262
+ await response.body?.cancel();
263
+ throw new Error(`yt-dlp download failed: HTTP ${response.status}.`);
264
+ }
265
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) {
266
+ await response.body.cancel();
267
+ throw new Error('yt-dlp download exceeds the size limit.');
268
+ }
269
+ let bytes = 0;
270
+ const hash = createHash('sha256');
271
+ const verifier = new Transform({
272
+ transform(chunk, encoding, callback) {
273
+ bytes += chunk.length;
274
+ if (bytes > MAX_BYTES) return callback(new Error('yt-dlp download exceeds the size limit.'));
275
+ hash.update(chunk);
276
+ callback(null, chunk);
277
+ },
278
+ });
279
+ await pipeline(Readable.fromWeb(response.body), verifier,
280
+ createWriteStream(temp, { flags: 'wx', mode: 0o600 }), { signal: downloadSignal });
281
+ if (hash.digest('hex') !== expected) throw new Error('yt-dlp SHA-256 verification failed. The download was discarded.');
282
+ if (replace) await rm(destination, { recursive: true, force: true });
283
+ await publish(temp, destination, expected, downloadSignal);
284
+ return destination;
285
+ } catch (cause) {
286
+ signal?.throwIfAborted();
287
+ if (timeout.aborted) throw new Error('yt-dlp download timed out after 3 minutes. Please retry.', { cause });
288
+ throw new Error(`Unable to prepare yt-dlp: ${cause.message} You can set VEO_YT_DLP_PATH to a trusted local executable.`, { cause });
289
+ } finally {
290
+ await rm(temp, { force: true });
291
+ }
292
+ }
293
+
294
+ async function acquire(asset, directory, signal, status) {
295
+ const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
296
+ const expected = HASHES[asset];
297
+ status('Checking yt-dlp…');
298
+ if (await matches(destination, expected, signal)) {
299
+ if (process.platform !== 'win32') await chmod(destination, 0o755);
300
+ return destination;
301
+ }
302
+ status(`Downloading yt-dlp ${RELEASE} (first use)…`);
303
+ return downloadBackend({ asset, release: RELEASE, expected, directory, signal, status });
304
+ }
305
+
306
+ async function stage(source, destination, signal) {
307
+ const expected = await sha256(source, signal);
308
+ if (await matches(destination, expected, signal)) {
309
+ if (process.platform !== 'win32') await chmod(destination, 0o755);
310
+ return;
311
+ }
312
+ const temp = `${destination}.${randomUUID()}.tmp`;
313
+ try {
314
+ signal?.throwIfAborted();
315
+ await copyFile(source, temp, constants.COPYFILE_EXCL);
316
+ if (!await matches(temp, expected, signal)) throw new Error(`Backend copy verification failed: ${source}`);
317
+ await publish(temp, destination, expected, signal);
318
+ } finally {
319
+ await rm(temp, { force: true });
320
+ }
321
+ }
322
+
323
+ async function managedMediaTools({ signal, status, directory }) {
324
+ if (!staticToolsSupported()) return 'the bundled static media tools do not support this platform';
325
+ let ffmpeg;
326
+ let ffprobe;
327
+ try {
328
+ ffmpeg = await executable(require('ffmpeg-static'), 'ffmpeg-static');
329
+ ffprobe = await executable(require('ffprobe-static').path, 'ffprobe-static');
330
+ } catch (cause) {
331
+ return `the bundled static media tools are unusable (${cause.message})`;
332
+ }
333
+ status('Preparing ffmpeg and ffprobe…');
334
+ const suffix = exeSuffix();
335
+ await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
336
+ await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
337
+ return null;
338
+ }
339
+
340
+ /**
341
+ * Resolve a location that contains both ffmpeg and ffprobe for
342
+ * --ffmpeg-location. Precedence: explicit override, bundled static binaries,
343
+ * then a system installation (which is what Windows on ARM needs, because
344
+ * ffmpeg-static and ffprobe-static ship no arm64 Windows binaries).
345
+ */
346
+ export async function resolveMediaTools({ signal, status = () => {}, directory, offline = false,
347
+ find = findOnPath, managed = managedMediaTools, install = installMediaTools } = {}) {
348
+ const suffix = exeSuffix();
349
+ const override = envPath('VEO_FFMPEG_PATH');
350
+ if (override) {
351
+ await executable(path.join(override, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
352
+ await executable(path.join(override, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
353
+ status('Using VEO_FFMPEG_PATH override.');
354
+ return override;
355
+ }
356
+ const problem = await managed({ signal, status, directory });
357
+ if (!problem) return directory;
358
+ // A previous run may already have staged a working pair into the cache.
359
+ if (await isExecutable(path.join(directory, `ffmpeg${suffix}`))
360
+ && await isExecutable(path.join(directory, `ffprobe${suffix}`))) return directory;
361
+ const ffmpeg = await find(['ffmpeg']);
362
+ const ffprobe = await find(['ffprobe']);
363
+ if (!ffmpeg || !ffprobe) {
364
+ if (!offline) return install({ signal, status, directory, find, stage });
365
+ const missing = [!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ');
366
+ throw new Error(`Cannot load media tools: ${problem}, and no ${missing} was found on PATH. `
367
+ + 'Run veo doctor fix without --offline to install them automatically, or set VEO_FFMPEG_PATH to a directory containing both binaries.');
368
+ }
369
+ // Same directory: yt-dlp can use it in place instead of duplicating ~150 MB.
370
+ if (path.dirname(ffmpeg) === path.dirname(ffprobe)) {
371
+ status(`Using the system ffmpeg in ${path.dirname(ffmpeg)}.`);
372
+ return path.dirname(ffmpeg);
373
+ }
374
+ status('Copying system ffmpeg and ffprobe into the backend cache…');
375
+ await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
376
+ await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
377
+ return directory;
378
+ }
379
+
380
+ /**
381
+ * Report the state of every backend component without downloading, executing,
382
+ * or changing anything. Used by `veo doctor`.
383
+ */
384
+ export async function inspectBackend({ signal, platform = process.platform, arch = process.arch, find = findOnPath } = {}) {
385
+ const suffix = exeSuffix();
386
+ const directory = cacheDirectory();
387
+ const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
388
+ const report = {
389
+ release: RELEASE,
390
+ directory,
391
+ platform: `${platform}/${arch}`,
392
+ asset: undefined,
393
+ override: null,
394
+ ytDlp: { source: 'none', present: false, verified: false },
395
+ ffmpeg: { source: 'missing', present: false },
396
+ ffprobe: { source: 'missing', present: false },
397
+ errors: [],
398
+ };
399
+ try {
400
+ const override = envPath('VEO_YT_DLP_PATH');
401
+ if (override) {
402
+ report.ytDlp = { source: 'override', path: override, present: await isExecutable(override), verified: false };
403
+ } else if (platform === 'android') {
404
+ const file = await find(['yt-dlp']);
405
+ report.ytDlp = { source: 'system', path: file, present: Boolean(file), verified: false };
406
+ } else {
407
+ const asset = selectAsset(platform, arch, musl);
408
+ report.asset = asset;
409
+ if (!asset) {
410
+ report.errors.push(`No standalone yt-dlp is published for ${report.platform}.`);
411
+ } else {
412
+ report.override = await activeOverride(asset, { signal });
413
+ const file = report.override?.path || path.join(directory, `yt-dlp${suffix}`);
414
+ const present = await isExecutable(file);
415
+ report.ytDlp = {
416
+ source: report.override ? 'installed' : 'managed',
417
+ path: file,
418
+ present,
419
+ verified: present && await matches(file, report.override?.sha256 || HASHES[asset], signal),
420
+ };
421
+ }
422
+ }
423
+ } catch (error) {
424
+ report.errors.push(error.message);
425
+ report.ytDlp = { source: 'invalid', present: false, verified: false };
426
+ }
427
+ let override;
428
+ try {
429
+ override = envPath('VEO_FFMPEG_PATH');
430
+ } catch (error) {
431
+ report.errors.push(error.message);
432
+ }
433
+ for (const name of ['ffmpeg', 'ffprobe']) {
434
+ const cached = path.join(directory, `${name}${suffix}`);
435
+ let entry;
436
+ if (override) {
437
+ const file = path.join(override, `${name}${suffix}`);
438
+ entry = { source: 'override', path: file, present: await isExecutable(file) };
439
+ } else if (platform !== 'android' && await isExecutable(cached)) {
440
+ entry = { source: 'cache', path: cached, present: true };
441
+ } else {
442
+ const found = await find([name]);
443
+ entry = { source: found ? 'path' : 'missing', path: found, present: Boolean(found) };
444
+ }
445
+ report[name] = entry;
446
+ }
447
+ return report;
448
+ }
449
+
450
+ /**
451
+ * Resolve native tools, installing missing tools on first use (unless offline).
452
+ * VEO_YT_DLP_PATH: trusted executable file; bypasses acquisition/pinned hash checks.
453
+ * VEO_FFMPEG_PATH: directory containing both ffmpeg[.exe] and ffprobe[.exe].
454
+ * Relative overrides resolve against cwd. ffmpeg-static's own FFMPEG_BIN
455
+ * override is honored through its normal module API.
456
+ * Media tools fall back to a system installation when the static packages
457
+ * cannot serve the current platform (notably Windows on ARM).
458
+ * onStatus receives plain strings. Throws on cancellation or acquisition failure.
459
+ */
460
+ export async function resolveBackend({ signal, onStatus, offline = false, platform = process.platform, find = findOnPath,
461
+ setupTermux = installTermuxTools, hasEjs = hasTermuxEjs, run = runSetup } = {}) {
462
+ if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal.');
463
+ if (onStatus !== undefined && typeof onStatus !== 'function') throw new TypeError('onStatus must be a function.');
464
+ signal?.throwIfAborted();
465
+ const status = onStatus || (() => {});
466
+ const ytOverride = envPath('VEO_YT_DLP_PATH');
467
+ if (platform === 'android') {
468
+ const mediaOverride = envPath('VEO_FFMPEG_PATH');
469
+ // Invalid explicit paths must fail before a package-manager mutation.
470
+ if (ytOverride) await executable(ytOverride, 'VEO_YT_DLP_PATH');
471
+ if (mediaOverride) {
472
+ await executable(path.join(mediaOverride, 'ffmpeg'), 'VEO_FFMPEG_PATH ffmpeg');
473
+ await executable(path.join(mediaOverride, 'ffprobe'), 'VEO_FFMPEG_PATH ffprobe');
474
+ }
475
+ const discover = async () => ({
476
+ ytDlp: ytOverride || await find(['yt-dlp']),
477
+ ffmpeg: mediaOverride ? path.join(mediaOverride, 'ffmpeg') : await find(['ffmpeg']),
478
+ ffprobe: mediaOverride ? path.join(mediaOverride, 'ffprobe') : await find(['ffprobe']),
479
+ ejs: Boolean(ytOverride) || await hasEjs({ find, signal, run }),
480
+ });
481
+ let tools = await discover();
482
+ const ready = value => value.ytDlp && value.ffmpeg && value.ffprobe && value.ejs;
483
+ if (!ready(tools)) {
484
+ if (offline) throw new Error(`Termux tools are missing (yt-dlp, FFmpeg/FFprobe or JavaScript support). Run veo doctor fix without --offline, or: ${TERMUX_SETUP}`);
485
+ await setupTermux({ find, signal, status, run });
486
+ tools = await discover();
487
+ if (!ready(tools)) throw new Error(`Termux setup finished but tools are still missing. Check PATH and run: ${TERMUX_SETUP}`);
488
+ for (const [file, args] of [[tools.ytDlp, ['--version']], [tools.ffmpeg, ['-version']], [tools.ffprobe, ['-version']]]) {
489
+ await run(file, args, { signal, timeoutMs: 15_000 });
490
+ }
491
+ }
492
+ const { ytDlp, ffmpeg, ffprobe } = tools;
493
+ await executable(ffmpeg, 'ffmpeg');
494
+ await executable(ffprobe, 'ffprobe');
495
+ if (path.dirname(ffmpeg) !== path.dirname(ffprobe)) throw new Error('Set VEO_FFMPEG_PATH to a directory containing both ffmpeg and ffprobe.');
496
+ status(ytOverride ? 'Using VEO_YT_DLP_PATH override.' : 'Using system yt-dlp (Termux package; not pinned by veo).');
497
+ signal?.throwIfAborted();
498
+ return { ytDlp, ffmpegLocation: path.dirname(ffmpeg) };
499
+ }
500
+ // Diagnostic reports expose glibc when linked against it; no shell probe needed.
501
+ const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
502
+ const asset = ytOverride ? undefined : selectAsset(process.platform, process.arch, musl);
503
+ if (!ytOverride && !asset) throw new Error(`No standalone yt-dlp is available for ${process.platform}/${process.arch}. Set VEO_YT_DLP_PATH to a trusted executable.`);
504
+ // Validate overrides and dependencies before doing any network work.
505
+ const ytDlp = ytOverride ? await executable(ytOverride, 'VEO_YT_DLP_PATH') : undefined;
506
+ // An explicitly installed newer backend takes precedence over the pinned one.
507
+ // Media tools stay in the pinned release directory so they are never duplicated.
508
+ const installed = ytDlp ? null : await activeOverride(asset, { signal });
509
+ const directory = cacheDirectory();
510
+ await mkdir(directory, { recursive: true, mode: 0o700 });
511
+ const ffmpegLocation = await resolveMediaTools({ signal, status, directory, offline });
512
+ if (ytDlp) status('Using VEO_YT_DLP_PATH override.');
513
+ else if (installed) status(`Using the installed yt-dlp ${installed.release}.`);
514
+ if (offline && !ytDlp && !installed && !await matches(path.join(directory, `yt-dlp${exeSuffix()}`), HASHES[asset], signal)) {
515
+ throw new Error('yt-dlp is missing or damaged. Run veo doctor fix without --offline to download it.');
516
+ }
517
+ const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status);
518
+ signal?.throwIfAborted();
519
+ return { ytDlp: backend, ffmpegLocation };
520
+ }