veodl 1.9.1 → 1.10.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,24 @@
2
2
 
3
3
  All notable changes to veo. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 1.10.0
6
+
7
+ ### Added
8
+
9
+ - **Persistent Profile Management**: Added `veo profile [NAME|list|reset]` commands to view the active configuration profile, inspect available profiles, switch defaults, or reset back to default without manual configuration edits.
10
+ - **On-demand Config Guide**: Added `veo config guide` to display full configuration guidance, supported options, and templates in the terminal without opening the interactive editor.
11
+ - **Interactive Config Editor enhancements**:
12
+ - Full Undo (`Ctrl+Z`) and Redo (`Ctrl+Y`) history support when modifying settings.
13
+ - Select All (`Ctrl+A`) shortcut to easily highlight and overwrite lines or entire sections.
14
+ - Interactive F3 guide management prompt (`A` to add/refresh, `R` to strip documentation lines).
15
+ - Mouse edge drag auto-scrolling when dragging selections past viewport bounds.
16
+ - **Automatic Browser Source Discovery**: Enabled automatic source discovery fallback by default when direct extraction fails, automatically selecting and proceeding if a single verified media stream is found.
17
+
18
+ ### Changed
19
+
20
+ - Streamlined source discovery observation polling to immediately proceed once candidate streams are detected.
21
+ - Enhanced progress output styling and ANSI handling across terminal status reporting.
22
+
5
23
  ## 1.9.1
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -193,9 +193,11 @@ example through `veo config edit`. Enable it with `veo URL --profile kompatibel`
193
193
  reserved by concurrent downloads in the same process. Unknown sizes are reported as
194
194
  unknown; conversion sizes and other processes' disk usage cannot be predicted exactly.
195
195
  Use `--no-check-space` to disable this conservative check.
196
- - `--timings` shows elapsed time for setup, metadata, download/backend, processing,
197
- saving and retry waits. Download/backend includes the backend's own startup and
198
- extraction overhead; these are elapsed timings, not a diagnosis of network speed.
196
+ - `--timings` shows phase times at the end of the run. Single downloads show
197
+ timing without a summary; multiple downloads show timing below the summary.
198
+ The phases cover setup, metadata, download/backend, processing, saving and retry waits.
199
+ Download/backend includes the backend's own startup and extraction overhead;
200
+ these are summed per-download times, not a diagnosis of network speed.
199
201
  JSON results include `timings` in milliseconds, or `entryTimings` for playlists.
200
202
  Use `--no-timings` to hide/omit them.
201
203
  - Terminal details are gray, titles bold, saves green and errors red. The same styling
@@ -230,7 +232,7 @@ veo config show --profile fast
230
232
  Config checks validate every effective profile (or the selected one), including unknown
231
233
  settings, ranges and conflicting options. Show prints merged built-in, global and profile
232
234
  settings as JSON, with credential paths/browser profiles redacted. Neither command contacts
233
- video sites. All new options are also documented as commented examples in `veo config edit`.
235
+ video sites. All new options are also documented in `veo config guide`.
234
236
 
235
237
  ### Files, names and resume
236
238
 
@@ -322,11 +324,12 @@ download fails because a login is required, the error message points at these fl
322
324
  is reached, verified sources found so far are shown with a timeout notice.
323
325
  A deep scan covers observed requests; it cannot discover sources that the
324
326
  page never loads. The timeout does not stop a subsequent download.
325
- - `veo <page-url> --source 2 --dry-run --json` previews source 2;
326
- `veo <page-url> --source 2 --json` downloads it. For an unsupported single page
327
- URL in a terminal, veo asks whether to search for sources. Answer `y` to see
328
- the numbered list and choose one; `n` or Enter keeps the original failure.
329
- Scripts must pass `--source <n>`; they never receive an interactive prompt.
327
+ - For an unsupported single page URL, veo searches for media automatically and
328
+ uses the only verified source when there is one. If several sources are found,
329
+ the terminal asks which one to use; scripts receive a numbered source list and
330
+ a nonzero exit. `veo <page-url> --source 2 --dry-run --json` previews a chosen
331
+ source, and `veo <page-url> --source 2 --json` downloads it. Scripts never
332
+ receive an interactive prompt.
330
333
  Retry jobs retain the page URL and selected number and discover a fresh media
