veodl 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to veo. This project follows [Semantic Versioning](https://semver.org/).
4
4
 
5
+ ## 1.9.1
6
+
7
+ ### Added
8
+
9
+ - Added typo and spelling suggestions for misspelled CLI options and subcommands (e.g. `--deepscan` suggests `--deep-scan`, `veo histroy` suggests `veo history`).
10
+
11
+ ### Fixed
12
+
13
+ - Improved browser-based player source discovery to continuously detect delayed play controls and overlays inside frames.
14
+
5
15
  ## 1.9.0
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -307,9 +307,10 @@ download fails because a login is required, the error message points at these fl
307
307
 
308
308
  ### Inspection and scripting
309
309
 
310
- - `veo <page-url> --list-sources` opens the page in a temporary headless Chrome,
311
- Edge or Chromium profile, presses a clearly labeled Play button when present,
312
- and checks media requests loaded by the player. Use `--json` for numbered source
310
+ - `veo <page-url> --list-sources` opens the page in a temporary headless Chrome,
311
+ Edge or Chromium profile, repeatedly checks for visible Play controls (including
312
+ controls added later or inside frames), and watches media requests after interaction.
313
+ Use `--json` for numbered source
313
314
  metadata. Each result includes its title, host, HLS/DASH/direct type, known
314
315
  resolutions, estimated MiB size and Mbit/s bitrate when the source provides them. Signed media
315
316
  URLs are not printed. Set `VEO_BROWSER_PATH` if the browser is not found.
@@ -328,7 +329,9 @@ download fails because a login is required, the error message points at these fl
328
329
  Scripts must pass `--source <n>`; they never receive an interactive prompt.
329
330
  Retry jobs retain the page URL and selected number and discover a fresh media
330
331
  URL, because player links may expire. Browser discovery uses a temporary
331
- profile without copying browser cookies, and cannot unlock DRM content.
332
+ profile without copying browser cookies, and cannot unlock DRM content.
333
+ Misspelled flags and commands show a nearby valid spelling, for example
334
+ `--deepscan` suggests `--deep-scan`.
332
335
  - `--list-formats` prints the backend's own format table for one URL and exits.
333
336
  - `--dry-run` prints the title, the resolved quality and the destination path that would be
334
337
  used — without creating the output directory or downloading anything.
@@ -14,7 +14,8 @@ For a page with an embedded player that the normal extractor cannot identify,
14
14
  run `veo "<page-url>" --list-sources --json` and choose an `index` from the
15
15
  returned `sources`. Then use `veo "<page-url>" --source <index> --dry-run --json`
16
16
  before the download. Source discovery opens a temporary headless Chromium-based
17
- browser and observes media requests after Play. It needs Chrome, Edge or Chromium,
17
+ browser, repeatedly checks for Play controls, and observes media requests after
18
+ interaction. It needs Chrome, Edge or Chromium,
18
19
  or `VEO_BROWSER_PATH`. A found source is verified by the backend; size may be
19
20
  unknown, and DRM-protected media is excluded. Source numbers are tied to the
20
21
  page's current discovery run and may change later.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veodl",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "A clean, simple video downloader powered by yt-dlp.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -9,7 +9,8 @@ import { openFile } from './open-file.js';
9
9
  import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
10
10
  import { applyProfile, configMain, loadConfig } from './config.js';
11
11
  import { validateItems, describeEstimate } from './playlist.js';
12
- import { parseSourceTimeout } from './source-discovery.js';
12
+ import { parseSourceTimeout } from './source-discovery.js';
13
+ import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
13
14
  import { retryOptions, runJob, jobFilePath } from './jobs.js';
14
15
  import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText, validateCookieFile, validateBrowserSpec, cookieFileWarning } from './utils.js';
15
16
 
@@ -201,7 +202,7 @@ export function optionDefaults(config = {}) {
201
202
  };
202
203
  }
203
204
 
