veodl 1.10.0 → 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/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, profileMain, selectedProfileName } 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 after no video is found (default: on)
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,8 +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|guide|check|show|reset Manage defaults and named profiles
93
- veo profile [list|reset|name] Show, list or change the default profile
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
94
99
  veo config edit [--external|--terminal] Choose the configuration editor
95
100
 
96
101
  Run veo without arguments in a terminal for interactive setup.
@@ -134,6 +139,7 @@ const STRING_OPTIONS = {
134
139
  };
135
140
 
136
141
  const BOOLEAN_OPTIONS = {
142
+ 'experimental-fragments': {},
137
143
  compatible: {},
138
144
  recode: {},
139
145
  'adaptive-concurrency': {},
@@ -149,6 +155,8 @@ const BOOLEAN_OPTIONS = {
149
155
  'embed-metadata': {},
150
156
  'embed-thumbnail': {},
151
157
  'closest-quality': {},
158
+ 'list-qualities': {},
159
+ 'list-qualitys': {},
152
160
  'list-formats': {},
153
161
  'list-sources': {},
154
162
  'auto-list-sources': {},
@@ -180,6 +188,7 @@ export function optionDefaults(config = {}) {
180
188
  cookies: config.cookies,
181
189
  'cookies-from-browser': config.cookiesFromBrowser,
182
190
  'concurrent-fragments': String(config.concurrentFragments ?? 8),
191
+ 'experimental-fragments': config.experimentalFragments ?? false,
183
192
  'sub-langs': config.subLangs,
184
193
  'sponsorblock-remove': config.sponsorblockRemove,
185
194
  section: config.section,
@@ -199,11 +208,11 @@ export function optionDefaults(config = {}) {
199
208
  timeout: config.timeout,
200
209
  'deep-scan': config.deepScan ?? false,
201
210
  'list-sources': config.listSources ?? false,
202
- 'auto-list-sources': config.autoListSources ?? true,
211
+ 'auto-list-sources': config.autoListSources ?? true,
203
212
  };
204
213
  }
205
214
 
206
- export function cliOptions(config = {}) {
215
+ export function cliOptions(config = {}) {
207
216
  const defaults = optionDefaults(config);
208
217
  const options = {};
209
218
  for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
@@ -215,42 +224,42 @@ export function cliOptions(config = {}) {
215
224
  if (defaults[name] !== undefined) options[name].default = defaults[name];
216
225
  }
217
226
  return options;
218
- }
219
-
220
- const COMMAND_FLAGS = {
221
- stats: ['--json'], history: ['--json', '--limit', '--failed'],
222
- runs: ['--json'], stop: [], flush: ['--stats'],
223
- inspect: ['--json', '--check-audio', '--search'],
224
- doctor: ['--offline', '--output', '-o'],
225
- update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
226
- backend: ['--check', '--keep-files'],
227
- alias: ['--json', '--force', '--bin-dir'],
228
- uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
229
- config: ['--profile', '--external', '--terminal'],
230
- profile: [],
231
- };
232
- const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
233
-
234
- function mistypedCommandOption(args) {
235
- const command = args[0];
236
- if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
237
- const suggestion = suggestOption(command, COMMAND_NAMES);
238
- if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
239
- }
240
- const options = Object.hasOwn(COMMAND_FLAGS, command)
241
- ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
242
- : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
243
- if (!options) return null; // Download flags are checked by parseCli itself.
244
- const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
245
- for (const arg of args.slice(start)) {
246
- if (arg === '--') break;
247
- const name = arg.split('=')[0];
248
- if (!name.startsWith('-') || options.includes(name)) continue;
249
- const suggestion = suggestOption(name, options);
250
- if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
251
- }
252
- return null;
253
- }
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
+ }
254
263
 
255
264
  export function parseCli(args, { config = {} } = {}) {
256
265
  if (args[0] === 'help') args = ['--help', ...args.slice(1)];
@@ -258,15 +267,15 @@ export function parseCli(args, { config = {} } = {}) {
258
267
  if (args.length !== 1) throw new Error('Usage: veo version');
259
268
  return { version: true };
260
269
  }
261
- let preliminary;
262
- try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
263
- catch (error) { throw explainUnknownOption(error, cliOptions()); }
264
- const profileName = selectedProfileName(config, preliminary.values.profile);
265
- config = applyProfile(config, preliminary.values.profile);
266
- let parsed;
267
- try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
268
- catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
269
- 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;
270
279
  if (values.help || values.version) return values;
271
280
  // A stored default conflicting with a flag typed right now is a user error;
272
281
  // a stored default merely ignored by another flag is not.
@@ -293,6 +302,13 @@ export function parseCli(args, { config = {} } = {}) {
293
302
  if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
294
303
  if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
295
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
+ }
296
312
  if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
297
313
  if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
298
314
  if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
@@ -310,7 +326,7 @@ export function parseCli(args, { config = {} } = {}) {
310
326
  if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
311
327
  if (values['concurrent-fragments'] !== undefined) {
312
328
  const fragments = Number(values['concurrent-fragments']);
313
- 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);
314
330
  }
315
331
  const concurrency = Number(values['playlist-concurrency']);
316
332
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
@@ -343,18 +359,18 @@ export function parseCli(args, { config = {} } = {}) {
343
359
  if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
344
360
  const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
345
361
  if (options.subLangs && !disableSubs) options.subs = true;
346
- if (disableSubs) {
347
- options.subLangs = undefined;
348
- options.embedSubs = false;
349
- }
350
- options.profile = profileName;
351
- return options;
352
- }
362
+ if (disableSubs) {
363
+ options.subLangs = undefined;
364
+ options.embedSubs = false;
365
+ }
366
+ options.profile = profileName;
367
+ return options;
368
+ }
353
369
 
354
- export async function main(args = process.argv.slice(2), { config } = {}) {
355
- if (args[0] === 'help') args = ['--help', ...args.slice(1)];
356
- const mistyped = mistypedCommandOption(args);
357
- 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; }
358
374
  let color = !args.includes('--no-color') && !args.includes('--json');
359
375
  let display;
360
376
  try {
@@ -421,7 +437,7 @@ async function runMain(args, { config }) {
421
437
  try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
422
438
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
423
439
  }
424
- if (args[0] === 'retry') {
440
+ if (args[0] === 'retry') {
425
441
  if (args[1] !== '--last') {
426
442
  stderr.write('veo: Usage: veo retry --last [download options]\n');
427
443
  return 1;
@@ -430,12 +446,12 @@ async function runMain(args, { config }) {
430
446
  const { latestFailedJob } = await import('./jobs.js');
431
447
  args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
432
448
  } catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
433
- }
434
- if (args[0] === 'profile') {
435
- try { return await profileMain(args.slice(1)); }
436
- catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
437
- }
438
- const { cleanupDownloadCache } = await import('./download-cache.js');
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
+ }
454
+ const { cleanupDownloadCache } = await import('./download-cache.js');
439
455
  await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
440
456
  // update/upgrade/up/check subcommands are handled before URL validation.
441
457
  if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
@@ -446,10 +462,10 @@ async function runMain(args, { config }) {
446
462
  }
447
463
  return updateMain(args, { registry: defaultRegistry() });
448
464
  }
449
- if (args[0] === 'config') {
450
- try { return await configMain(args.slice(1)); }
451
- catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
452
- }
465
+ if (args[0] === 'config') {
466
+ try { return await configMain(args.slice(1)); }
467
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
468
+ }
453
469
  if (args[0] === 'doctor') {
454
470
  try {
455
471
  const { doctorMain } = await import('./doctor.js');
@@ -515,14 +531,14 @@ async function runMain(args, { config }) {
515
531
  retryItems = await retryOptions(options.retryFailed);
516
532
  // Revalidate stored options and apply only flags explicitly supplied now.
517
533
  const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
518
- const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
519
- retryItems = retryItems.map(item => {
520
- const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
521
- ? { ...item, profiles: loaded.config?.profiles }
522
- : item });
523
- if (!overrideProfile) resumed.profile = item.profile;
524
- return resumed;
525
- });
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
+ });
526
542
  options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
527
543
  }