331
334
  URL, because player links may expire. Browser discovery uses a temporary
332
335
  profile without copying browser cookies, and cannot unlock DRM content.
@@ -391,10 +394,11 @@ silently ignoring a typo would be worse.
391
394
  Source discovery defaults are `deepScan` (boolean), `timeout` (string such as
392
395
  `"30s"` or `"2m"`), `listSources` (boolean) and `autoListSources` (boolean).
393
396
  `listSources: true` makes a single URL list sources and exit; use
394
- `--no-list-sources` to download for one invocation. `autoListSources: true`
395
- starts a source search when ordinary extraction finds no downloadable video,
396
- without the yes/no question. It still requires a source number before a
397
- download; scripts receive the source list and a nonzero exit. `deepScan` and
397
+ `--no-list-sources` to download for one invocation. `autoListSources` defaults
398
+ to `true` and searches when ordinary extraction finds no downloadable video.
399
+ Set it to `false` or pass `--no-auto-list-sources` to disable that fallback.
400
+ A single verified source is selected automatically; multiple sources require
401
+ a choice. `deepScan` and
398
402
  `timeout` apply to either kind of source search.
399
403
 
400
404
  Additional defaults are `skipExisting` and `playlistItems`. Boolean defaults can be disabled
@@ -403,11 +407,16 @@ disables stored subtitle languages and subtitle embedding for that invocation.
403
407
 
404
408
  ### Named profiles
405
409
 
406
- If no profile is selected, `profiles.default` is applied automatically. You can also select
407
- it explicitly with `--profile default`. Other named profiles use global defaults rather
408
- than inheriting `default`. The wizard preselects `default` when it exists.
409
- `veo config edit` adds an empty `default` profile to existing configurations if missing,
410
- preserving existing settings. An empty profile does not change download behavior.
410
+ If no profile is selected, `profiles.default` is applied automatically. `veo profile music`
411
+ selects another named profile for future commands and stores it as `activeProfile` in the
412
+ config; `veo profile` shows the current choice, and `veo profile list` marks it in
413
+ the list. `veo profile reset` removes the stored choice and returns to `default`.
414
+ You can also use `veo profile default` to select it explicitly.
415
+ The names `list` and `reset` are reserved for these commands and cannot be used as profile names.
416
+ `--profile NAME` overrides this choice for one command. Named profiles use global defaults
417
+ rather than inheriting `default`. The wizard preselects the active profile.
418
+ New configurations include an empty `default` profile and the example profiles. Existing configurations are
419
+ left unchanged when opened. An empty profile does not change download behavior.
411
420
 
412
421
  ```json
413
422
  {
@@ -420,13 +429,15 @@ preserving existing settings. An empty profile does not change download behavior
420
429
  }
421
430
  ```
422
431
 
423
- `veo <url> --profile music` merges global defaults, then the selected profile, then explicit
424
- CLI flags. `veo config profiles` lists profile names; `veo config path` prints the file path.
425
- `veo config edit` fills new or empty files with a commented template explaining common
426
- options and example profiles. When the template changes in a new veo version,
427
- `veo config edit` refreshes its marked reference guide in existing files. Active settings
428
- and comments outside the marked guide remain intact; put personal notes below the guide.
429
- New options are visible without resetting the config.
432
+ `veo <url> --profile music` merges global defaults, then the selected profile, then explicit
433
+ CLI flags. `veo config profiles` lists profile names; `veo config path` prints the file path.
434
+ The download status shows `Profile: NAME` (or `global (no profile)` when none is configured).
435
+ `veo config edit` creates a small, valid configuration for new or empty files. Opening an
436
+ existing configuration does not add or update comments. The current option guide is kept in
437
+ the program; `veo config guide` prints it without changing the file. In the built-in editor,
438
+ F3 offers **A** to add or update the guide at the top and **R** to remove a previously
439
+ generated guide. Ctrl+S saves that choice. This can also clean up the long duplicate block
440
+ inserted by older versions. Personal settings and notes below a generated block are preserved.
430
441
  In an interactive terminal, it opens the built-in editor
