veodl 1.10.0 → 1.11.0

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,9 +44,14 @@ 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
- --sub-langs <langs> Subtitle languages, e.g. "de,en" (implies --subs)
51
+ --auto-subs Include automatically generated subtitles
52
+ --sub-langs <langs> Subtitle languages, e.g. "de,en" (manual unless --auto-subs)
53
+ --sub-format <format> Preferred subtitle format, e.g. srt/vtt/best
54
+ --list-subs Show manual and automatic subtitle languages, then exit
49
55
  --embed-subs Embed subtitles into the video file
50
56
  --embed-metadata Embed title, date and other metadata
51
57
  --embed-thumbnail Embed the thumbnail
@@ -56,17 +62,22 @@ Options:
56
62
  --cookies-from-browser <browser[:profile]>
57
63
  Read cookies from an installed browser
58
64
  --resume Keep partial data and continue an interrupted download
65
+ --incognito Save media without run IDs, history, stats or retry data
66
+ --neutral-filename Use a random video name; omit title and naming templates
67
+ --list-qualities Show available video resolutions and exit
68
+ --list-qualitys Alias for --list-qualities
59
69
  --list-formats Show the available formats and exit
60
70
  --list-sources Find video sources loaded by a web page and exit
61
- --auto-list-sources Search after no video is found (default: on)
71
+ --auto-list-sources Search after no video is found (default: on)
62
72
  --source <n> Select source number from that page (also for scripts)
63
73
  --deep-scan Check every media candidate found during source search
64
74
  --timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
65
75
  --dry-run Show what would be downloaded and exit
76
+ --verify Probe saved media files with FFprobe (default: off)
66
77
  --json Print one JSON object per URL instead of prose
67
78
  --profile <name> Apply a named config profile
