veodl 1.10.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to veo. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 1.11.0
6
+
7
+ ### Added
8
+
9
+ - **Incognito Downloads**: Added `--incognito` (and `"incognito": true` in configuration/profiles) to save media without creating run records, persistent retry jobs, run archives, download history, `.veo-history` records, or download statistics. Uses an isolated temporary staging directory and completely removes it upon completion, failure, or cancellation, and disables yt-dlp's filesystem cache.
10
+ - **Neutral Filenames**: Added `--neutral-filename` (and `"neutralFilename": true`) to generate unidentifiable random filenames (e.g. `video-a1b2c3d4e5f6.mp4`), suppressing video titles, channel names, and custom folder templates in filesystem paths.
11
+ - **Subtitle Inspection & Automatic Captions**:
12
+ - Added `veo subs <url>` (alias `--list-subs` / `--list-subtitles`) to inspect available manual and automatically generated subtitle tracks, languages, and source formats without downloading.
13
+ - Added `--auto-subs` (and `"autoSubs": true`) to download automatically generated captions/subtitles.
14
+ - Added `--sub-format <format>` (and `"subFormat": string`) to express a preferred subtitle source format (e.g. `srt/vtt/best`).
15
+ - **Post-Download Media Verification**: Added `--verify` (and `"verify": true`) to probe saved media files with FFprobe before recording success, verifying container integrity and stream presence. Retains saved files for inspection if verification fails.
16
+
5
17
  ## 1.10.1
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -71,7 +71,10 @@ Options:
71
71
  -N, --concurrent-fragments <n> Parallel fragments, 1-64 (default: 8; 17-64 experimental)
72
72
  --experimental-fragments Explicitly accept experimental values above 16
73
73
  --subs Download subtitles (default languages: en)
74
- --sub-langs <langs> Subtitle languages, e.g. "de,en" (implies --subs)
74
+ --auto-subs Include automatically generated subtitles
75
+ --sub-langs <langs> Subtitle languages, e.g. "de,en" (manual unless --auto-subs)
76
+ --sub-format <format> Prefer subtitle formats, e.g. srt/vtt/best
77
+ --list-subs Show manual and automatic subtitle availability
75
78
  --embed-subs Embed subtitles into the video file
76
79
  --embed-metadata Embed title, date and other metadata
77
80
  --embed-thumbnail Embed the thumbnail
@@ -80,9 +83,12 @@ Options:
80
83
  --cookies <file> Netscape cookie file, for content you may access
81
84
  --cookies-from-browser <browser[:profile]>
82
85
  --resume Keep partial data and continue an interrupted download
86
+ --incognito Save media without run ID, history, stats or retry data
87
+ --neutral-filename Optional random filename instead of title and templates
83
88
  --list-qualities Show available video resolutions and exit
84
89
  --list-formats Show available formats and exit
85
90
  --dry-run Show what would be downloaded and exit
91
+ --verify Probe saved media files with FFprobe (default: off)
86
92
  --json One JSON object per URL instead of prose
87
93
  --profile <name> Use a named profile from the config file
88
94
  --batch-file <file> Read one URL per line (blank lines and # comments ignored)
@@ -292,8 +298,13 @@ video sites. All new options are also documented in `veo config guide`.
292
298
 
293
299
  ### Metadata and subtitles
294
300
 
295
- - `--subs` writes subtitle files, `--sub-langs de,en` selects languages, and `--embed-subs`
296
- embeds them into the video container instead.
301
+ - `veo subs <URL>` lists manual and automatically generated subtitles separately, including
302
+ their languages and source formats. `--json` returns one object without subtitle URLs.
303
+ - `--subs` writes manual subtitle files. `--auto-subs` adds generated subtitles, or selects
304
+ only generated subtitles when used alone. `--sub-langs de,en` selects languages;
305
+ `--sub-format srt/vtt/best` prefers available source formats in that order. A format
306
+ preference does not convert subtitles. `--embed-subs` embeds selected subtitles into
307
+ the video container.
297
308
  - `--embed-metadata` and `--embed-thumbnail` use FFmpeg to write metadata and cover art.
298
309
  - `--sponsorblock-remove sponsor,selfpromo` cuts sponsor segments (YouTube) and requires
299
310
  FFmpeg; the cut re-encodes the affected parts.
@@ -301,6 +312,16 @@ video sites. All new options are also documented in `veo config guide`.
301
312
  - `-N 8` downloads several fragments in parallel, which is noticeably faster on HLS/DASH
302
313
  sources and heavier on the network.
303
314
 
315
+ ### Optional post-download verification
316
+
317
+ Use `veo <URL> --verify` or set `"verify": true` in a config profile to check
318
+ saved media with FFprobe before recording a successful download. It checks that
319
+ the container can be probed and contains a video track (or an audio track for
320
+ `--audio`). Verification is off by default; `--no-verify` overrides a profile.
321
+ This quick check does not decode the whole file or prove that the audio is
322
+ audible. Use `veo inspect <file> --check-audio` for a full audio signal check.
323
+ If verification fails, veo reports failure and keeps the saved file for review.
324
+
304
325
  ### Access and credentials
305
326
 
306
327
  veo downloads publicly accessible content. It does not bypass access controls, and it does
@@ -397,11 +418,11 @@ are not allowed.
397
418
  }