528
544
  if (options.batchFile) {
@@ -533,25 +549,26 @@ async function runMain(args, { config }) {
533
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.');
534
550
  if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
535
551
  }
552
+ await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
536
553
  // The job file is created before the first download, so another terminal can
537
554
  // follow this run's per-item progress with `veo runs <id>`.
538
- const jobFile = options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
539
- await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
540
- audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
541
- playlist: options.playlist, job: jobFile });
542
- reporter.configure?.({ color: options.color && !options.json });
543
- reporter.start(options.rename);
544
- if (!options.dryRun && !options.listFormats && !options.listSources) reporter.profile(options.profile);
545
- const preparedBackends = new Map();
546
- const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
547
- const cookieWarning = cookieFileWarning(options.cookies);
555
+ const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
556
+ await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
557
+ audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
558
+ playlist: options.playlist, job: jobFile });
559
+ reporter.configure?.({ color: options.color && !options.json });
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;
564
+ const cookieWarning = cookieFileWarning(options.cookies);
548
565
  if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
549
566
 
550
567
  // For a single page, fall back to media requests observed while its player loads.
551
568
  // Explicit --source always rediscovers: signed media URLs can expire between runs.
552
569
  if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
553
- const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
554
- onBackend: backend => preparedBackends.set(options.url, backend) });
570
+ const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
571
+ onBackend: backend => preparedBackends.set(options.url, backend) });
555
572
  if (options.listSources) {
556
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`);
557
574
  else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
@@ -574,21 +591,31 @@ async function runMain(args, { config }) {
574
591
  }
575
592
  if (retryItems) for (const item of retryItems) {
576
593
  if (!item.source) continue;
577
- const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
578
- onBackend: backend => preparedBackends.set(item.url, backend) });
594
+ const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
595
+ onBackend: backend => preparedBackends.set(item.url, backend) });
579
596
  item.mediaUrl = discovery.mediaUrl;
580
597
  }
581
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
+ }
582
609
  if (options.listFormats) {
583
610
  const { listFormats } = await import('./downloader.js');
584
- stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
611
+ stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
585
612
  return 0;
586
613
  }
587
614
  if (options.dryRun) {
588
615
  const { planDownload } = await import('./downloader.js');
589
616
  for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
590
617
  const { url } = request;
591
- const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
618
+ const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
592
619
  if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
593
620
  else {
594
621
  stdout.write(`URL: ${url}\n`);
@@ -602,14 +629,17 @@ async function runMain(args, { config }) {
602
629
  const { download } = await import('./downloader.js');
603
630
  const { createStatsRecorder } = await import('./stats.js');
604
631
  const { createHistoryRecorder } = await import('./history.js');
605
- const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
606
- 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() });
607
635
  return result;
608
636
  } catch (error) {
609
637
  reporter.fail(controller.signal.aborted);
638
+ reporter.dispose();
610
639
  stderr.write(`veo: ${readableError(error)}\n`);
611
640
  return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
612
641
  } finally {
642
+ reporter.dispose();
613
643
  await run?.unregister();
614
644
  process.removeListener('SIGINT', cancel);
615
645
  process.removeListener('SIGTERM', cancel);
@@ -622,12 +652,12 @@ async function askSourceQuestion(prompt, signal) {
622
652
  finally { rl.close(); }
623
653
  }
624
654
 
625
- export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
626
- backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
655
+ export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
656
+ backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
627
657
  const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
628
658
  const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
629
- const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
630
- if (backend) onBackend?.(backend);
659
+ const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
660
+ if (backend) onBackend?.(backend);
631
661
  const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
632
662
  let originalError;
633
663
  if (!options.source && !options.listSources) {
@@ -638,10 +668,10 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
638
668
  }
639
669
  return {};
640
670
  }
641
- catch (error) { originalError = error; }
642
- if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
643
- if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
644
- stderr.write('veo: No direct video found; searching the page for a playable source…\n');
671
+ catch (error) { originalError = error; }
672
+ if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
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');
645
675
  }
646
676
  let sources;
647
677
  try {
@@ -656,18 +686,18 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
656
686
  }
657
687
  if (options.listSources) return { sources, formatSource };
658
688
  if (sources.timedOut) stderr.write(`veo: Source search timed out after ${options.timeout || (options.deepScan ? '120s' : '45s')}; showing verified sources found so far.\n`);
659
- if (!sources.length) {
660
- if (originalError) throw originalError;
661
- throw new Error('No downloadable video sources were found on this page.');
662
- }
663
- let index = Number(options.source);
664
- if (!index) {
665
- if (sources.length === 1) index = 1;
666
- else if (!interactive) return { sources, formatSource, needsSelection: true };
667
- }
668
- if (!index) {
669
- for (const source of sources) stderr.write(`${formatSource(source)}\n`);
670
- const answer = await ask('Source number (Enter to cancel): ', signal);
689
+ if (!sources.length) {
690
+ if (originalError) throw originalError;
691
+ throw new Error('No downloadable video sources were found on this page.');
692
+ }
693
+ let index = Number(options.source);
694
+ if (!index) {
695
+ if (sources.length === 1) index = 1;
696
+ else if (!interactive) return { sources, formatSource, needsSelection: true };
697
+ }
698
+ if (!index) {
699
+ for (const source of sources) stderr.write(`${formatSource(source)}\n`);
700
+ const answer = await ask('Source number (Enter to cancel): ', signal);
671
701
  if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
672
702
  index = Number(answer.trim());
673
703
  }
@@ -1,4 +1,4 @@
1
- import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, 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', 'activeProfile'].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']);
@@ -90,22 +90,22 @@ export async function semanticIssue(text) {
90
90
  };
91
91
  let issue = check(data, []);
92
92
  if (issue) return issue;
93
- if (Object.hasOwn(data, 'profiles')) {
94
- if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
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
- }
99
- if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
93
+ if (Object.hasOwn(data, 'profiles')) {
94
+ if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
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
+ }
99
+ if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
100
100
  issue = check(profile, ['profiles', name]); if (issue) return issue;
101
101
  }
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
- }
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
+ }
109
109
  const { config } = parseConfigText(text);
110
110
  for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
111
111
  try { await effectiveConfig(config, profile); }
@@ -114,7 +114,7 @@ export async function semanticIssue(text) {
114
114
  let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
115
115
  /output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
116
116
  /--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
117
- const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
117
+ const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
118
118
  const selected = applyProfile(config, profile);
119
119
  // Template errors do not name the CLI option. Locate the failing template.
120
120
  if (!key) {
@@ -143,8 +143,8 @@ export function editorCompletions(text, cursor) {
143
143
  const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
144
144
  if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
145
145
  const profile = path.length === 3 ? path[1] : undefined;
146
- const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
147
- key === 'activeProfile' ? Object.keys(data.profiles || {}) : 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));
148
148
  if (!choices.length) return null;
149
149
  const proposed = property ? suggestion(key, choices) : null;
150
150
  return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,