veodl 1.9.1 → 1.10.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 CHANGED
@@ -291,11 +291,19 @@ async function downloadBackend({ asset, release, expected, directory, signal, st
291
291
  }
292
292
  }
293
293
 
294
- async function acquire(asset, directory, signal, status) {
294
+ async function acquire(asset, directory, signal, status, statusDone = () => {}) {
295
295
  const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
296
296
  const expected = HASHES[asset];
297
297
  status('Checking yt-dlp…');
298
- if (await matches(destination, expected, signal)) {
298
+ let cached;
299
+ try {
300
+ cached = await matches(destination, expected, signal);
301
+ statusDone('Checking yt-dlp…', 'done');
302
+ } catch (error) {
303
+ if (!signal?.aborted && error?.name !== 'AbortError') statusDone('Checking yt-dlp…', 'failed');
304
+ throw error;
305
+ }
306
+ if (cached) {
299
307
  if (process.platform !== 'win32') await chmod(destination, 0o755);
300
308
  return destination;
301
309
  }
@@ -330,7 +338,6 @@ async function managedMediaTools({ signal, status, directory }) {
330
338
  } catch (cause) {
331
339
  return `the bundled static media tools are unusable (${cause.message})`;
332
340
  }
333
- status('Preparing ffmpeg and ffprobe…');
334
341
  const suffix = exeSuffix();
335
342
  await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
336
343
  await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
@@ -343,38 +350,58 @@ async function managedMediaTools({ signal, status, directory }) {
343
350
  * then a system installation (which is what Windows on ARM needs, because
344
351
  * ffmpeg-static and ffprobe-static ship no arm64 Windows binaries).
345
352
  */
346
- export async function resolveMediaTools({ signal, status = () => {}, directory, offline = false,
353
+ export async function resolveMediaTools({ signal, status = () => {}, statusDone = () => {}, directory, offline = false,
347
354
  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);
355
+ const preparing = 'Preparing ffmpeg and ffprobe…';
356
+ let currentStatus;
357
+ const report = message => { currentStatus = message; status(message); };
358
+ const finish = outcome => {
359
+ if (currentStatus !== preparing) report(preparing);
360
+ statusDone(preparing, outcome);
361
+ };
362
+ report(preparing);
363
+ try {
364
+ const suffix = exeSuffix();
365
+ const override = envPath('VEO_FFMPEG_PATH');
366
+ if (override) {
367
+ await executable(path.join(override, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
368
+ await executable(path.join(override, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
369
+ finish('done');
370
+ report('Using VEO_FFMPEG_PATH override.');
371
+ return override;
372
+ }
373
+ const problem = await managed({ signal, status: report, directory });
374
+ if (!problem) { finish('done'); return directory; }
375
+ // A previous run may already have staged a working pair into the cache.
376
+ if (await isExecutable(path.join(directory, `ffmpeg${suffix}`))
377
+ && await isExecutable(path.join(directory, `ffprobe${suffix}`))) { finish('done'); return directory; }
378
+ const ffmpeg = await find(['ffmpeg']);
379
+ const ffprobe = await find(['ffprobe']);
380
+ if (!ffmpeg || !ffprobe) {
381
+ if (!offline) {
382
+ const location = await install({ signal, status: report, directory, find, stage });
383
+ finish('done');
384
+ return location;
385
+ }
386
+ const missing = [!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ');
387
+ throw new Error(`Cannot load media tools: ${problem}, and no ${missing} was found on PATH. `
388
+ + 'Run veo doctor fix without --offline to install them automatically, or set VEO_FFMPEG_PATH to a directory containing both binaries.');
389
+ }
390
+ // Same directory: yt-dlp can use it in place instead of duplicating ~150 MB.
391
+ if (path.dirname(ffmpeg) === path.dirname(ffprobe)) {
392
+ finish('done');
393
+ report(`Using the system ffmpeg in ${path.dirname(ffmpeg)}.`);
394
+ return path.dirname(ffmpeg);
395
+ }
396
+ report('Copying system ffmpeg and ffprobe into the backend cache…');
397
+ await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
398
+ await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
399
+ finish('done');
400
+ return directory;
401
+ } catch (error) {
402
+ if (!signal?.aborted && error?.name !== 'AbortError') finish('failed');
403
+ throw error;
373
404
  }
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
405
  }
379
406
 
380
407
  /**
@@ -457,12 +484,13 @@ export async function inspectBackend({ signal, platform = process.platform, arch
457
484
  * cannot serve the current platform (notably Windows on ARM).
458
485
  * onStatus receives plain strings. Throws on cancellation or acquisition failure.
459
486
  */
460
- export async function resolveBackend({ signal, onStatus, offline = false, platform = process.platform, find = findOnPath,
487
+ export async function resolveBackend({ signal, onStatus, onStatusDone, offline = false, platform = process.platform, find = findOnPath,
461
488
  setupTermux = installTermuxTools, hasEjs = hasTermuxEjs, run = runSetup } = {}) {
462
489
  if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal.');
463
490
  if (onStatus !== undefined && typeof onStatus !== 'function') throw new TypeError('onStatus must be a function.');
464
491
  signal?.throwIfAborted();
465
492
  const status = onStatus || (() => {});
493
+ const statusDone = onStatusDone || (() => {});
466
494
  const ytOverride = envPath('VEO_YT_DLP_PATH');
467
495
  if (platform === 'android') {
468
496
  const mediaOverride = envPath('VEO_FFMPEG_PATH');
@@ -508,13 +536,13 @@ export async function resolveBackend({ signal, onStatus, offline = false, platfo
508
536
  const installed = ytDlp ? null : await activeOverride(asset, { signal });
509
537
  const directory = cacheDirectory();
510
538
  await mkdir(directory, { recursive: true, mode: 0o700 });
511
- const ffmpegLocation = await resolveMediaTools({ signal, status, directory, offline });
539
+ const ffmpegLocation = await resolveMediaTools({ signal, status, statusDone, directory, offline });
512
540
  if (ytDlp) status('Using VEO_YT_DLP_PATH override.');
513
541
  else if (installed) status(`Using the installed yt-dlp ${installed.release}.`);
514
542
  if (offline && !ytDlp && !installed && !await matches(path.join(directory, `yt-dlp${exeSuffix()}`), HASHES[asset], signal)) {
515
543
  throw new Error('yt-dlp is missing or damaged. Run veo doctor fix without --offline to download it.');
516
544
  }
517
- const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status);
545
+ const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status, statusDone);
518
546
  signal?.throwIfAborted();
519
547
  return { ytDlp: backend, ffmpegLocation };
520
548
  }
package/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { outputOptions, outputStream, withOutputSettings } from './output.js';
2
+ import { validateFragments, confirmExperimentalFragments } from './experimental-fragments.js';
2
3
  import { validateTemplate } from './naming.js';
3
4
  import { parseArgs } from 'node:util';
4
5
  import path from 'node:path';
@@ -7,10 +8,10 @@ import { createInterface } from 'node:readline/promises';
7
8
  import { createReporter } from './progress.js';
8
9
  import { openFile } from './open-file.js';
9
10
  import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
10
- import { applyProfile, configMain, loadConfig } from './config.js';
11
+ import { applyProfile, configMain, loadConfig, profileMain, selectedProfileName } from './config.js';
11
12
  import { validateItems, describeEstimate } from './playlist.js';
12
- import { parseSourceTimeout } from './source-discovery.js';
13
- import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
13
+ import { parseSourceTimeout } from './source-discovery.js';
14
+ import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
14
15
  import { retryOptions, runJob, jobFilePath } from './jobs.js';
15
16
  import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText, validateCookieFile, validateBrowserSpec, cookieFileWarning } from './utils.js';
16
17
 
@@ -43,7 +44,9 @@ Options:
43
44
  --playlist-concurrency <n> Simultaneous playlist downloads (1-4; default: 2)
44
45
  --playlist Download every entry of a playlist or channel URL
45
46
  -N, --concurrent-fragments <n>
46
- Download this many fragments in parallel (1-16; default: 8)
47
+ Parallel fragments (1-64; default: 8); 17-64 are experimental
48
+ --experimental-fragments Explicitly accept experimental values above 16
49
+ Otherwise requires terminal confirmation before download
47
50
  --subs Download subtitles (default languages: en)
48
51
  --sub-langs <langs> Subtitle languages, e.g. "de,en" (implies --subs)
49
52
  --embed-subs Embed subtitles into the video file
@@ -56,9 +59,11 @@ Options:
56
59
  --cookies-from-browser <browser[:profile]>
57
60
  Read cookies from an installed browser
58
61
  --resume Keep partial data and continue an interrupted download
62
+ --list-qualities Show available video resolutions and exit
63
+ --list-qualitys Alias for --list-qualities
59
64
  --list-formats Show the available formats and exit
60
65
  --list-sources Find video sources loaded by a web page and exit
61
- --auto-list-sources Search automatically after no downloadable video is found
66
+ --auto-list-sources Search after no video is found (default: on)
62
67
  --source <n> Select source number from that page (also for scripts)
63
68
  --deep-scan Check every media candidate found during source search
64
69
  --timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
@@ -66,7 +71,7 @@ Options:
66
71
  --json Print one JSON object per URL instead of prose
67
72
  --profile <name> Apply a named config profile
68
73
  --batch-file <file> Read URLs from a file (one per line; # comments)
69
- --retry-failed <file> Retry failed/unfinished items from a saved job
74
+ --retry-failed <id|file> Retry failed/unfinished items by run ID or job file
70
75
  --playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
71
76
  --skip-existing Skip matching downloads still present on disk
72
77
  --no-<boolean-option> Disable a stored boolean default, e.g. --no-open
@@ -89,7 +94,8 @@ Commands:
89
94
  veo alias list|add|remove Manage extra command names (wrappers calling veo)
90
95
  veo uninstall [-p <name>] Remove one alias, or everything with --yes
91
96
  veo version Show the installed version
92
- veo config edit|path|profiles|check|show|reset Manage defaults and named profiles
97
+ veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
98
+ veo profile [list|reset|name] Show, list or change the default profile
93
99
  veo config edit [--external|--terminal] Choose the configuration editor
94
100
 
95
101
  Run veo without arguments in a terminal for interactive setup.
@@ -133,6 +139,7 @@ const STRING_OPTIONS = {
133
139
  };
134
140
 
135
141
  const BOOLEAN_OPTIONS = {
142
+ 'experimental-fragments': {},
136
143
  compatible: {},
137
144
  recode: {},
138
145
  'adaptive-concurrency': {},
@@ -148,6 +155,8 @@ const BOOLEAN_OPTIONS = {
148
155
  'embed-metadata': {},
149
156
  'embed-thumbnail': {},
150
157
  'closest-quality': {},
158
+ 'list-qualities': {},
159
+ 'list-qualitys': {},
151
160
  'list-formats': {},
152
161
  'list-sources': {},
153
162
  'auto-list-sources': {},
@@ -179,6 +188,7 @@ export function optionDefaults(config = {}) {
179
188
  cookies: config.cookies,
180
189
  'cookies-from-browser': config.cookiesFromBrowser,
181
190
  'concurrent-fragments': String(config.concurrentFragments ?? 8),
191
+ 'experimental-fragments': config.experimentalFragments ?? false,
182
192
  'sub-langs': config.subLangs,
183
193
  'sponsorblock-remove': config.sponsorblockRemove,
184
194
  section: config.section,
@@ -198,11 +208,11 @@ export function optionDefaults(config = {}) {
198
208
  timeout: config.timeout,
199
209
  'deep-scan': config.deepScan ?? false,
200
210
  'list-sources': config.listSources ?? false,
201
- 'auto-list-sources': config.autoListSources ?? false,
211
+ 'auto-list-sources': config.autoListSources ?? true,
202
212
  };
203
213
  }
204
214
 
205
- export function cliOptions(config = {}) {
215
+ export function cliOptions(config = {}) {
206
216
  const defaults = optionDefaults(config);
207
217
  const options = {};
208
218
  for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
@@ -214,41 +224,42 @@ export function cliOptions(config = {}) {
214
224
  if (defaults[name] !== undefined) options[name].default = defaults[name];
215
225
  }
216
226
  return options;
217
- }
218
-
219
- const COMMAND_FLAGS = {
220
- stats: ['--json'], history: ['--json', '--limit', '--failed'],
221
- runs: ['--json'], stop: [], flush: ['--stats'],
222
- inspect: ['--json', '--check-audio', '--search'],
223
- doctor: ['--offline', '--output', '-o'],
224
- update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
225
- backend: ['--check', '--keep-files'],
226
- alias: ['--json', '--force', '--bin-dir'],
227
- uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
228
- config: ['--profile', '--external', '--terminal'],
229
- };
230
- const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
231
-
232
- function mistypedCommandOption(args) {
233
- const command = args[0];
234
- if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
235
- const suggestion = suggestOption(command, COMMAND_NAMES);
236
- if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
237
- }
238
- const options = Object.hasOwn(COMMAND_FLAGS, command)
239
- ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
240
- : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
241
- if (!options) return null; // Download flags are checked by parseCli itself.
242
- const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
243
- for (const arg of args.slice(start)) {
244
- if (arg === '--') break;
245
- const name = arg.split('=')[0];
246
- if (!name.startsWith('-') || options.includes(name)) continue;
247
- const suggestion = suggestOption(name, options);
248
- if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
249
- }
250
- return null;
251
- }
227
+ }
228
+
229
+ const COMMAND_FLAGS = {
230
+ stats: ['--json'], history: ['--json', '--limit', '--failed'],
231
+ runs: ['--json'], stop: [], flush: ['--stats'],
232
+ inspect: ['--json', '--check-audio', '--search'],
233
+ doctor: ['--offline', '--output', '-o'],
234
+ update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
235
+ backend: ['--check', '--keep-files'],
236
+ alias: ['--json', '--force', '--bin-dir'],
237
+ uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
238
+ config: ['--profile', '--external', '--terminal'],
239
+ profile: [],
240
+ };
241
+ const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
242
+
243
+ function mistypedCommandOption(args) {
244
+ const command = args[0];
245
+ if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
246
+ const suggestion = suggestOption(command, COMMAND_NAMES);
247
+ if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
248
+ }
249
+ const options = Object.hasOwn(COMMAND_FLAGS, command)
250
+ ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
251
+ : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
252
+ if (!options) return null; // Download flags are checked by parseCli itself.
253
+ const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
254
+ for (const arg of args.slice(start)) {
255
+ if (arg === '--') break;
256
+ const name = arg.split('=')[0];
257
+ if (!name.startsWith('-') || options.includes(name)) continue;
258
+ const suggestion = suggestOption(name, options);
259
+ if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
260
+ }
261
+ return null;
262
+ }
252
263
 
253
264
  export function parseCli(args, { config = {} } = {}) {
254
265
  if (args[0] === 'help') args = ['--help', ...args.slice(1)];
@@ -256,14 +267,15 @@ export function parseCli(args, { config = {} } = {}) {
256
267
  if (args.length !== 1) throw new Error('Usage: veo version');
257
268
  return { version: true };
258
269
  }
259
- let preliminary;
260
- try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
261
- catch (error) { throw explainUnknownOption(error, cliOptions()); }
262
- config = applyProfile(config, preliminary.values.profile);
263
- let parsed;
264
- try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
265
- catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
266
- const { values, positionals, tokens } = parsed;
270
+ let preliminary;
271
+ try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
272
+ catch (error) { throw explainUnknownOption(error, cliOptions()); }
273
+ const profileName = selectedProfileName(config, preliminary.values.profile);
274
+ config = applyProfile(config, preliminary.values.profile);
275
+ let parsed;
276
+ try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
277
+ catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
278
+ const { values, positionals, tokens } = parsed;
267
279
  if (values.help || values.version) return values;
268
280
  // A stored default conflicting with a flag typed right now is a user error;
269
281
  // a stored default merely ignored by another flag is not.
@@ -290,6 +302,13 @@ export function parseCli(args, { config = {} } = {}) {
290
302
  if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
291
303
  if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
292
304
  if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
305
+ values['list-qualities'] = Boolean(values['list-qualities'] || values['list-qualitys']);
306
+ delete values['list-qualitys'];
307
+ if (values['list-qualities']) {
308
+ if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-qualities accepts exactly one URL.');
309
+ if (values['list-formats'] || values['list-sources']) throw new Error('--list-qualities cannot be combined with other listing actions.');
310
+ if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
311
+ }
293
312
  if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
294
313
  if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
295
314
  if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
@@ -307,7 +326,7 @@ export function parseCli(args, { config = {} } = {}) {
307
326
  if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
308
327
  if (values['concurrent-fragments'] !== undefined) {
309
328
  const fragments = Number(values['concurrent-fragments']);
310
- if (!Number.isInteger(fragments) || fragments < 1 || fragments > 16) throw new Error('--concurrent-fragments must be a whole number between 1 and 16.');
329
+ validateFragments(fragments);
311
330
  }
312
331
  const concurrency = Number(values['playlist-concurrency']);
313
332
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
@@ -344,13 +363,14 @@ export function parseCli(args, { config = {} } = {}) {
344
363
  options.subLangs = undefined;
345
364
  options.embedSubs = false;
346
365
  }
366
+ options.profile = profileName;
347
367
  return options;
348
368
  }
349
369
 
350
- export async function main(args = process.argv.slice(2), { config } = {}) {
351
- if (args[0] === 'help') args = ['--help', ...args.slice(1)];
352
- const mistyped = mistypedCommandOption(args);
353
- if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
370
+ export async function main(args = process.argv.slice(2), { config } = {}) {
371
+ if (args[0] === 'help') args = ['--help', ...args.slice(1)];
372
+ const mistyped = mistypedCommandOption(args);
373
+ if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
354
374
  let color = !args.includes('--no-color') && !args.includes('--json');
355
375
  let display;
356
376
  try {
@@ -427,6 +447,10 @@ async function runMain(args, { config }) {
427
447
  args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
428
448
  } catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
429
449
  }
450
+ if (args[0] === 'profile') {
451
+ try { return await profileMain(args.slice(1)); }
452
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
453
+ }
430
454
  const { cleanupDownloadCache } = await import('./download-cache.js');
431
455
  await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
432
456
  // update/upgrade/up/check subcommands are handled before URL validation.
@@ -507,7 +531,14 @@ async function runMain(args, { config }) {
507
531
  retryItems = await retryOptions(options.retryFailed);
508
532
  // Revalidate stored options and apply only flags explicitly supplied now.
509
533
  const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
510
- retryItems = retryItems.map(item => parseCli([item.url, ...retryArgs], { config: { ...item, profiles: loaded.config?.profiles } }));
534
+ const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
535
+ retryItems = retryItems.map(item => {
536
+ const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
537
+ ? { ...item, profiles: loaded.config?.profiles }
538
+ : item });
539
+ if (!overrideProfile) resumed.profile = item.profile;
540
+ return resumed;
541
+ });
511
542
  options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
512
543
  }
513
544
  if (options.batchFile) {
@@ -518,20 +549,26 @@ async function runMain(args, { config }) {
518
549
  if (options.rename && options.urls.length > 1 && !options.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
519
550
  if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
520
551
  }
552
+ await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
521
553
  // The job file is created before the first download, so another terminal can
522
554
  // follow this run's per-item progress with `veo runs <id>`.
523
- const jobFile = options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
555
+ const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
524
556
  await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
525
- audio: options.audio, quality: options.quality, format: options.format, playlist: options.playlist, job: jobFile });
557
+ audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
558
+ playlist: options.playlist, job: jobFile });
526
559
  reporter.configure?.({ color: options.color && !options.json });
527
560
  reporter.start(options.rename);
561
+ if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources) reporter.profile(options.profile);
562
+ const preparedBackends = new Map();
563
+ const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
528
564
  const cookieWarning = cookieFileWarning(options.cookies);
529
565
  if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
530
566
 
531
567
  // For a single page, fall back to media requests observed while its player loads.
532
568
  // Explicit --source always rediscovers: signed media URLs can expire between runs.
533
569
  if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
534
- const discovery = await resolvePageSource(options, { signal: controller.signal, stderr });
570
+ const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
571
+ onBackend: backend => preparedBackends.set(options.url, backend) });
535
572
  if (options.listSources) {
536
573
  if (options.json) stdout.write(`${JSON.stringify({ url: options.url, status: 'sources', timedOut: Boolean(discovery.sources.timedOut), sources: discovery.sources.map(({ url, pageUrl, ...safe }) => safe) })}\n`);
537
574
  else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
@@ -554,20 +591,31 @@ async function runMain(args, { config }) {
554
591
  }
555
592
  if (retryItems) for (const item of retryItems) {
556
593
  if (!item.source) continue;
557
- const discovery = await resolvePageSource(item, { signal: controller.signal, stderr });
594
+ const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
595
+ onBackend: backend => preparedBackends.set(item.url, backend) });
558
596
  item.mediaUrl = discovery.mediaUrl;
559
597
  }
560
598
 
599
+ if (options.listQualities) {
600
+ const { listQualities } = await import('./downloader.js');
601
+ const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
602
+ if (options.json) stdout.write(JSON.stringify(result) + '\n');
603
+ else {
604
+ stdout.write('Available qualities: ' + (result.qualities.join(', ') || 'resolution unknown') + '\n');
605
+ if (result.qualities.length) stdout.write('Choose with --quality ' + result.qualities[0] + '\n');
606
+ }
607
+ return 0;
608
+ }
561
609
  if (options.listFormats) {
562
610
  const { listFormats } = await import('./downloader.js');
563
- stdout.write(await listFormats(options, { signal: controller.signal, reporter }));
611
+ stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
564
612
  return 0;
565
613
  }
566
614
  if (options.dryRun) {
567
615
  const { planDownload } = await import('./downloader.js');
568
616
  for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
569
617
  const { url } = request;
570
- const plan = await planDownload(request, { signal: controller.signal, reporter });
618
+ const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
571
619
  if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
572
620
  else {
573
621
  stdout.write(`URL: ${url}\n`);
@@ -581,13 +629,17 @@ async function runMain(args, { config }) {
581
629
  const { download } = await import('./downloader.js');
582
630
  const { createStatsRecorder } = await import('./stats.js');
583
631
  const { createHistoryRecorder } = await import('./history.js');
584
- const result = await runJob(options, { download, reporter, signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id, recordStats: createStatsRecorder(), recordHistory: createHistoryRecorder() });
632
+ if (!options.json && process.stdout.isTTY) reporter.enableInline();
633
+ const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
634
+ reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id, recordStats: createStatsRecorder(), recordHistory: createHistoryRecorder() });
585
635
  return result;
586
636
  } catch (error) {
587
637
  reporter.fail(controller.signal.aborted);
638
+ reporter.dispose();
588
639
  stderr.write(`veo: ${readableError(error)}\n`);
589
640
  return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
590
641
  } finally {
642
+ reporter.dispose();
591
643
  await run?.unregister();
592
644
  process.removeListener('SIGINT', cancel);
593
645
  process.removeListener('SIGTERM', cancel);
@@ -600,11 +652,12 @@ async function askSourceQuestion(prompt, signal) {
600
652
  finally { rl.close(); }
601
653
  }
602
654
 
603
- export async function resolvePageSource(options, { signal, stderr, inspect, discover, ask = askSourceQuestion,
655
+ export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
604
656
  backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
605
657
  const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
606
658
  const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
607
- const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal });
659
+ const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
660
+ if (backend) onBackend?.(backend);
608
661
  const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
609
662
  let originalError;
610
663
  if (!options.source && !options.listSources) {
@@ -617,11 +670,8 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
617
670
  }
618
671
  catch (error) { originalError = error; }
619
672
  if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
620
- if (!interactive && !options.autoListSources) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
621
- if (interactive && !options.autoListSources) {
622
- const answer = (await ask('No downloadable video found. Search this page for other sources? (y/N): ', signal)).trim().toLowerCase();
623
- if (!['y', 'yes'].includes(answer)) throw originalError;
624
- }
673
+ if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
674
+ stderr.write('veo: No direct video found; searching the page for a playable source…\n');
625
675
  }
626
676
  let sources;
627
677
  try {
@@ -642,9 +692,11 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
642
692
  }
643
693
  let index = Number(options.source);
644
694
  if (!index) {
645
- if (!interactive && options.autoListSources) return { sources, formatSource, needsSelection: true };
695
+ if (sources.length === 1) index = 1;
696
+ else if (!interactive) return { sources, formatSource, needsSelection: true };
697
+ }
698
+ if (!index) {
646
699
  for (const source of sources) stderr.write(`${formatSource(source)}\n`);
647
- if (!interactive) throw new Error('Select a source with --source <number>.');
648
700
  const answer = await ask('Source number (Enter to cancel): ', signal);
649
701
  if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
650
702
  index = Number(answer.trim());
@@ -1,4 +1,4 @@
1
- import { CONFIG_KEYS, parseConfigText, effectiveConfig, applyProfile } from './config.js';
1
+ import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, parseConfigText, effectiveConfig, applyProfile } from './config.js';
2
2
  import { stripConfigComments } from './config-template.js';
3
3
  import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
4
4
 
@@ -64,7 +64,7 @@ function valueChoices(key, config = {}) {
64
64
  if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
65
65
  if (key === 'timeout') return ['30s', '45s', '1m', '2m', '5m'];
66
66
  if (['concurrentDownloads', 'playlistConcurrency'].includes(key)) return [1, 2, 3, 4];
67
- if (key === 'concurrentFragments') return Array.from({ length: 16 }, (_, i) => i + 1);
67
+ if (key === 'concurrentFragments') return Array.from({ length: 64 }, (_, i) => i + 1);
68
68
  return [];
69
69
  }
70
70
 
@@ -75,7 +75,7 @@ export async function semanticIssue(text) {
75
75
  if (!object(data)) return located(text, node([]), 'The config must contain a JSON object.');
76
76
  const check = (settings, path) => {
77
77
  for (const [key, value] of Object.entries(settings)) {
78
- if (!path.length && ['$schema', 'profiles'].includes(key)) continue;
78
+ if (!path.length && ['$schema', 'profiles', 'activeProfile'].includes(key)) continue;
79
79
  const target = node([...path, key]);
80
80
  if (!Object.hasOwn(CONFIG_KEYS, key)) {
81
81
  const proposed = suggestion(key, path.length ? keys : [...keys, 'profiles', '$schema']);
@@ -93,10 +93,19 @@ export async function semanticIssue(text) {
93
93
  if (Object.hasOwn(data, 'profiles')) {
94
94
  if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
95
95
  for (const [name, profile] of Object.entries(data.profiles)) {
96
+ if (RESERVED_PROFILE_NAMES.includes(name)) {
97
+ return located(text, node(['profiles', name]), `Profile name "${name}" is reserved for veo profile ${name}. Rename this profile.`, true);
98
+ }
96
99
  if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
97
100
  issue = check(profile, ['profiles', name]); if (issue) return issue;
98
101
  }
99
102
  }
103
+ if (Object.hasOwn(data, 'activeProfile')) {
104
+ if (typeof data.activeProfile !== 'string' || !data.activeProfile) return located(text, node(['activeProfile']), '"activeProfile" must be a non-empty profile name.');
105
+ if (!object(data.profiles) || !Object.hasOwn(data.profiles, data.activeProfile)) {
106
+ return located(text, node(['activeProfile']), `Unknown active profile "${data.activeProfile}". Choose: ${Object.keys(data.profiles || {}).join(', ') || 'none'}.`, false, Object.keys(data.profiles || {}));
107
+ }
108
+ }
100
109
  const { config } = parseConfigText(text);
101
110
  for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
102
111
  try { await effectiveConfig(config, profile); }
@@ -105,7 +114,7 @@ export async function semanticIssue(text) {
105
114
  let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
106
115
  /output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
107
116
  /--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
108
- const name = profile ?? (config.profiles?.default ? 'default' : undefined);
117
+ const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
109
118
  const selected = applyProfile(config, profile);
110
119
  // Template errors do not name the CLI option. Locate the failing template.
111
120
  if (!key) {
@@ -134,7 +143,8 @@ export function editorCompletions(text, cursor) {
134
143
  const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
135
144
  if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
136
145
  const profile = path.length === 3 ? path[1] : undefined;
137
- const choices = property ? (path.length === 1 ? [...keys, 'profiles', '$schema'] : keys) : valueChoices(key, applyProfile(data, profile));
146
+ const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
147
+ key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
138
148
  if (!choices.length) return null;
139
149
  const proposed = property ? suggestion(key, choices) : null;
140
150
  return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,