398
419
  ```
399
420
 
400
- Supported keys: `output`, `quality`, `format`, `rename`, `audio`, `open`, `resume`,
421
+ Supported keys: `output`, `quality`, `format`, `rename`, `audio`, `open`, `resume`, `incognito`, `neutralFilename`,
401
422
  `closestQuality`, `cookies`, `cookiesFromBrowser`, `playlist`, `concurrentFragments`,
402
423
  `experimentalFragments`, `playlistConcurrency`, `recode`, `compatible`, `concurrentDownloads`, `adaptiveConcurrency`,
403
424
  `filenameTemplate`, `folderTemplate`, `checkSpace`, `timings`, `color`,
404
- `subs`, `subLangs`, `embedSubs`, `embedMetadata`, `embedThumbnail`, `sponsorblockRemove`,
425
+ `subs`, `autoSubs`, `subLangs`, `subFormat`, `embedSubs`, `embedMetadata`, `embedThumbnail`, `sponsorblockRemove`, `verify`,
405
426
  `section`, `json`. An explicit command-line flag always wins over a stored default. An
406
427
  unknown key produces a warning; invalid JSON or a wrong value type is an error, because
407
428
  silently ignoring a typo would be worse.
@@ -420,6 +441,27 @@ Additional defaults are `skipExisting` and `playlistItems`. Boolean defaults can
420
441
  with `--no-open`, `--no-audio`, `--no-resume`, `--no-embed-metadata`, etc. `--no-subs` also
421
442
  disables stored subtitle languages and subtitle embedding for that invocation.
422
443
 
444
+ ### Incognito downloads
445
+
446
+ Use `veo <url> --incognito` or set `"incognito": true` in a named profile. The
447
+ downloaded media is saved normally. veo does not assign a run ID or write a
448
+ retry job, run archive, download history, output `.veo-history` record, or
449
+ statistics for that attempt. It uses a private temporary staging directory and
450
+ removes it after success, failure, or cancellation. It also disables yt-dlp's
451
+ filesystem cache. `--no-incognito` overrides
452
+ a profile for one request. Incognito cannot be combined with `--resume`,
453
+ `--skip-existing`, or `--retry-failed`; there is no veo retry for an incognito
454
+ attempt. A force-killed process may leave temporary files that the OS can
455
+ clean later. The destination file, shell history, network service, and source
456
+ website can still reveal the download.
457
+
458
+ Example profile: `"profiles": { "private": { "incognito": true } }`.
459
+ Add `"neutralFilename": true` to that profile, or pass `--neutral-filename`,
460
+ to save files as names such as `video-a1b2c3d4e5f6.mp4`. This is optional:
461
+ incognito keeps the normal title by default. Neutral naming overrides `--rename`,
462
+ `--filename-template`, and `--folder-template` for that request. The selected
463
+ output directory, media contents, and embedded metadata are unaffected.
464
+
423
465
  ### Named profiles
424
466
 
425
467
  If no profile is selected, `profiles.default` is applied automatically. `veo profile music`
@@ -548,6 +590,7 @@ veo retry --last # retry the newest failed or unfinished job
548
590
  veo runs # active runs with their id; veo runs <id> for details
549
591
  veo runs --json # machine-readable active run metadata
550
592
  veo inspect FILE --check-audio --json # track metadata and audio signal
593
+ veo subs URL --json # available manual and automatic subtitles
551
594
  veo inspect run ID --check-audio --json # finished run and saved media
552
595
  veo stop [id] # stop one run, or every active run
553
596
  veo flush # stop runs, clear temporary downloads and retry jobs
@@ -68,6 +68,8 @@ veo retry --last --json
68
68
 
69
69
  ## Inspect saved media and audio
70
70
 
71
+ `veo subs <URL> --json` lists manual and automatic subtitle languages and formats without saving media. Select manual subtitles with `--subs --sub-langs de,en`, generated subtitles with `--auto-subs --sub-langs de,en`, and prefer a source format with `--sub-format srt/vtt/best`. `--verify` optionally probes saved media files before recording success; it is off by default and does not decode the full track.
72
+
71
73
  ```sh
72
74
  veo inspect "/downloads/Example.mp4" --json
73
75
  veo inspect "/downloads/Example.mp4" --check-audio --json
@@ -85,6 +87,9 @@ The run record is stored in veo's per-user cache on the device that ran the down
85
87
 
86
88
  ## Operational notes
87
89
 
90
+ - `veo <URL> --incognito --json` saves media without a run ID, retry job, history, run archive, output duplicate record or statistics. It disables yt-dlp's filesystem cache. A config profile can set `"incognito": true`; `--no-incognito` overrides it. Incognito cannot use `--resume`, `--skip-existing`, or retry. Its temporary staging data is removed after the attempt; the output file and records outside veo (such as shell history or server logs) still exist.
91
+ - Add `--neutral-filename` or `"neutralFilename": true` to use random media filenames instead of titles. This option is independent of `--incognito` and overrides custom file and folder naming for that request.
92
+
88
93
  - Fragment concurrency defaults to 8. Values 1-16 need no confirmation; 17-64 are experimental and require terminal `y/N` confirmation before downloading. Scripts and JSON calls must explicitly accept with `--experimental-fragments` or `"experimentalFragments": true` in the config/profile. Values above 64 are always rejected. More connections may trigger server limits and do not guarantee higher speed. Dry-runs and source/format listings do not require confirmation.
89
94
  - Example profile: `"experimental": { "concurrentFragments": 32, "experimentalFragments": true }`. Run with `veo <URL> --profile experimental`; use `--no-experimental-fragments` to revoke a stored opt-in.
90
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veodl",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -48,7 +48,10 @@ Options:
48
48
  --experimental-fragments Explicitly accept experimental values above 16
49
49
  Otherwise requires terminal confirmation before download
50
50
  --subs Download subtitles (default languages: en)
51
- --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
52
55
  --embed-subs Embed subtitles into the video file
53
56
  --embed-metadata Embed title, date and other metadata
54
57
  --embed-thumbnail Embed the thumbnail