204
- export function cliOptions(config = {}) {
205
+ export function cliOptions(config = {}) {
205
206
  const defaults = optionDefaults(config);
206
207
  const options = {};
207
208
  for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
@@ -213,7 +214,41 @@ export function cliOptions(config = {}) {
213
214
  if (defaults[name] !== undefined) options[name].default = defaults[name];
214
215
  }
215
216
  return options;
216
- }
217
+ }
218
+
219
+ const COMMAND_FLAGS = {
220
+ stats: ['--json'], history: ['--json', '--limit', '--failed'],
221
+ runs: ['--json'], stop: [], flush: ['--stats'],
222
+ inspect: ['--json', '--check-audio', '--search'],
223
+ doctor: ['--offline', '--output', '-o'],
224
+ update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
225
+ backend: ['--check', '--keep-files'],
226
+ alias: ['--json', '--force', '--bin-dir'],
227
+ uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
228
+ config: ['--profile', '--external', '--terminal'],
229
+ };
230
+ const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
231
+
232
+ function mistypedCommandOption(args) {
233
+ const command = args[0];
234
+ if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
235
+ const suggestion = suggestOption(command, COMMAND_NAMES);
236
+ if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
237
+ }
238
+ const options = Object.hasOwn(COMMAND_FLAGS, command)
239
+ ? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
240
+ : command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
241
+ if (!options) return null; // Download flags are checked by parseCli itself.
242
+ const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
243
+ for (const arg of args.slice(start)) {
244
+ if (arg === '--') break;
245
+ const name = arg.split('=')[0];
246
+ if (!name.startsWith('-') || options.includes(name)) continue;
247
+ const suggestion = suggestOption(name, options);
248
+ if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
249
+ }
250
+ return null;
251
+ }
217
252
 
218
253
  export function parseCli(args, { config = {} } = {}) {
219
254
  if (args[0] === 'help') args = ['--help', ...args.slice(1)];
@@ -221,9 +256,14 @@ export function parseCli(args, { config = {} } = {}) {
221
256
  if (args.length !== 1) throw new Error('Usage: veo version');
222
257
  return { version: true };
223
258
  }
224
- const preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() });
225
- config = applyProfile(config, preliminary.values.profile);
226
- const { values, positionals, tokens } = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) });
259
+ let preliminary;
260
+ try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
261
+ catch (error) { throw explainUnknownOption(error, cliOptions()); }
262
+ config = applyProfile(config, preliminary.values.profile);
263
+ let parsed;
264
+ try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
265
+ catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
266
+ const { values, positionals, tokens } = parsed;
227
267
  if (values.help || values.version) return values;
228
268
  // A stored default conflicting with a flag typed right now is a user error;
229
269
  // a stored default merely ignored by another flag is not.
@@ -307,8 +347,10 @@ export function parseCli(args, { config = {} } = {}) {
307
347
  return options;
308
348
  }
309
349
 