68
79
  --batch-file <file> Read URLs from a file (one per line; # comments)
69
- --retry-failed <file> Retry failed/unfinished items from a saved job
80
+ --retry-failed <id|file> Retry failed/unfinished items by run ID or job file
70
81
  --playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
71
82
  --skip-existing Skip matching downloads still present on disk
72
83
  --no-<boolean-option> Disable a stored boolean default, e.g. --no-open
@@ -84,13 +95,14 @@ Commands:
84
95
  veo history --failed --limit 20 Filter and extend download history
85
96
  veo runs [id] List active runs; add --json for metadata and progress
86
97
  veo inspect <file> Read media metadata; --check-audio measures audio signal
98
+ veo subs <url> List available manual and automatic subtitles
87
99
  veo inspect run <id> Inspect saved files from a finished run by its id
88
100
  veo stop [id] Stop one run, or every active run
89
101
  veo alias list|add|remove Manage extra command names (wrappers calling veo)
90
102
  veo uninstall [-p <name>] Remove one alias, or everything with --yes
91
103
  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
104
+ veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
105
+ veo profile [list|reset|name] Show, list or change the default profile
94
106
  veo config edit [--external|--terminal] Choose the configuration editor
95
107
 
96
108
  Run veo without arguments in a terminal for interactive setup.
@@ -123,6 +135,7 @@ const STRING_OPTIONS = {
123
135
  'cookies-from-browser': {},
124
136
  'concurrent-fragments': { short: 'N' },
125
137
  'sub-langs': {},
138
+ 'sub-format': {},
126
139
  'sponsorblock-remove': {},
127
140
  section: {},
128
141
  profile: {},
@@ -134,6 +147,7 @@ const STRING_OPTIONS = {
134
147
  };
135
148
 
136
149
  const BOOLEAN_OPTIONS = {
150
+ 'experimental-fragments': {},
137
151
  compatible: {},
138
152
  recode: {},
139
153
  'adaptive-concurrency': {},
@@ -143,12 +157,19 @@ const BOOLEAN_OPTIONS = {
143
157
  open: {},
144
158
  audio: {},
145
159
  resume: {},
160
+ incognito: {},
161
+ 'neutral-filename': {},
146
162
  playlist: {},
147
163
  subs: {},
164
+ 'auto-subs': {},
165
+ 'list-subs': {},
166
+ verify: {},
148
167
  'embed-subs': {},
149
168
  'embed-metadata': {},
150
169
  'embed-thumbnail': {},
151
170
  'closest-quality': {},
171
+ 'list-qualities': {},
172
+ 'list-qualitys': {},
152
173
  'list-formats': {},
153
174
  'list-sources': {},
154
175
  'auto-list-sources': {},
@@ -180,14 +201,20 @@ export function optionDefaults(config = {}) {
180
201
  cookies: config.cookies,
181
202
  'cookies-from-browser': config.cookiesFromBrowser,
182
203
  'concurrent-fragments': String(config.concurrentFragments ?? 8),
204
+ 'experimental-fragments': config.experimentalFragments ?? false,
183
205
  'sub-langs': config.subLangs,
184
206
  'sponsorblock-remove': config.sponsorblockRemove,
185
207
  section: config.section,
186
208
  open: config.open ?? false,
187
209
  audio: config.audio ?? false,
188
210
  resume: config.resume ?? false,
211
+ incognito: config.incognito ?? false,
212
+ 'neutral-filename': config.neutralFilename ?? false,
189
213
  playlist: config.playlist ?? false,
190
214
  subs: config.subs ?? false,
215
+ 'auto-subs': config.autoSubs ?? false,
216
+ 'sub-format': config.subFormat,
217
+ verify: config.verify ?? false,
191
218
  'embed-subs': config.embedSubs ?? false,
192
219
  'embed-metadata': config.embedMetadata ?? false,
193
220
  'embed-thumbnail': config.embedThumbnail ?? false,
@@ -199,11 +226,11 @@ export function optionDefaults(config = {}) {
199
226
  timeout: config.timeout,
200
227
  'deep-scan': config.deepScan ?? false,
201
228
  'list-sources': config.listSources ?? false,
202
- 'auto-list-sources': config.autoListSources ?? true,
229
+ 'auto-list-sources': config.autoListSources ?? true,
203
230
  };
204
231
  }
205
232
 
206
- export function cliOptions(config = {}) {
233
+ export function cliOptions(config = {}) {
207
234
  const defaults = optionDefaults(config);
208
235
  const options = {};
209
236
  for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
@@ -215,42 +242,42 @@ export function cliOptions(config = {}) {
215
242
  if (defaults[name] !== undefined) options[name].default = defaults[name];
216
243
  }
217
244
  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
- }
245
+ }
246
+
247
+ const COMMAND_FLAGS = {
248
+ stats: ['--json'], history: ['--json', '--limit', '--failed'],
249
+ runs: ['--json'], stop: [], flush: ['--stats'],
250
+ inspect: ['--json', '--check-audio', '--search'],
251
+ doctor: ['--offline', '--output', '-o'],
252
+ update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
253
+ backend: ['--check', '--keep-files'],
254
+ alias: ['--json', '--force', '--bin-dir'],
255
+ uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
256
+ config: ['--profile', '--external', '--terminal'],
257
+ profile: [],
258
+ };
259
+ const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'subs', 'help', 'version'];
260
+
261
+ function mistypedCommandOption(args) {
262
+ const command = args[0];
263
+ if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
264
+ const suggestion = suggestOption(command, COMMAND_NAMES);
265
+ if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
266
+ }
267
+ const options = Object.hasOwn(COMMAND_FLAGS, command)
268
+ ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
269
+ : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
270
+ if (!options) return null; // Download flags are checked by parseCli itself.
271
+ const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
272
+ for (const arg of args.slice(start)) {
273
+ if (arg === '--') break;
274
+ const name = arg.split('=')[0];
275
+ if (!name.startsWith('-') || options.includes(name)) continue;
276
+ const suggestion = suggestOption(name, options);
277
+ if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
278
+ }
279
+ return null;
280
+ }
254
281
 
255
282
  export function parseCli(args, { config = {} } = {}) {
256
283
  if (args[0] === 'help') args = ['--help', ...args.slice(1)];
@@ -258,15 +285,15 @@ export function parseCli(args, { config = {} } = {}) {
258
285
  if (args.length !== 1) throw new Error('Usage: veo version');
259
286
  return { version: true };
260
287
  }
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;
288
+ let preliminary;
289
+ try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
290
+ catch (error) { throw explainUnknownOption(error, cliOptions()); }
291
+ const profileName = selectedProfileName(config, preliminary.values.profile);
292
+ config = applyProfile(config, preliminary.values.profile);
293
+ let parsed;
294
+ try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
295
+ catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
296
+ const { values, positionals, tokens } = parsed;
270
297
  if (values.help || values.version) return values;
271
298
  // A stored default conflicting with a flag typed right now is a user error;
272
299
  // a stored default merely ignored by another flag is not.
@@ -287,14 +314,36 @@ export function parseCli(args, { config = {} } = {}) {
287
314
  } else { validateItems(values['playlist-items']); values.playlist = true; }
288
315
  }
289
316
  if (values['retry-failed'] && (positionals.length || values['batch-file'])) throw new Error('--retry-failed cannot be combined with URLs or --batch-file.');
317
+ if (values.incognito && values['retry-failed']) throw new Error('--incognito cannot retry a saved job. Use --no-incognito for this request.');
318
+ if (values.incognito && values.resume) {
319
+ if (tokens.some(token => token.name === 'resume')) throw new Error('--incognito cannot be combined with --resume.');
320
+ values.resume = false;
321
+ }
322
+ if (values.incognito && values['skip-existing']) {
323
+ if (tokens.some(token => token.name === 'skip-existing')) throw new Error('--incognito cannot be combined with --skip-existing.');
324
+ values['skip-existing'] = false;
325
+ }
290
326
  if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
291
327
  if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
292
- if (positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
328
+ if (!values['neutral-filename'] && positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
293
329
  if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
294
330
  if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
295
331
  if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
332
+ values['list-qualities'] = Boolean(values['list-qualities'] || values['list-qualitys']);
333
+ delete values['list-qualitys'];
334
+ if (values['list-qualities']) {
335
+ if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-qualities accepts exactly one URL.');
336
+ if (values['list-formats'] || values['list-sources'] || values['list-subs']) throw new Error('--list-qualities cannot be combined with other listing actions.');
337
+ if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
338
+ }
296
339
  if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
297
340
  if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
341
+ if (values['list-subs']) {
342
+ if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-subs accepts exactly one URL.');
343
+ if (values['list-formats'] || values['list-sources']) throw new Error('--list-subs cannot be combined with other listing actions.');
344
+ if (values.playlist) throw new Error('--list-subs requires a single video without --playlist.');
345
+ }
346
+ if (values['sub-format'] && (!/^[a-z0-9]+(?:\/[a-z0-9]+)*$/i.test(values['sub-format']) || values['sub-format'].length > 100)) throw new Error('--sub-format must be a format or preference list such as srt/vtt/best.');
298
347
  if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
299
348
  if (values.source && values['list-sources'] && !tokens.some(token => token.name === 'list-sources')) values['list-sources'] = false;
300
349
  if (values['list-sources'] && !tokens.some(token => token.name === 'list-sources')
@@ -310,7 +359,7 @@ export function parseCli(args, { config = {} } = {}) {
310
359
  if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
311
360
  if (values['concurrent-fragments'] !== undefined) {
312
361
  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.');
362
+ validateFragments(fragments);
314
363
  }
315
364
  const concurrency = Number(values['playlist-concurrency']);
316
365
  if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
@@ -340,26 +389,28 @@ export function parseCli(args, { config = {} } = {}) {
340
389
  if (!Number.isInteger(options.concurrentDownloads) || options.concurrentDownloads < 1 || options.concurrentDownloads > 4) throw new Error('--concurrent-downloads must be a whole number between 1 and 4.');
341
390
  if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
342
391
  if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
343
- if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
392
+ if (options.rename && options.filenameTemplate && !options.neutralFilename) throw new Error('--rename and --filename-template cannot be combined.');
344
393
  const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
345
- 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
- }
394
+ if (options.subLangs && !disableSubs && !options.autoSubs) options.subs = true;
395
+ if (disableSubs) {
396
+ options.embedSubs = false;
397
+ if (!options.autoSubs) options.subLangs = undefined;
398
+ }
399
+ options.profile = profileName;
400
+ return options;
401
+ }
353
402
 
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; }
403
+ export async function main(args = process.argv.slice(2), { config } = {}) {
404
+ if (args[0] === 'help') args = ['--help', ...args.slice(1)];
405
+ const mistyped = mistypedCommandOption(args);
406
+ if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
358
407
  let color = !args.includes('--no-color') && !args.includes('--json');
359
408
  let display;
409
+ let loadedConfig;
360
410
  try {
361
411
  display = outputOptions(args);
362
412
  const loaded = config ?? await loadConfig();
413
+ loadedConfig = loaded.config || {};
363
414
  color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
364
415
  // Validate explicit profiles even when a color flag overrides their setting.
365
416
  if (display.profile) applyProfile(loaded.config || {}, display.profile);
@@ -375,7 +426,12 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
375
426
  // One throttled (once per day) update hint after every successful command.
376
427
  // Failures, cancellations, help/version output and the update commands
377
428
  // themselves never trigger it; VEO_NO_UPDATE_CHECK=1 disables it for scripts.
378
- if (code === 0 && shouldUpdateNotice(args)) {
429
+ let incognitoRequest = !args.length;
430
+ if (!incognitoRequest && code === 0 && shouldUpdateNotice(args)) {
431
+ try { incognitoRequest = Boolean(parseCli(args, { config: loadedConfig }).incognito); }
432
+ catch { /* Non-download commands have their own argument parser. */ }
433
+ }
434
+ if (code === 0 && shouldUpdateNotice(args) && !incognitoRequest) {
379
435
  try {
380
436
  const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
381
437
  if (notice) process.stderr.write(`${notice}\n`);
@@ -421,7 +477,7 @@ async function runMain(args, { config }) {
421
477
  try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
422
478
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
423
479
  }
424
- if (args[0] === 'retry') {
480
+ if (args[0] === 'retry') {
425
481
  if (args[1] !== '--last') {
426
482
  stderr.write('veo: Usage: veo retry --last [download options]\n');
427
483
  return 1;
@@ -430,12 +486,23 @@ async function runMain(args, { config }) {
430
486
  const { latestFailedJob } = await import('./jobs.js');
431
487
  args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
432
488
  } 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');
489
+ }
490
+ if (args[0] === 'subs') {
491
+ if (args[1] === '--help' || args[1] === '-h') {
492
+ stdout.write('veo subs <url> [--json] [--source <n>] [--profile <name>]\n\nList manual and automatically generated subtitle languages and source formats without downloading media.\n');
493
+ return 0;
494
+ }
495
+ if (!args[1] || args[1].startsWith('-')) {
496
+ stderr.write('veo: Usage: veo subs <url> [--json] [--source <n>]\n');
497
+ return 1;
498
+ }
499
+ args = [args[1], '--list-subs', ...args.slice(2)];
500
+ }
501
+ if (args[0] === 'profile') {
502
+ try { return await profileMain(args.slice(1)); }
503
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
504
+ }
505
+ const { cleanupDownloadCache } = await import('./download-cache.js');
439
506
  await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
440
507
  // update/upgrade/up/check subcommands are handled before URL validation.
441
508
  if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
@@ -446,10 +513,10 @@ async function runMain(args, { config }) {
446
513
  }
447
514
  return updateMain(args, { registry: defaultRegistry() });
448
515
  }
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
- }
516
+ if (args[0] === 'config') {
517
+ try { return await configMain(args.slice(1)); }
518
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
519
+ }
453
520
  if (args[0] === 'doctor') {
454
521
  try {
455
522
  const { doctorMain } = await import('./doctor.js');
@@ -493,9 +560,6 @@ async function runMain(args, { config }) {
493
560
  process.once('SIGINT', cancel);
494
561
  process.once('SIGTERM', cancel);
495
562
  try {
496
- if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version')) {
497
- run = await (await import('./runs.js')).registerRun(cancel);
498
- }
499
563
  const loaded = config ?? await loadConfig();
500
564
  for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
501
565
  if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
@@ -510,19 +574,20 @@ async function runMain(args, { config }) {
510
574
  stdout.write(`${pkg.version}\n`);
511
575
  return 0;
512
576
  }
577
+ if (!options.incognito) run = await (await import('./runs.js')).registerRun(cancel);
513
578
  let retryItems;
514
579
  if (options.retryFailed) {
515
580
  retryItems = await retryOptions(options.retryFailed);
516
581
  // Revalidate stored options and apply only flags explicitly supplied now.
517
582
  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
- });
583
+ const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
584
+ retryItems = retryItems.map(item => {
585
+ const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
586
+ ? { ...item, profiles: loaded.config?.profiles }
587
+ : item });
588
+ if (!overrideProfile) resumed.profile = item.profile;
589
+ return resumed;
590
+ });
526
591
  options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
527
592
  }
528
593
  if (options.batchFile) {
@@ -530,28 +595,29 @@ async function runMain(args, { config }) {
530
595
  options.urls.push(...lines.map(validateUrl));
531
596
  options.url = options.urls[0];
532
597
  if (!options.urls.length) throw new Error('The URL list is empty.');
533
- 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.');
598
+ if (!options.neutralFilename && 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
599
  if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
535
600
  }
601
+ await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
536
602
  // The job file is created before the first download, so another terminal can
537
603
  // 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);
604
+ const jobFile = options.incognito || options.listQualities || options.listFormats || options.listSources || options.listSubs || options.dryRun ? null : jobFilePath();
605
+ await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
606
+ audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
607
+ playlist: options.playlist, job: jobFile });
608
+ reporter.configure?.({ color: options.color && !options.json });
609
+ reporter.start(options.rename);
610
+ if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources && !options.listSubs) reporter.profile(options.profile);
611
+ const preparedBackends = new Map();
612
+ const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
613
+ const cookieWarning = cookieFileWarning(options.cookies);
548
614
  if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
549
615
 
550
616
  // For a single page, fall back to media requests observed while its player loads.
551
617
  // Explicit --source always rediscovers: signed media URLs can expire between runs.
552
618
  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) });
619
+ const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
620
+ onBackend: backend => preparedBackends.set(options.url, backend) });
555
621
  if (options.listSources) {
556
622
  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
623
  else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
@@ -574,21 +640,45 @@ async function runMain(args, { config }) {
574
640
  }
575
641
  if (retryItems) for (const item of retryItems) {
576
642
  if (!item.source) continue;
577
- const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
578
- onBackend: backend => preparedBackends.set(item.url, backend) });
643
+ const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
644
+ onBackend: backend => preparedBackends.set(item.url, backend) });
579
645
  item.mediaUrl = discovery.mediaUrl;