431
442
  unless `VISUAL` or `EDITOR` is configured (an executable path, without shell arguments).
432
443
  The editor provides syntax colors, line numbers and live validation. Syntax errors mark
@@ -435,11 +446,13 @@ spelling suggestions, expected types and allowed values in the status area. Vali
435
446
  the same rules as the CLI, including profile overrides and audio/video formats.
436
447
  Press F2 on a property or value to open suggestions, use Up/Down to choose, Enter to apply
437
448
  or Esc to cancel. Suggestions require valid JSON syntax; free-text values and custom numeric
438
- resolutions such as `900p` remain supported. Ctrl+S validates and saves;
439
- Esc or Ctrl+Q exits, asking before discarding changes. Use arrows, Home/End and PageUp/PageDown
449
+ resolutions such as `900p` remain supported. Ctrl+S validates and saves;
450
+ Ctrl+Z undoes edits, Ctrl+Y redoes them, and Ctrl+A selects the whole file.
451
+ Esc or Ctrl+Q exits, asking before discarding changes. Use arrows, Home/End and PageUp/PageDown
440
452
  to navigate. Invalid configurations cannot be saved. `--no-color` disables syntax colors.
441
453
  In terminals supporting SGR mouse reporting, left-click positions the cursor and dragging
442
- selects text, including across lines. Backspace/Delete removes the selection; typing replaces
454
+ selects text, including across lines. Holding the pointer at the top or bottom while dragging
455
+ scrolls the file and extends the selection. Backspace/Delete removes the selection; typing replaces
443
456
  it. The mouse wheel scrolls without changing the editing position; Ctrl+Up/Down also scrolls.
444
457
  Ctrl+C copies selected text to the system clipboard, and Ctrl+V pastes clipboard text at the
445
458
  cursor or replaces the selection. Ctrl+Q or Esc exits the editor. The editor disables mouse
@@ -464,9 +477,8 @@ $env:VEO_CONFIG_EDITOR = 'external'
464
477
 
465
478
  Non-interactive sessions use the external editor unless `--terminal` is explicitly selected,
466
479
  in which case an interactive-terminal error is reported. The wizard also offers configured profiles.
467
- Generated templates and app messages are in English. Previously generated German template
468
- comments are translated the next time you run `veo config edit`; existing profile names,
469
- paths and custom comments are preserved.
480
+ Generated guides and app messages are in English. Existing comments, profile names and
481
+ settings are preserved when opening the editor.
470
482
 
471
483
  ### Terminal output
472
484
 
@@ -502,9 +514,14 @@ paths and custom comments are preserved.
502
514
  ```bash
503
515
  veo version # installed version (also: veo --version or veo -v)
504
516
  veo # interactive download wizard (terminal only)
505
- veo config edit # create/open config, including example profiles
517
+ veo config edit # create/open config; F3 offers the optional guide
518
+ veo config guide # print the current option guide
506
519
  veo config reset # confirm with y; back up config and restore the current template
507
- veo config profiles # list available profiles
520
+ veo config profiles # list available profiles
521
+ veo profile # show the currently selected profile
522
+ veo profile list # list all profiles and mark the active one
523
+ veo profile music # use music as the default for future commands
524
+ veo profile reset # return to default (or global settings if absent)
508
525
  veo config path # show the config file location
509
526
  veo doctor # diagnose the local setup; exit 1 if a check fails
510
527
  veo doctor fix # restore missing or damaged managed tools
@@ -10,10 +10,12 @@ veo "https://example.com/video" --json --output ./downloads
10
10
  veo inspect "./downloads/video.mp4" --check-audio --json
11
11
  ```