310
- export async function main(args = process.argv.slice(2), { config } = {}) {
311
- if (args[0] === 'help') args = ['--help', ...args.slice(1)];
350
+ export async function main(args = process.argv.slice(2), { config } = {}) {
351
+ if (args[0] === 'help') args = ['--help', ...args.slice(1)];
352
+ const mistyped = mistypedCommandOption(args);
353
+ if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
312
354
  let color = !args.includes('--no-color') && !args.includes('--json');
313
355
  let display;
314
356
  try {
@@ -0,0 +1,43 @@
1
+ // Match misspelled flags without silently accepting them. Ignore punctuation
2
+ // differences first, then allow a small number of insertions or transpositions.
3
+ function editDistance(left, right) {
4
+ const rows = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
5
+ for (let i = 0; i <= left.length; i++) rows[i][0] = i;
6
+ for (let j = 0; j <= right.length; j++) rows[0][j] = j;
7
+ for (let i = 1; i <= left.length; i++) {
8
+ for (let j = 1; j <= right.length; j++) {
9
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1,
10
+ rows[i - 1][j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
11
+ if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
12
+ rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
13
+ }
14
+ }
15
+ }
16
+ return rows[left.length][right.length];
17
+ }
18
+
19
+ export function suggestOption(unknown, options) {
20
+ const normalized = value => value.toLowerCase().replace(/-/g, '');
21
+ const target = normalized(unknown);
22
+ const prefix = unknown.startsWith('--') ? '--' : unknown.startsWith('-') ? '-' : null;
23
+ const candidates = [...new Set(options)].filter(option => prefix ? option.startsWith(prefix) : !option.startsWith('-'));
24
+ const ranked = candidates.map(option => ({ option, distance: editDistance(target, normalized(option)) }))
25
+ .sort((a, b) => a.distance - b.distance || a.option.length - b.option.length);
26
+ const best = ranked[0];
27
+ return best && best.distance <= Math.min(3, Math.max(1, Math.floor(target.length / 3))) ? best.option : null;
28
+ }
29
+
30
+ export function optionSpellings(schema) {
31
+ return Object.entries(schema).flatMap(([name, details]) => [
32
+ `--${name}`, ...(details.short ? [`-${details.short}`] : []),
33
+ ...(details.type === 'boolean' ? [`--no-${name}`] : []),
34
+ ]);
35
+ }
36
+
37
+ export function explainUnknownOption(error, schema) {
38
+ if (error?.code !== 'ERR_PARSE_ARGS_UNKNOWN_OPTION') return error;
39
+ const unknown = /Unknown option '([^']+)'/.exec(error.message)?.[1];
40
+ if (!unknown) return error;
41
+ const suggestion = suggestOption(unknown, optionSpellings(schema));
42
+ return new Error(`Unknown option "${unknown}".${suggestion ? ` Did you mean "${suggestion}"?` : ' Run veo --help to see available options.'}`, { cause: error });
43
+ }
@@ -5,8 +5,44 @@ import path from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { cleanText, validateUrl } from './utils.js';
7
7
 
8
- const MEDIA_URL = /\.(?:m3u8|mpd|mp4|webm|mov)(?:[?#]|$)/i;
9
- const MEDIA_TYPE = /(?:mpegurl|dash\+xml|video\/(?:mp4|webm|quicktime))/i;
8
+ const MEDIA_URL = /\.(?:m3u8|mpd|mp4|webm|mov)(?:[?#]|$)/i;
9
+ const MEDIA_TYPE = /(?:mpegurl|dash\+xml|video\/(?:mp4|webm|quicktime))/i;
10
+ const PLAY_CONTROL = String.raw`(() => {
11
+ const visited = new WeakSet();
12
+ const state = globalThis[Symbol.for('veo.playControls')] ||= { visited, clicks: 0 };
13
+ const labelMatches = node => {
14
+ const label = [node.getAttribute('aria-label'), node.getAttribute('title'), node.getAttribute('data-testid'),
15
+ node.getAttribute('value'), node.textContent].find(value => value && value.trim())?.trim() || '';
16
+ return /^(?:play|watch|start|replay|abspielen|ansehen|wiedergabe)(?:\s+(?:video|movie|film|now))?$/i.test(label)
17
+ || /(?:^|[-_\s])(?:big[-_])?play(?:[-_\s]|$)/i.test(node.className?.baseVal || node.className || '');
18
+ };
19
+ const documents = [document];
20
+ for (let index = 0; index < documents.length && index < 16; index++) {
21
+ for (const frame of documents[index].querySelectorAll('iframe')) {
22
+ try { if (frame.contentDocument && !documents.includes(frame.contentDocument)) documents.push(frame.contentDocument); } catch {}
23
+ }
24
+ }
25
+ for (const doc of documents) {
26
+ const controls = doc.querySelectorAll('button,[role="button"],[aria-label],[title],[data-testid],input[type="button"],input[type="image"],[class*="play"],[class*="Play"]');
27
+ for (const node of controls) {
28
+ if (state.clicks >= 8) return false;
29
+ if (state.visited.has(node) || node.disabled || node.getAttribute('aria-disabled') === 'true' || !labelMatches(node)) continue;
30
+ const style = doc.defaultView.getComputedStyle(node);
31
+ if (style.display === 'none' || style.visibility === 'hidden' || style.pointerEvents === 'none' || !node.getClientRects().length) continue;
32
+ state.visited.add(node);
33
+ state.clicks++;
34
+ node.click();
35
+ return true;
36
+ }
37
+ for (const video of doc.querySelectorAll('video')) {
38
+ if (state.visited.has(video) || !video.paused) continue;
39
+ state.visited.add(video);
40
+ video.play().catch(() => {});
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ })()`;
10
46
 
11
47
  function abortable(promise, signal) {
12
48
  if (!signal) return promise;
@@ -149,19 +185,22 @@ export async function captureBrowserMedia(pageUrl, { signal, browserPaths = brow
149
185
  socket.addEventListener('open', resolve, { once: true });
150
186
  socket.addEventListener('error', reject, { once: true });
151
187
  }), signal);
152
- const cdp = createCdp(socket);
153
- const responses = new Map();
154
- const bodyReads = [];
188
+ const cdp = createCdp(socket);
189
+ const responses = new Map();
190
+ const bodyReads = [];
191
+ const sessions = new Set();
155
192
  const collect = (value, mime = '') => {
156
193
  if (!MEDIA_URL.test(value) && !MEDIA_TYPE.test(mime)) return;
157
194
  try { urls.add(validateUrl(value)); } catch {}
158
195
  };
159
196
  cdp.on(message => {
160
- if (message.method === 'Target.attachedToTarget') {
161
- const attached = message.params.sessionId;
162
- cdp.send('Network.enable', {}, attached).catch(() => {});
163
- return;
164
- }
197
+ if (message.method === 'Target.attachedToTarget') {
198
+ const attached = message.params.sessionId;
199
+ sessions.add(attached);
200
+ cdp.send('Network.enable', {}, attached).catch(() => {});
201
+ return;
202
+ }
203
+ if (message.method === 'Target.detachedFromTarget') sessions.delete(message.params.sessionId);
165
204
  if (message.method === 'Network.requestWillBeSent') collect(message.params.request?.url);
166
205
  if (message.method === 'Network.responseReceived') {
167
206
  const response = message.params.response;
@@ -182,19 +221,21 @@ export async function captureBrowserMedia(pageUrl, { signal, browserPaths = brow
182
221
  }
183
222
  });
184
223
  await abortable(cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }), signal);
185
- const target = await abortable(cdp.send('Target.createTarget', { url: 'about:blank' }), signal);
186
- const attached = await abortable(cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }), signal);
224
+ const target = await abortable(cdp.send('Target.createTarget', { url: 'about:blank' }), signal);
225
+ const attached = await abortable(cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true }), signal);
226
+ sessions.add(attached.sessionId);
187
227
  await abortable(cdp.send('Network.enable', {}, attached.sessionId), signal);
188
228
  await abortable(cdp.send('Page.enable', {}, attached.sessionId), signal);
189
- await abortable(cdp.send('Page.navigate', { url: pageUrl }, attached.sessionId), signal);
190
- await delay(Math.min(observeMs / 2, 4000), undefined, { signal });
191
- await abortable(cdp.send('Runtime.evaluate', { expression: `(() => {
192
- for (const video of document.querySelectorAll('video')) video.play().catch(() => {});
193
- const buttons = [...document.querySelectorAll('button,[role="button"]')];
194
- const play = buttons.find(node => /^(play|watch|abspielen|ansehen|start)(?:\\s+(?:video|movie|film))?$/i.test((node.getAttribute('aria-label') || node.textContent || '').trim()));
195
- if (play) play.click();
196
- })()`, returnByValue: true }, attached.sessionId).catch(() => {}), signal);
197
- await delay(Math.max(1000, observeMs / 2), undefined, { signal });
229
+ await abortable(cdp.send('Page.navigate', { url: pageUrl }, attached.sessionId), signal);
230
+ const observeUntil = Date.now() + observeMs;
231
+ while (Date.now() < observeUntil) {
232
+ signal?.throwIfAborted();
233
+ await Promise.allSettled([...sessions].map(sessionId => abortable(cdp.send('Runtime.evaluate', {
234
+ expression: PLAY_CONTROL, returnByValue: true,
235
+ }, sessionId), signal)));
236
+ const remaining = observeUntil - Date.now();
237
+ if (remaining > 0) await delay(Math.min(300, remaining), undefined, { signal });
238
+ }
198
239
  await Promise.race([Promise.allSettled(bodyReads), delay(1000, undefined, { signal }).catch(() => {})]);
199
240
  return [...urls];
200
241
  } catch (error) {