580
646
  }
581
647
 
648
+ if (options.listSubs) {
649
+ const { listSubtitles } = await import('./downloader.js');
650
+ const result = await listSubtitles(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
651
+ if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
652
+ else {
653
+ stdout.write(`Subtitles: ${cleanText(result.title || options.url)}\n`);
654
+ for (const [label, entries] of [['Manual', result.manual], ['Automatic', result.automatic]]) {
655
+ stdout.write(`${label}:\n`);
656
+ if (!entries.length) stdout.write(' none\n');
657
+ for (const entry of entries) stdout.write(` ${cleanText(entry.language)}${entry.name ? ` (${cleanText(entry.name)})` : ''}: ${entry.formats.join(', ') || 'unknown'}\n`);
658
+ }
659
+ }
660
+ return 0;
661
+ }
662
+ if (options.listQualities) {
663
+ const { listQualities } = await import('./downloader.js');
664
+ const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
665
+ if (options.json) stdout.write(JSON.stringify(result) + '\n');
666
+ else {
667
+ stdout.write('Available qualities: ' + (result.qualities.join(', ') || 'resolution unknown') + '\n');
668
+ if (result.qualities.length) stdout.write('Choose with --quality ' + result.qualities[0] + '\n');
669
+ }
670
+ return 0;
671
+ }
582
672
  if (options.listFormats) {
583
673
  const { listFormats } = await import('./downloader.js');
584
- stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
674
+ stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
585
675
  return 0;
586
676
  }
587
677
  if (options.dryRun) {
588
678
  const { planDownload } = await import('./downloader.js');
589
679
  for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
590
680
  const { url } = request;
591
- const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
681
+ const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
592
682
  if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
593
683
  else {
594
684
  stdout.write(`URL: ${url}\n`);
@@ -602,14 +692,18 @@ async function runMain(args, { config }) {
602
692
  const { download } = await import('./downloader.js');
603
693
  const { createStatsRecorder } = await import('./stats.js');
604
694
  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() });
695
+ if (!options.json && process.stdout.isTTY) reporter.enableInline();
696
+ const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
697
+ reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id,
698
+ recordStats: options.incognito ? undefined : createStatsRecorder(), recordHistory: options.incognito ? undefined : createHistoryRecorder() });
607
699
  return result;