@@ -59,6 +62,8 @@ Options:
59
62
  --cookies-from-browser <browser[:profile]>
60
63
  Read cookies from an installed browser
61
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
62
67
  --list-qualities Show available video resolutions and exit
63
68
  --list-qualitys Alias for --list-qualities
64
69
  --list-formats Show the available formats and exit
@@ -68,6 +73,7 @@ Options:
68
73
  --deep-scan Check every media candidate found during source search
69
74
  --timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
70
75
  --dry-run Show what would be downloaded and exit
76
+ --verify Probe saved media files with FFprobe (default: off)
71
77
  --json Print one JSON object per URL instead of prose
72
78
  --profile <name> Apply a named config profile
73
79
  --batch-file <file> Read URLs from a file (one per line; # comments)
@@ -89,6 +95,7 @@ Commands:
89
95
  veo history --failed --limit 20 Filter and extend download history
90
96
  veo runs [id] List active runs; add --json for metadata and progress
91
97
  veo inspect <file> Read media metadata; --check-audio measures audio signal
98
+ veo subs <url> List available manual and automatic subtitles
92
99
  veo inspect run <id> Inspect saved files from a finished run by its id
93
100
  veo stop [id] Stop one run, or every active run
94
101
  veo alias list|add|remove Manage extra command names (wrappers calling veo)
@@ -128,6 +135,7 @@ const STRING_OPTIONS = {
128
135
  'cookies-from-browser': {},
129
136
  'concurrent-fragments': { short: 'N' },
130
137
  'sub-langs': {},
138
+ 'sub-format': {},
131
139
  'sponsorblock-remove': {},
132
140
  section: {},
133
141
  profile: {},
@@ -149,8 +157,13 @@ const BOOLEAN_OPTIONS = {
149
157
  open: {},
150
158
  audio: {},
151
159
  resume: {},
160
+ incognito: {},
161
+ 'neutral-filename': {},
152
162
  playlist: {},
153
163
  subs: {},
164
+ 'auto-subs': {},
165
+ 'list-subs': {},
166
+ verify: {},
154
167
  'embed-subs': {},
155
168
  'embed-metadata': {},
156
169
  'embed-thumbnail': {},
@@ -195,8 +208,13 @@ export function optionDefaults(config = {}) {
195
208
  open: config.open ?? false,
196
209
  audio: config.audio ?? false,
197
210
  resume: config.resume ?? false,
211
+ incognito: config.incognito ?? false,
212
+ 'neutral-filename': config.neutralFilename ?? false,
198
213
  playlist: config.playlist ?? false,
199
214
  subs: config.subs ?? false,
215
+ 'auto-subs': config.autoSubs ?? false,
216
+ 'sub-format': config.subFormat,
217
+ verify: config.verify ?? false,
200
218
  'embed-subs': config.embedSubs ?? false,
201
219
  'embed-metadata': config.embedMetadata ?? false,
202
220
  'embed-thumbnail': config.embedThumbnail ?? false,
@@ -238,7 +256,7 @@ const COMMAND_FLAGS = {
238
256
  config: ['--profile', '--external', '--terminal'],
239
257
  profile: [],
240
258
  };
241
- const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
259
+ const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'subs', 'help', 'version'];
242
260
 
243
261
  function mistypedCommandOption(args) {
244
262
  const command = args[0];
@@ -296,9 +314,18 @@ export function parseCli(args, { config = {} } = {}) {
296
314
  } else { validateItems(values['playlist-items']); values.playlist = true; }
297
315
  }
298
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
+ }
299
326
  if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
300
327
  if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
301
- 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.');
302
329
  if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
303
330
  if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
304
331
  if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
@@ -306,11 +333,17 @@ export function parseCli(args, { config = {} } = {}) {
306
333
  delete values['list-qualitys'];
307
334
  if (values['list-qualities']) {
308
335
  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.');
336
+ if (values['list-formats'] || values['list-sources'] || values['list-subs']) throw new Error('--list-qualities cannot be combined with other listing actions.');
310
337
  if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
311
338
  }
312
339
  if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
313
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.');
314
347
  if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
315
348
  if (values.source && values['list-sources'] && !tokens.some(token => token.name === 'list-sources')) values['list-sources'] = false;
316
349
  if (values['list-sources'] && !tokens.some(token => token.name === 'list-sources')
@@ -356,12 +389,12 @@ export function parseCli(args, { config = {} } = {}) {
356
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.');
357
390
  if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
358
391
  if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
359
- 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.');
360
393
  const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
361
- if (options.subLangs && !disableSubs) options.subs = true;
394
+ if (options.subLangs && !disableSubs && !options.autoSubs) options.subs = true;
362
395
  if (disableSubs) {
363
- options.subLangs = undefined;
364
396
  options.embedSubs = false;
397
+ if (!options.autoSubs) options.subLangs = undefined;
365
398
  }
366
399
  options.profile = profileName;
367
400
  return options;
@@ -373,9 +406,11 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
373
406
  if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
374
407
  let color = !args.includes('--no-color') && !args.includes('--json');
375
408
  let display;
409
+ let loadedConfig;
376
410
  try {
377
411
  display = outputOptions(args);
378
412
  const loaded = config ?? await loadConfig();
413
+ loadedConfig = loaded.config || {};
379
414
  color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
380
415
  // Validate explicit profiles even when a color flag overrides their setting.
381
416
  if (display.profile) applyProfile(loaded.config || {}, display.profile);
@@ -391,7 +426,12 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
391
426
  // One throttled (once per day) update hint after every successful command.
392
427
  // Failures, cancellations, help/version output and the update commands
393
428
  // themselves never trigger it; VEO_NO_UPDATE_CHECK=1 disables it for scripts.
394
- 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) {
395
435
  try {
396
436
  const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
397
437
  if (notice) process.stderr.write(`${notice}\n`);
@@ -447,6 +487,17 @@ async function runMain(args, { config }) {
447
487
  args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
448
488
  } catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
449
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
+ }
450
501
  if (args[0] === 'profile') {
451
502
  try { return await profileMain(args.slice(1)); }
452
503
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
@@ -509,9 +560,6 @@ async function runMain(args, { config }) {
509
560
  process.once('SIGINT', cancel);
510
561
  process.once('SIGTERM', cancel);
511
562
  try {
512
- if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version')) {
513
- run = await (await import('./runs.js')).registerRun(cancel);
514
- }
515
563
  const loaded = config ?? await loadConfig();
516
564
  for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
517
565
  if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
@@ -526,6 +574,7 @@ async function runMain(args, { config }) {
526
574
  stdout.write(`${pkg.version}\n`);
527
575
  return 0;
528
576
  }
577
+ if (!options.incognito) run = await (await import('./runs.js')).registerRun(cancel);
529
578
  let retryItems;
530
579
  if (options.retryFailed) {
531
580
  retryItems = await retryOptions(options.retryFailed);
@@ -546,19 +595,19 @@ async function runMain(args, { config }) {
546
595
  options.urls.push(...lines.map(validateUrl));
547
596
  options.url = options.urls[0];
548
597
  if (!options.urls.length) throw new Error('The URL list is empty.');
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.');
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.');
550
599
  if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
551
600
  }
552
601
  await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
553
602
  // The job file is created before the first download, so another terminal can
554
603
  // follow this run's per-item progress with `veo runs <id>`.
555
- const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
604
+ const jobFile = options.incognito || options.listQualities || options.listFormats || options.listSources || options.listSubs || options.dryRun ? null : jobFilePath();
556
605
  await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
557
606
  audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
558
607
  playlist: options.playlist, job: jobFile });
559
608
  reporter.configure?.({ color: options.color && !options.json });
560
609
  reporter.start(options.rename);
561
- if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources) reporter.profile(options.profile);
610
+ if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources && !options.listSubs) reporter.profile(options.profile);
562
611
  const preparedBackends = new Map();
563
612
  const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
564
613
  const cookieWarning = cookieFileWarning(options.cookies);
@@ -596,6 +645,20 @@ async function runMain(args, { config }) {
596
645
  item.mediaUrl = discovery.mediaUrl;
597
646
  }
598
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
+ }
599
662
  if (options.listQualities) {
600
663
  const { listQualities } = await import('./downloader.js');
601
664
  const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
@@ -631,7 +694,8 @@ async function runMain(args, { config }) {
631
694
  const { createHistoryRecorder } = await import('./history.js');
632
695
  if (!options.json && process.stdout.isTTY) reporter.enableInline();
633
696
  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() });
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() });
635
699
  return result;
636
700
  } catch (error) {
637
701
  reporter.fail(controller.signal.aborted);
@@ -40,7 +40,10 @@ export const PROFILE_OPTIONS_GUIDE = String.raw`${PROFILE_OPTIONS_MARKER}
40
40
  // "folderTemplate": "{channel}/{year}",
41
41
  // "filenameTemplate": "{index} - {title}", // Without extension; do not combine with rename
42
42
  // "resume": true,
43
+ // "incognito": false, // Save only media; no run ID, history, stats or retry data
44
+ // "neutralFilename": false, // Optional random filenames; ignores rename and naming templates
43
45
  // "skipExisting": true
46
+ // "verify": false, // Probe saved media with FFprobe; no full decode
44
47
  // Lossless container change (the selected codecs must fit the container):
45
48
  // "format": "mkv",
46
49
  // "compatible": false, // Opt-in H.264/AAC MP4; converts only when needed
@@ -82,7 +85,10 @@ const TEMPLATE_SOURCE = String.raw`${TEMPLATE_MARKER}
82
85
  // "section": "*10:00-12:00", // Download only a time range
83
86
  // "open": false, // Open the completed file automatically
84
87
  // "resume": true, // Resume interrupted downloads
88
+ // "incognito": true, // Save only media; no run ID, history, stats or retry data
89
+ // "neutralFilename": false, // Set true for random filenames without title or template folders
85
90
  // "skipExisting": true, // Skip previously saved downloads
91
+ // "verify": false, // Check saved media container and tracks with FFprobe
86
92
  // "concurrentFragments": 8, // Concurrent fragments: 1 to 64; default 8
87
93
  // Values above 16 require confirmation or the explicit opt-in below.
88
94
  // "experimentalFragments": false, // true accepts experimental values 17-64
@@ -95,7 +101,9 @@ ${SOURCE_OPTIONS_GUIDE}
95
101
 
96
102
  // Subtitles and additional information:
97
103
  // "subs": true, // Download subtitles; defaults to English
104
+ // "autoSubs": false, // Include automatically generated subtitles
98
105
  // "subLangs": "de,en", // Enable subtitles for these languages
106
+ // "subFormat": "srt/vtt/best", // Prefer these available subtitle formats
99
107
  // "embedSubs": true, // Embed subtitles in the video
100
108
  // "embedMetadata": true, // Embed the title, date and other metadata
101
109
  // "embedThumbnail": true, // Embed the thumbnail
package/src/config.js CHANGED
@@ -29,6 +29,8 @@ export const CONFIG_KEYS = Object.freeze({
29
29
  audio: 'boolean',
30
30
  open: 'boolean',
31
31
  resume: 'boolean',
32
+ incognito: 'boolean',
33
+ neutralFilename: 'boolean',
32
34
  closestQuality: 'boolean',
33
35
  cookies: 'string',
34
36
  cookiesFromBrowser: 'string',
@@ -36,7 +38,10 @@ export const CONFIG_KEYS = Object.freeze({
36
38
  concurrentFragments: 'number',
37
39
  experimentalFragments: 'boolean',
38
40
  subs: 'boolean',
41
+ autoSubs: 'boolean',
39
42
  subLangs: 'string',
43
+ subFormat: 'string',
44
+ verify: 'boolean',
40
45
  embedSubs: 'boolean',
41
46
  embedMetadata: 'boolean',
42
47
  embedThumbnail: 'boolean',
package/src/downloader.js CHANGED
@@ -9,6 +9,7 @@ import path from 'node:path';
9
9
  import { resolveBackend } from './backend.js';
10
10
  import { availableHeights, cappedHeight, closestHeight, saveUnique, sanitizeTitle } from './utils.js';
11
11
  import { digest, readJson, writeJson } from './state.js';
12
+ import { isMediaFile } from './run-archive.js';
12
13
  import { cleanupDownloadCache, downloadCacheRoot, TRANSFER_RETENTION_MS } from './download-cache.js';
13
14
  import { selectedEntries, sizeEstimate, describeEstimate } from './playlist.js';
14
15
 
@@ -157,6 +158,8 @@ function downloadSettings(options) {
157
158
  embedMetadata: false, embedThumbnail: false, ...options };
158
159
  const settings = Object.fromEntries(['quality', 'audio', 'format', 'closestQuality', 'subs', 'subLangs', 'embedSubs',
159
160
  'embedMetadata', 'embedThumbnail', 'sponsorblockRemove', 'section'].map(key => [key, options[key] ?? null]));
161
+ if (options.autoSubs) settings.autoSubs = true;
162
+ if (options.subFormat) settings.subFormat = options.subFormat;
160
163
  // Existing conversion jobs retain their keys; remux requests must not reuse them.
161
164
  if (options.format && !options.audio && !options.recode) settings.videoRemux = true;
162
165
  if (options.compatible) settings.compatible = true;
@@ -229,6 +232,7 @@ function backendArgs(options, backend) {
229
232
  '--socket-timeout', '30', '--retries', '3', '--fragment-retries', '3',
230
233
  '--no-js-runtimes', '--js-runtimes', `node:${process.execPath}`,
231
234
  '--ffmpeg-location', backend.ffmpegLocation];
235
+ if (options.incognito) common.push('--no-cache-dir');
232
236
  if (!options.playlist) common.push('--no-playlist');
233
237
  if (options._entryIndex) common.push('--playlist-items', String(options._entryIndex));
234
238
  common.push('--concurrent-fragments', String(options.concurrentFragments ?? 8));
@@ -265,9 +269,12 @@ function mediaArgs(options, quality) {
265
269
  // WebM keep their own source selection. Resolution remains the primary choice.
266
270
  const selectedSort = options.recode || options.format === 'webm' ? quality.sort : sort;
267
271
  if (!options.audio && selectedSort) args.push('-S', selectedSort);
268
- if (options.subs || options.subLangs || options.embedSubs) {
269
- args.push('--write-subs', '--sub-langs', options.subLangs || 'en.*,en');
272
+ if (options.subs || options.autoSubs || options.subLangs || options.embedSubs) {
273
+ if (options.subs || (options.embedSubs && !options.autoSubs)) args.push('--write-subs');
274
+ if (options.autoSubs) args.push('--write-auto-subs');
275
+ args.push('--sub-langs', options.subLangs || 'en.*,en');
270
276
  }
277
+ if (options.subFormat) args.push('--sub-format', options.subFormat);
271
278
  if (options.embedSubs) args.push('--embed-subs');
272
279
  if (options.embedMetadata) args.push('--embed-metadata');
273
280
  if (options.embedThumbnail) args.push('--embed-thumbnail');
@@ -334,6 +341,19 @@ export async function listQualities(options, { signal, backendResolver = resolve
334
341
  return { url: options.url, status: 'qualities', title: metadata.title || null, qualities };
335
342
  }
336
343
 
344
+ /** List subtitle languages and source formats without exposing signed subtitle URLs. */
345
+ export async function listSubtitles(options, { signal, backendResolver = resolveBackend, runner = runBackend, reporter } = {}) {
346
+ const backend = await prepareBackend(options, { signal, backendResolver, reporter });
347
+ const metadata = await fetchMetadata({ ...options, playlist: false }, { signal, backend, runner, reporter });
348
+ const summarize = source => Object.entries(source || {}).map(([language, tracks]) => ({
349
+ language,
350
+ name: Array.isArray(tracks) ? tracks.find(track => typeof track?.name === 'string')?.name || null : null,
351
+ formats: Array.isArray(tracks) ? [...new Set(tracks.map(track => track?.ext).filter(ext => typeof ext === 'string' && /^[a-z0-9]+$/i.test(ext)))].sort() : [],
352
+ })).sort((a, b) => a.language.localeCompare(b.language));
353
+ return { url: options.url, status: 'subtitles', title: metadata.title || null,
354
+ manual: summarize(metadata.subtitles), automatic: summarize(metadata.automatic_captions) };
355
+ }
356
+
337
357
  /** `veo --list-formats`: print the backend's own format table and stop. */
338
358
  export async function listFormats(options, { signal, backendResolver = resolveBackend, runner = runBackend, reporter } = {}) {
339
359
  const backend = await prepareBackend(options, { signal, backendResolver, reporter });
@@ -405,8 +425,9 @@ async function downloadCollection(options, metadata, dependencies) {
405
425
  } catch (error) {
406
426
  if (signal?.aborted) throw error;
407
427
  failures.push({ index, error: error.message });
428
+ results[offset] = error.files || [];
408
429
  reporter?.status(`Entry ${index} failed: ${error.message}`);
409
- outcome = { index, status: 'failed', error: error.message };
430
+ outcome = { index, status: 'failed', error: error.message, files: error.files || [] };
410
431
  }
411
432
  await notify(outcome);
412
433
  }
@@ -417,7 +438,9 @@ async function downloadCollection(options, metadata, dependencies) {
417
438
  if (rejected) throw rejected.reason;
418
439
  const files = results.flatMap(files => files || []);
419
440
  failures.sort((a, b) => a.index - b.index);
420
- return { url: options.url, title: metadata.title || metadata.id || 'Playlist', files, saved, skipped, failures, ...(entryTimings.length ? { entryTimings: entryTimings.sort((a, b) => a.index - b.index) } : {}),
441
+ return { url: options.url, title: metadata.title || metadata.id || 'Playlist', files, saved, skipped, failures,
442
+ ...(options.verify && !failures.length && !skipped ? { verified: true } : {}),
443
+ ...(entryTimings.length ? { entryTimings: entryTimings.sort((a, b) => a.index - b.index) } : {}),
421
444
  status: failures.length ? 'failed' : saved ? 'saved' : 'skipped' };
422
445
  }
423
446
 
@@ -425,7 +448,7 @@ async function downloadCollection(options, metadata, dependencies) {
425
448
  * Download one URL. Returns every file that was saved, in the order the
426
449
  * backend produced them, so collections and subtitle sidecars are reported.
427
450
  */
428
- export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend } = {}) {
451
+ export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend, verifier } = {}) {
429
452
  const timer = phaseTimer(now);
430
453
  const timingResult = () => {
431
454
  if (options.timings === false) return {};
@@ -438,13 +461,13 @@ export async function download(options, { signal, reporter, backendResolver = re
438
461
  const stagingPath = path.join(localRoot, `${PARTIAL_PREFIX}${requestKey}`);
439
462
  const existingStage = await lstat(stagingPath).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
440
463
  if (existingStage && (!existingStage.isDirectory() || existingStage.isSymbolicLink())) throw new Error(`The partial download path is not a plain directory: ${stagingPath}`);
441
- const cached = await readJson(path.join(stagingPath, 'job.json'), null);
464
+ const cached = options.incognito ? null : await readJson(path.join(stagingPath, 'job.json'), null);
442
465
  const readyToTransfer = cached?.requestKey === requestKey && cached.ready?.length && cached.metadata
443
466
  && (cached.backendSucceeded === true || cached.compatibilityChecked === true || cached.files?.length);
444
467
  const backend = readyToTransfer ? null : await prepareBackend(options, { signal, backendResolver, reporter });
445
468
  timer.switch('metadata');
446
469
  const metadata = readyToTransfer ? cached.metadata : await adaptiveRun(() => fetchMetadata(options, { signal, backend, runner, reporter }), { enabled: options.adaptiveConcurrency !== false, state: adaptiveState, signal, reporter, wait, timer });
447
- if (metadata.entries && !options._entryIndex) return downloadCollection(options, metadata, { signal, reporter, backendResolver: async () => backend, runner, onEntry, localRoot, adaptiveState, wait, diskChecker, now, compatibilityRunner });
470
+ if (metadata.entries && !options._entryIndex) return downloadCollection(options, metadata, { signal, reporter, backendResolver: async () => backend, runner, onEntry, localRoot, adaptiveState, wait, diskChecker, now, compatibilityRunner, verifier });
448
471
  const destination = mediaDestination(options, metadata, stagedTitle('media.mp4', metadata, options.rename));
449
472
  directory = destination.directory;
450
473
  await prepareDestination(options.output, directory);
@@ -456,10 +479,23 @@ export async function download(options, { signal, reporter, backendResolver = re
456
479
 
457
480
  const key = partialKey(metadata, options.url, options);
458
481
  const historyFile = path.join(directory, '.veo-history', `${key}.json`);
459
- const history = options.skipExisting || (options.resume && options._entryIndex) ? await readJson(historyFile, null) : null;
460
- if (await pruneHistoryRecord(historyFile, directory, history)) {
482
+ const history = !options.incognito && (options.skipExisting || (options.resume && options._entryIndex)) ? await readJson(historyFile, null) : null;
483
+ if (!options.incognito && await pruneHistoryRecord(historyFile, directory, history)) {
484
+ if (options.verify) {
485
+ const verifyFile = verifier || (await import('./inspect-media.js')).verifySavedMedia;
486
+ try {
487
+ const media = history.files.filter(isMediaFile);
488
+ if (!media.length) throw new Error('Verification failed: no saved media file was found.');
489
+ for (const file of media) await verifyFile(file, { audio: Boolean(options.audio), signal });
490
+ } catch (error) {
491
+ error.files = history.files;
492
+ error.verificationFailed = true;
493
+ throw error;
494
+ }
495
+ }
461
496
  reporter?.status('Already downloaded; skipped.');
462
- return { url: options.url, title: metadata.title, files: [], status: 'skipped', saved: 0, skipped: 1, ...timingResult() };
497
+ return { url: options.url, title: metadata.title, files: [], status: 'skipped', saved: 0, skipped: 1,
498
+ ...(options.verify ? { verified: true } : {}), ...timingResult() };
463
499
  }
464
500
  const staging = await prepareStaging(stagingPath);
465
501
  const lockPath = path.join(staging, '.lock');
@@ -562,15 +598,30 @@ export async function download(options, { signal, reporter, backendResolver = re
562
598
  files.push(saved);
563
599
  files.push(...await saveSidecars(staging, resolved, saved, { signal, manifest, manifestFile }));
564
600
  }
565
- await writeJson(historyFile, { version: 1, source: sourceKey(metadata, options.url), settings: downloadSettings(options), files });
566
- return { url: options.url, title: metadata.title || metadata.id || 'video', files, status: 'saved', saved: 1, skipped: 0, ...timingResult() };
601
+ if (options.verify) {
602
+ const media = files.filter(isMediaFile);
603
+ if (!media.length) throw new Error('Verification failed: no saved media file was found.');
604
+ const verifyFile = verifier || (await import('./inspect-media.js')).verifySavedMedia;
605
+ try {
606
+ reporter?.status('Verifying saved media…');
607
+ for (const file of media) await verifyFile(file, { audio: Boolean(options.audio), signal });
608
+ reporter?.status('Media verification passed.');
609
+ } catch (error) {
610
+ error.files = files;
611
+ error.verificationFailed = true;
612
+ throw error;
613
+ }
614
+ }
615
+ if (!options.incognito) await writeJson(historyFile, { version: 1, source: sourceKey(metadata, options.url), settings: downloadSettings(options), files });
616
+ return { url: options.url, title: metadata.title || metadata.id || 'video', files, status: 'saved', saved: 1, skipped: 0,
617
+ ...(options.verify ? { verified: true } : {}), ...timingResult() };
567
618
  } catch (error) {
568
619
  if (!signal?.aborted) reporter?.failStep?.();
569
620
  // A kept staging directory is the whole point of --resume, and cancellation
570
621
  // is the most common reason to want one.
571
- keepPartial = Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
622
+ keepPartial = !options.incognito && !error.verificationFailed && Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
572
623
  if (manifest && unconfirmedOutput) manifest.unconfirmedOutput = true;
573
- if (manifest?.ready?.length || (unconfirmedOutput && !options.resume)) {
624
+ if (keepPartial && (manifest?.ready?.length || (unconfirmedOutput && !options.resume))) {
574
625
  manifest.expiresAt = Date.now() + TRANSFER_RETENTION_MS;
575
626
  await writeJson(manifestFile, manifest);
576
627
  }
@@ -92,6 +92,17 @@ export async function inspectMedia(file, { checkAudio = false, signal, runner =
92
92
  return result;
93
93
  }
94
94
 
95
+ /** A fast post-download container/stream check; it does not decode the full file. */
96
+ export async function verifySavedMedia(file, { audio = false, signal, inspect = inspectMedia } = {}) {
97
+ let report;
98
+ try { report = await inspect(file, { signal }); }
99
+ catch (error) { throw new Error(`Verification failed for ${file}: ${error.message}`); }
100
+ if (audio ? !report.hasAudioTrack : !report.hasVideoTrack) {
101
+ throw new Error(`Verification failed for ${file}: no ${audio ? 'audio' : 'video'} track found.`);
102
+ }
103
+ return report;
104
+ }
105
+
95
106
  export async function inspectRun(id, { checkAudio = false, searchRoot, signal, root, inspect = inspectMedia } = {}) {
96
107
  if (!RUN_ARCHIVE_ID.test(id)) throw new Error('Run ids have 6 letters or digits. List active runs with veo runs --json or finished attempts with veo history --json.');
97
108
  const archive = await readRunArchive(id, root);
@@ -104,10 +104,15 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
104
104
  if (type === 'video' && ['mp3', 'm4a', 'aac', 'opus', 'flac', 'wav'].includes(defaults.format)) args.push('--format', 'mp4');
105
105
  if (type === 'audio' && ['mp4', 'mkv', 'webm', 'mov'].includes(defaults.format)) args.push('--format', 'mp3');
106
106
  const directory = (await question(`Output directory [${defaults.output || process.cwd()}]: `)).trim() || defaults.output || process.cwd();
107
- args.push('-o', directory, '--resume');
108
- const skipDefault = defaults.skipExisting === false ? 'n' : 'y';
109
- const skip = await choose(`Skip previously downloaded videos? (y/n) [${skipDefault}]: `, ['y', 'n'], skipDefault);
110
- args.push(skip === 'y' ? '--skip-existing' : '--no-skip-existing');
107
+ args.push('-o', directory);
108
+ if (defaults.incognito) {
109
+ args.push('--no-resume', '--no-skip-existing');
110
+ } else {
111
+ args.push('--resume');
112
+ const skipDefault = defaults.skipExisting === false ? 'n' : 'y';
113
+ const skip = await choose(`Skip previously downloaded videos? (y/n) [${skipDefault}]: `, ['y', 'n'], skipDefault);
114
+ args.push(skip === 'y' ? '--skip-existing' : '--no-skip-existing');
115
+ }
111
116
  output.write(`Ready: ${type}, ${directory}\n`);
112
117
  if (await choose('Start download? (y/n) [y]: ', ['y', 'n'], 'y') === 'n') return null;
113
118
  return args;
package/src/jobs.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { runPool, serialQueue, reducedLimit, formatTimings } from './execution.js';
2
2
  import { styleText, terminalText } from './progress.js';
3
3
  import path from 'node:path';
4
- import { lstat, readdir } from 'node:fs/promises';
4
+ import { lstat, mkdtemp, readdir, rm } from 'node:fs/promises';
5
+ import os from 'node:os';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  import { cacheBase } from './paths.js';
7
8
  import { publicOptions, readJson, writeJson } from './state.js';
@@ -74,6 +75,13 @@ export async function latestFailedJob(root = cacheBase()) {
74
75
  }
75
76
 
76
77
  export async function runJob(options, { download, reporter, signal, openFile, stdout = process.stdout, stderr = process.stderr, jobFile, items, recordStats, recordHistory, runId, archiveRoot } = {}) {
78
+ if (options.incognito) {
79
+ runId = undefined;
80
+ recordStats = undefined;
81
+ recordHistory = undefined;
82
+ }
83
+ const concurrency = options.concurrentDownloads ?? 2;
84
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('Concurrent downloads must be between 1 and 4.');
77
85
  const file = jobFile || jobFilePath();
78
86
  const requests = items || options.urls.map(url => ({ ...options, url }));
79
87
  const job = { version: 1, ...(runId ? { runId } : {}), startedAt: new Date().toISOString(), options: publicOptions({ ...options, output: path.resolve(options.output) }),
@@ -81,17 +89,17 @@ export async function runJob(options, { download, reporter, signal, openFile, st
81
89
  // Each retry item can have its own playlist selection.
82
90
  job.items.forEach((item, index) => { item.options = publicOptions(requests[index]); });
83
91
  const { cleanupDownloadCache } = await import('./download-cache.js');
84
- await cleanupDownloadCache();
92
+ if (!options.incognito) await cleanupDownloadCache();
93
+ const privateRoot = options.incognito ? await mkdtemp(path.join(os.tmpdir(), 'veo-incognito-')) : null;
85
94
  const queue = serialQueue();
86
- const persist = () => queue(() => writeJson(file, job));
95
+ const persist = () => options.incognito ? Promise.resolve() : queue(() => writeJson(file, job));
87
96
  await persist();
88
97
  const activeTargets = new Map();
89
98
  const totals = { saved: 0, skipped: 0, failed: 0 };
90
99
  const adaptiveState = { divisor: 1 };
91
100
  const archiveTools = runId ? await import('./run-archive.js') : null;
92
- const concurrency = options.concurrentDownloads ?? 2;
93
- if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('Concurrent downloads must be between 1 and 4.');
94
- await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
101
+ try {
102
+ await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
95
103
  const targetKey = JSON.stringify([request.url, path.resolve(request.output)]);
96
104
  const previous = activeTargets.get(targetKey);
97
105
  let release;
@@ -133,7 +141,8 @@ export async function runJob(options, { download, reporter, signal, openFile, st
133
141
  try {
134
142
  item.status = 'running';
135
143
  await persist();
136
- const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true, onEntry: async entry => {
144
+ const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true,
145
+ ...(privateRoot ? { localRoot: privateRoot } : {}), onEntry: async entry => {
137
146
  await captureFiles(entry.files);
138
147
  item.entries.push(entry);
139
148
  await persist();
@@ -156,9 +165,10 @@ export async function runJob(options, { download, reporter, signal, openFile, st
156
165
  item.status = signal?.aborted ? 'cancelled' : 'failed';
157
166
  item.error = readableError(error);
158
167
  if (!signal?.aborted) failed++;
159
- item.files = item.entries.flatMap(entry => entry.files || []);
168
+ item.files = [...new Set([...item.entries.flatMap(entry => entry.files || []), ...(error.files || [])])];
160
169
  if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, profile: request.profile || null, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
161
170
  else stderr.write(styleText(stderr, terminalText(stderr, `veo: ${item.error} (${request.url})`), 'error', options.color !== false) + '\n');
171
+ if (error.verificationFailed && !options.json) for (const file of item.files) stderr.write(`Saved file retained: ${cleanText(file)}\n`);
162
172
  }
163
173
  if (recordStats) {
164
174
  try {
@@ -180,7 +190,10 @@ export async function runJob(options, { download, reporter, signal, openFile, st
180
190
  await persist();
181
191
  totals.saved += saved; totals.skipped += skipped; totals.failed += failed;
182
192
  } finally { release(); if (activeTargets.get(targetKey) === current) activeTargets.delete(targetKey); }
183
- }, { signal }).catch(error => { if (!signal?.aborted) throw error; });
193
+ }, { signal }).catch(error => { if (!signal?.aborted) throw error; });
194
+ } finally {
195
+ if (privateRoot) await rm(privateRoot, { recursive: true, force: true });
196
+ }
184
197
  const { saved, skipped, failed } = totals;
185
198
  if (runId) {
186
199
  try {
@@ -199,7 +212,7 @@ export async function runJob(options, { download, reporter, signal, openFile, st
199
212
  }
200
213
  if (Object.keys(totals).length) stderr.write(styleText(stderr, formatTimings(totals), 'muted', options.color !== false && !options.json) + '\n');
201
214
  }
202
- if (unfinished) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed ${runId || `"${file}"`}\n`);
215
+ if (unfinished && !options.incognito) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed ${runId || `"${file}"`}\n`);
203
216
  if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
204
217
  return signal?.aborted ? 130 : unfinished ? 1 : 0;
205
218
  }
package/src/naming.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { lstat, mkdir } from 'node:fs/promises';
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { sanitizeTitle } from './utils.js';
4
5
 
5
6
  const FIELDS = new Set(['title', 'id', 'channel', 'year', 'playlist', 'index']);
@@ -17,6 +18,9 @@ export function validateTemplate(template, { folders = false } = {}) {
17
18
  }
18
19
 
19
20
  export function mediaDestination(options, metadata, fallbackTitle) {
21
+ if (options.neutralFilename) {
22
+ return { directory: path.resolve(options.output), title: `video-${randomBytes(6).toString('hex')}` };
23
+ }
20
24
  const fields = {
21
25
  title: metadata.title || metadata.id || 'video', id: metadata.id || 'unknown',
22
26
  channel: metadata.channel || metadata.uploader || 'Unknown channel',