12
12
 
13
- For a page with an embedded player that the normal extractor cannot identify,
14
- run `veo "<page-url>" --list-sources --json` and choose an `index` from the
15
- returned `sources`. Then use `veo "<page-url>" --source <index> --dry-run --json`
16
- before the download. Source discovery opens a temporary headless Chromium-based
13
+ For a page with an embedded player that the normal extractor cannot identify,
14
+ `veo "<page-url>" --dry-run --json` searches for playable media automatically.
15
+ It uses a single verified source without a second command. If several sources
16
+ are found, run `veo "<page-url>" --list-sources --json` and choose an `index`,
17
+ then use `veo "<page-url>" --source <index> --dry-run --json` before downloading.
18
+ Source discovery opens a temporary headless Chromium-based
17
19
  browser, repeatedly checks for Play controls, and observes media requests after
18
20
  interaction. It needs Chrome, Edge or Chromium,
19
21
  or `VEO_BROWSER_PATH`. A found source is verified by the backend; size may be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veodl",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/backend.js CHANGED
@@ -291,11 +291,19 @@ async function downloadBackend({ asset, release, expected, directory, signal, st
291
291
  }
292
292
  }
293
293
 
294
- async function acquire(asset, directory, signal, status) {
294
+ async function acquire(asset, directory, signal, status, statusDone = () => {}) {
295
295
  const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
296
296
  const expected = HASHES[asset];
297
297
  status('Checking yt-dlp…');
298
- if (await matches(destination, expected, signal)) {
298
+ let cached;
299
+ try {
300
+ cached = await matches(destination, expected, signal);
301
+ statusDone('Checking yt-dlp…', 'done');
302
+ } catch (error) {
303
+ if (!signal?.aborted && error?.name !== 'AbortError') statusDone('Checking yt-dlp…', 'failed');
304
+ throw error;
305
+ }
306
+ if (cached) {
299
307
  if (process.platform !== 'win32') await chmod(destination, 0o755);
300
308
  return destination;
301
309
  }
@@ -330,7 +338,6 @@ async function managedMediaTools({ signal, status, directory }) {
330
338
  } catch (cause) {
331
339
  return `the bundled static media tools are unusable (${cause.message})`;
332
340
  }
333
- status('Preparing ffmpeg and ffprobe…');
334
341
  const suffix = exeSuffix();
335
342
  await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
336
343
  await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
@@ -343,38 +350,58 @@ async function managedMediaTools({ signal, status, directory }) {
343
350
  * then a system installation (which is what Windows on ARM needs, because
344
351
  * ffmpeg-static and ffprobe-static ship no arm64 Windows binaries).
345
352
  */
346
- export async function resolveMediaTools({ signal, status = () => {}, directory, offline = false,
353
+ export async function resolveMediaTools({ signal, status = () => {}, statusDone = () => {}, directory, offline = false,
347
354
  find = findOnPath, managed = managedMediaTools, install = installMediaTools } = {}) {
348
- const suffix = exeSuffix();
349
- const override = envPath('VEO_FFMPEG_PATH');
350
- if (override) {
351
- await executable(path.join(override, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
352
- await executable(path.join(override, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
353
- status('Using VEO_FFMPEG_PATH override.');
354
- return override;
355
- }
356
- const problem = await managed({ signal, status, directory });
357
- if (!problem) return directory;
358
- // A previous run may already have staged a working pair into the cache.
359
- if (await isExecutable(path.join(directory, `ffmpeg${suffix}`))
360
- && await isExecutable(path.join(directory, `ffprobe${suffix}`))) return directory;
361
- const ffmpeg = await find(['ffmpeg']);
362
- const ffprobe = await find(['ffprobe']);
363
- if (!ffmpeg || !ffprobe) {
364
- if (!offline) return install({ signal, status, directory, find, stage });
365
- const missing = [!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ');
366
- throw new Error(`Cannot load media tools: ${problem}, and no ${missing} was found on PATH. `
367
- + 'Run veo doctor fix without --offline to install them automatically, or set VEO_FFMPEG_PATH to a directory containing both binaries.');
368
- }
369
- // Same directory: yt-dlp can use it in place instead of duplicating ~150 MB.
370
- if (path.dirname(ffmpeg) === path.dirname(ffprobe)) {
371
- status(`Using the system ffmpeg in ${path.dirname(ffmpeg)}.`);
372
- return path.dirname(ffmpeg);
355
+ const preparing = 'Preparing ffmpeg and ffprobe…';
356
+ let currentStatus;
357
+ const report = message => { currentStatus = message; status(message); };
358
+ const finish = outcome => {
359
+ if (currentStatus !== preparing) report(preparing);
360
+ statusDone(preparing, outcome);
361
+ };
362
+ report(preparing);
363
+ try {
364
+ const suffix = exeSuffix();
365
+ const override = envPath('VEO_FFMPEG_PATH');
366
+ if (override) {
367
+ await executable(path.join(override, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
368
+ await executable(path.join(override, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
369
+ finish('done');
370
+ report('Using VEO_FFMPEG_PATH override.');
371
+ return override;
372
+ }
373
+ const problem = await managed({ signal, status: report, directory });
374
+ if (!problem) { finish('done'); return directory; }
375
+ // A previous run may already have staged a working pair into the cache.
376
+ if (await isExecutable(path.join(directory, `ffmpeg${suffix}`))
377
+ && await isExecutable(path.join(directory, `ffprobe${suffix}`))) { finish('done'); return directory; }
378
+ const ffmpeg = await find(['ffmpeg']);
379
+ const ffprobe = await find(['ffprobe']);
380
+ if (!ffmpeg || !ffprobe) {
381
+ if (!offline) {
382
+ const location = await install({ signal, status: report, directory, find, stage });
383
+ finish('done');
384
+ return location;
385
+ }
386
+ const missing = [!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ');
387
+ throw new Error(`Cannot load media tools: ${problem}, and no ${missing} was found on PATH. `
388
+ + 'Run veo doctor fix without --offline to install them automatically, or set VEO_FFMPEG_PATH to a directory containing both binaries.');
389
+ }
390
+ // Same directory: yt-dlp can use it in place instead of duplicating ~150 MB.
391
+ if (path.dirname(ffmpeg) === path.dirname(ffprobe)) {
392
+ finish('done');
393
+ report(`Using the system ffmpeg in ${path.dirname(ffmpeg)}.`);
394
+ return path.dirname(ffmpeg);
395
+ }
396
+ report('Copying system ffmpeg and ffprobe into the backend cache…');
397
+ await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
398
+ await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
399
+ finish('done');
400
+ return directory;
401
+ } catch (error) {
402
+ if (!signal?.aborted && error?.name !== 'AbortError') finish('failed');
403
+ throw error;
373
404
  }
374
- status('Copying system ffmpeg and ffprobe into the backend cache…');
375
- await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
376
- await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
377
- return directory;
378
405
  }
379
406
 
380
407
  /**
@@ -457,12 +484,13 @@ export async function inspectBackend({ signal, platform = process.platform, arch
457
484
  * cannot serve the current platform (notably Windows on ARM).
458
485
  * onStatus receives plain strings. Throws on cancellation or acquisition failure.
459
486
  */
460
- export async function resolveBackend({ signal, onStatus, offline = false, platform = process.platform, find = findOnPath,
487
+ export async function resolveBackend({ signal, onStatus, onStatusDone, offline = false, platform = process.platform, find = findOnPath,
461
488
  setupTermux = installTermuxTools, hasEjs = hasTermuxEjs, run = runSetup } = {}) {
462
489
  if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal.');
463
490
  if (onStatus !== undefined && typeof onStatus !== 'function') throw new TypeError('onStatus must be a function.');
464
491
  signal?.throwIfAborted();
465
492
  const status = onStatus || (() => {});
493
+ const statusDone = onStatusDone || (() => {});
466
494
  const ytOverride = envPath('VEO_YT_DLP_PATH');
467
495
  if (platform === 'android') {
468
496
  const mediaOverride = envPath('VEO_FFMPEG_PATH');
@@ -508,13 +536,13 @@ export async function resolveBackend({ signal, onStatus, offline = false, platfo
508
536
  const installed = ytDlp ? null : await activeOverride(asset, { signal });
509
537
  const directory = cacheDirectory();
510
538
  await mkdir(directory, { recursive: true, mode: 0o700 });
511
- const ffmpegLocation = await resolveMediaTools({ signal, status, directory, offline });
539
+ const ffmpegLocation = await resolveMediaTools({ signal, status, statusDone, directory, offline });
512
540
  if (ytDlp) status('Using VEO_YT_DLP_PATH override.');
513
541
  else if (installed) status(`Using the installed yt-dlp ${installed.release}.`);
514
542
  if (offline && !ytDlp && !installed && !await matches(path.join(directory, `yt-dlp${exeSuffix()}`), HASHES[asset], signal)) {
515
543
  throw new Error('yt-dlp is missing or damaged. Run veo doctor fix without --offline to download it.');
516
544
  }
517
- const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status);
545
+ const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status, statusDone);
518
546
  signal?.throwIfAborted();
519
547
  return { ytDlp: backend, ffmpegLocation };
520
548
  }
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { createInterface } from 'node:readline/promises';
7
7
  import { createReporter } from './progress.js';
8
8
  import { openFile } from './open-file.js';
9
9
  import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
10
- import { applyProfile, configMain, loadConfig } from './config.js';
10
+ import { applyProfile, configMain, loadConfig, profileMain, selectedProfileName } from './config.js';
11
11
  import { validateItems, describeEstimate } from './playlist.js';
12
12
  import { parseSourceTimeout } from './source-discovery.js';
13
13
  import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
@@ -58,7 +58,7 @@ Options:
58
58
  --resume Keep partial data and continue an interrupted download
59
59
  --list-formats Show the available formats and exit
60
60
  --list-sources Find video sources loaded by a web page and exit
61
- --auto-list-sources Search automatically after no downloadable video is found
61
+ --auto-list-sources Search after no video is found (default: on)
62
62
  --source <n> Select source number from that page (also for scripts)
63
63
  --deep-scan Check every media candidate found during source search
64
64
  --timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
@@ -89,7 +89,8 @@ Commands:
89
89
  veo alias list|add|remove Manage extra command names (wrappers calling veo)
90
90
  veo uninstall [-p <name>] Remove one alias, or everything with --yes
91
91
  veo version Show the installed version
92
- veo config edit|path|profiles|check|show|reset Manage defaults and named profiles
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
93
94
  veo config edit [--external|--terminal] Choose the configuration editor
94
95
 
95
96
  Run veo without arguments in a terminal for interactive setup.
@@ -198,7 +199,7 @@ export function optionDefaults(config = {}) {
198
199
  timeout: config.timeout,
199
200
  'deep-scan': config.deepScan ?? false,
200
201
  'list-sources': config.listSources ?? false,
201
- 'auto-list-sources': config.autoListSources ?? false,
202
+ 'auto-list-sources': config.autoListSources ?? true,
202
203
  };
203
204
  }
204
205
 
@@ -226,6 +227,7 @@ const COMMAND_FLAGS = {
226
227
  alias: ['--json', '--force', '--bin-dir'],
227
228
  uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
228
229
  config: ['--profile', '--external', '--terminal'],
230
+ profile: [],
229
231
  };
230
232
  const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
231
233
 
@@ -259,6 +261,7 @@ export function parseCli(args, { config = {} } = {}) {
259
261
  let preliminary;
260
262
  try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
261
263
  catch (error) { throw explainUnknownOption(error, cliOptions()); }
264
+ const profileName = selectedProfileName(config, preliminary.values.profile);
262
265
  config = applyProfile(config, preliminary.values.profile);
263
266
  let parsed;
264
267
  try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
@@ -340,12 +343,13 @@ export function parseCli(args, { config = {} } = {}) {
340
343
  if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
341
344
  const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
342
345
  if (options.subLangs && !disableSubs) options.subs = true;
343
- if (disableSubs) {
344
- options.subLangs = undefined;
345
- options.embedSubs = false;
346
- }
347
- return options;
348
- }
346
+ if (disableSubs) {
347
+ options.subLangs = undefined;
348
+ options.embedSubs = false;
349
+ }
350
+ options.profile = profileName;
351
+ return options;
352
+ }
349
353
 
350
354
  export async function main(args = process.argv.slice(2), { config } = {}) {
351
355
  if (args[0] === 'help') args = ['--help', ...args.slice(1)];
@@ -417,7 +421,7 @@ async function runMain(args, { config }) {
417
421
  try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
418
422
  catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
419
423
  }
420
- if (args[0] === 'retry') {
424
+ if (args[0] === 'retry') {
421
425
  if (args[1] !== '--last') {
422
426
  stderr.write('veo: Usage: veo retry --last [download options]\n');
423
427
  return 1;
@@ -426,8 +430,12 @@ async function runMain(args, { config }) {
426
430
  const { latestFailedJob } = await import('./jobs.js');
427
431
  args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
428
432
  } catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
429
- }
430
- const { cleanupDownloadCache } = await import('./download-cache.js');
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');
431
439
  await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
432
440
  // update/upgrade/up/check subcommands are handled before URL validation.
433
441
  if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
@@ -438,10 +446,10 @@ async function runMain(args, { config }) {
438
446
  }
439
447
  return updateMain(args, { registry: defaultRegistry() });
440
448
  }
441
- if (args[0] === 'config') {
442
- try { return await configMain(args.slice(1)); }
443
- catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
444
- }
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
+ }
445
453
  if (args[0] === 'doctor') {
446
454
  try {
447
455
  const { doctorMain } = await import('./doctor.js');
@@ -507,7 +515,14 @@ async function runMain(args, { config }) {
507
515
  retryItems = await retryOptions(options.retryFailed);
508
516
  // Revalidate stored options and apply only flags explicitly supplied now.
509
517
  const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
510
- retryItems = retryItems.map(item => parseCli([item.url, ...retryArgs], { config: { ...item, profiles: loaded.config?.profiles } }));
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
+ });
511
526
  options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
512
527
  }
513
528
  if (options.batchFile) {
@@ -521,17 +536,22 @@ async function runMain(args, { config }) {
521
536
  // The job file is created before the first download, so another terminal can
522
537
  // follow this run's per-item progress with `veo runs <id>`.
523
538
  const jobFile = options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
524
- await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
525
- audio: options.audio, quality: options.quality, format: options.format, playlist: options.playlist, job: jobFile });
526
- reporter.configure?.({ color: options.color && !options.json });
527
- reporter.start(options.rename);
528
- const cookieWarning = cookieFileWarning(options.cookies);
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);
529
548
  if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
530
549
 
531
550
  // For a single page, fall back to media requests observed while its player loads.
532
551
  // Explicit --source always rediscovers: signed media URLs can expire between runs.
533
552
  if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
534
- const discovery = await resolvePageSource(options, { signal: controller.signal, stderr });
553
+ const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
554
+ onBackend: backend => preparedBackends.set(options.url, backend) });
535
555
  if (options.listSources) {
536
556
  if (options.json) stdout.write(`${JSON.stringify({ url: options.url, status: 'sources', timedOut: Boolean(discovery.sources.timedOut), sources: discovery.sources.map(({ url, pageUrl, ...safe }) => safe) })}\n`);
537
557
  else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
@@ -554,20 +574,21 @@ async function runMain(args, { config }) {
554
574
  }
555
575
  if (retryItems) for (const item of retryItems) {
556
576
  if (!item.source) continue;
557
- const discovery = await resolvePageSource(item, { signal: controller.signal, stderr });
577
+ const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
578
+ onBackend: backend => preparedBackends.set(item.url, backend) });
558
579
  item.mediaUrl = discovery.mediaUrl;
559
580
  }
560
581
 
561
582
  if (options.listFormats) {
562
583
  const { listFormats } = await import('./downloader.js');
563
- stdout.write(await listFormats(options, { signal: controller.signal, reporter }));
584
+ stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
564
585
  return 0;
565
586
  }
566
587
  if (options.dryRun) {
567
588
  const { planDownload } = await import('./downloader.js');
568
589
  for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
569
590
  const { url } = request;
570
- const plan = await planDownload(request, { signal: controller.signal, reporter });
591
+ const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
571
592
  if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
572
593
  else {
573
594
  stdout.write(`URL: ${url}\n`);
@@ -581,7 +602,8 @@ async function runMain(args, { config }) {
581
602
  const { download } = await import('./downloader.js');
582
603
  const { createStatsRecorder } = await import('./stats.js');
583
604
  const { createHistoryRecorder } = await import('./history.js');
584
- const result = await runJob(options, { download, reporter, signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id, recordStats: createStatsRecorder(), recordHistory: createHistoryRecorder() });
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() });
585
607
  return result;
586
608
  } catch (error) {
587
609
  reporter.fail(controller.signal.aborted);
@@ -600,11 +622,12 @@ async function askSourceQuestion(prompt, signal) {
600
622
  finally { rl.close(); }
601
623
  }
602
624
 
603
- export async function resolvePageSource(options, { signal, stderr, inspect, discover, ask = askSourceQuestion,
604
- backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
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) }) {
605
627
  const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
606
628
  const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
607
- const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal });
629
+ const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
630
+ if (backend) onBackend?.(backend);
608
631
  const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
609
632
  let originalError;
610
633
  if (!options.source && !options.listSources) {
@@ -615,13 +638,10 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
615
638
  }
616
639
  return {};
617
640
  }
618
- catch (error) { originalError = error; }
619
- if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
620
- if (!interactive && !options.autoListSources) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
621
- if (interactive && !options.autoListSources) {
622
- const answer = (await ask('No downloadable video found. Search this page for other sources? (y/N): ', signal)).trim().toLowerCase();
623
- if (!['y', 'yes'].includes(answer)) throw originalError;
624
- }
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');
625
645
  }
626
646
  let sources;
627
647
  try {
@@ -636,16 +656,18 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
636
656
  }
637
657
  if (options.listSources) return { sources, formatSource };
638
658
  if (sources.timedOut) stderr.write(`veo: Source search timed out after ${options.timeout || (options.deepScan ? '120s' : '45s')}; showing verified sources found so far.\n`);
639
- if (!sources.length) {
640
- if (originalError) throw originalError;
641
- throw new Error('No downloadable video sources were found on this page.');
642
- }
643
- let index = Number(options.source);
644
- if (!index) {
645
- if (!interactive && options.autoListSources) return { sources, formatSource, needsSelection: true };
646
- for (const source of sources) stderr.write(`${formatSource(source)}\n`);
647
- if (!interactive) throw new Error('Select a source with --source <number>.');
648
- const answer = await ask('Source number (Enter to cancel): ', signal);
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);
649
671
  if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
650
672
  index = Number(answer.trim());
651
673
  }