608
700
  } catch (error) {
609
701
  reporter.fail(controller.signal.aborted);
702
+ reporter.dispose();
610
703
  stderr.write(`veo: ${readableError(error)}\n`);
611
704
  return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
612
705
  } finally {
706
+ reporter.dispose();
613
707
  await run?.unregister();
614
708
  process.removeListener('SIGINT', cancel);
615
709
  process.removeListener('SIGTERM', cancel);
@@ -622,12 +716,12 @@ async function askSourceQuestion(prompt, signal) {
622
716
  finally { rl.close(); }
623
717
  }
624
718
 
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) }) {
719
+ export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
720
+ backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
627
721
  const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
628
722
  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);
723
+ const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
724
+ if (backend) onBackend?.(backend);
631
725
  const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
632
726
  let originalError;
633
727
  if (!options.source && !options.listSources) {
@@ -638,10 +732,10 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
638
732
  }
639
733
  return {};
640
734
  }
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');
735
+ catch (error) { originalError = error; }
736
+ if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
737
+ if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
738
+ stderr.write('veo: No direct video found; searching the page for a playable source…\n');
645
739
  }
646
740
  let sources;
647
741
  try {
@@ -656,18 +750,18 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
656
750
  }
657
751
  if (options.listSources) return { sources, formatSource };
658
752
  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);
753
+ if (!sources.length) {
754
+ if (originalError) throw originalError;
755
+ throw new Error('No downloadable video sources were found on this page.');
756
+ }
757
+ let index = Number(options.source);
758
+ if (!index) {
759
+ if (sources.length === 1) index = 1;
760
+ else if (!interactive) return { sources, formatSource, needsSelection: true };
761
+ }
762
+ if (!index) {
763
+ for (const source of sources) stderr.write(`${formatSource(source)}\n`);
764
+ const answer = await ask('Source number (Enter to cancel): ', signal);
671
765
  if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
672
766
  index = Number(answer.trim());
673
767
  }