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 +10 -0
- package/README.md +7 -4
- package/docs/AGENT_GUIDE.md +2 -1
- package/package.json +1 -1
- package/src/cli.js +50 -8
- package/src/option-suggestions.js +43 -0
- package/src/source-discovery.js +62 -21
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,
|
|
312
|
-
|
|
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.
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
package/src/source-discovery.js
CHANGED
|
@@ -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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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) {
|