veodl 1.10.0 → 1.10.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/AGENTS.md +3 -3
- package/CHANGELOG.md +17 -0
- package/README.md +80 -62
- package/docs/AGENT_GUIDE.md +18 -9
- package/package.json +61 -58
- package/src/cli.js +154 -124
- package/src/config-diagnostics.js +20 -20
- package/src/config-editor.js +21 -17
- package/src/config-template.js +71 -66
- package/src/config.js +112 -110
- package/src/downloader.js +38 -28
- package/src/experimental-fragments.js +29 -0
- package/src/history.js +1 -1
- package/src/inline-region.js +72 -0
- package/src/jobs.js +29 -5
- package/src/output.js +77 -60
- package/src/path-links.js +53 -0
- package/src/progress.js +341 -208
- package/src/terminal-size.js +8 -0
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { outputOptions, outputStream, withOutputSettings } from './output.js';
|
|
2
|
+
import { validateFragments, confirmExperimentalFragments } from './experimental-fragments.js';
|
|
2
3
|
import { validateTemplate } from './naming.js';
|
|
3
4
|
import { parseArgs } from 'node:util';
|
|
4
5
|
import path from 'node:path';
|
|
@@ -7,10 +8,10 @@ import { createInterface } from 'node:readline/promises';
|
|
|
7
8
|
import { createReporter } from './progress.js';
|
|
8
9
|
import { openFile } from './open-file.js';
|
|
9
10
|
import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
|
|
10
|
-
import { applyProfile, configMain, loadConfig, profileMain, selectedProfileName } from './config.js';
|
|
11
|
+
import { applyProfile, configMain, loadConfig, profileMain, selectedProfileName } from './config.js';
|
|
11
12
|
import { validateItems, describeEstimate } from './playlist.js';
|
|
12
|
-
import { parseSourceTimeout } from './source-discovery.js';
|
|
13
|
-
import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
|
|
13
|
+
import { parseSourceTimeout } from './source-discovery.js';
|
|
14
|
+
import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
|
|
14
15
|
import { retryOptions, runJob, jobFilePath } from './jobs.js';
|
|
15
16
|
import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText, validateCookieFile, validateBrowserSpec, cookieFileWarning } from './utils.js';
|
|
16
17
|
|
|
@@ -43,7 +44,9 @@ Options:
|
|
|
43
44
|
--playlist-concurrency <n> Simultaneous playlist downloads (1-4; default: 2)
|
|
44
45
|
--playlist Download every entry of a playlist or channel URL
|
|
45
46
|
-N, --concurrent-fragments <n>
|
|
46
|
-
|
|
47
|
+
Parallel fragments (1-64; default: 8); 17-64 are experimental
|
|
48
|
+
--experimental-fragments Explicitly accept experimental values above 16
|
|
49
|
+
Otherwise requires terminal confirmation before download
|
|
47
50
|
--subs Download subtitles (default languages: en)
|
|
48
51
|
--sub-langs <langs> Subtitle languages, e.g. "de,en" (implies --subs)
|
|
49
52
|
--embed-subs Embed subtitles into the video file
|
|
@@ -56,9 +59,11 @@ Options:
|
|
|
56
59
|
--cookies-from-browser <browser[:profile]>
|
|
57
60
|
Read cookies from an installed browser
|
|
58
61
|
--resume Keep partial data and continue an interrupted download
|
|
62
|
+
--list-qualities Show available video resolutions and exit
|
|
63
|
+
--list-qualitys Alias for --list-qualities
|
|
59
64
|
--list-formats Show the available formats and exit
|
|
60
65
|
--list-sources Find video sources loaded by a web page and exit
|
|
61
|
-
--auto-list-sources Search after no video is found (default: on)
|
|
66
|
+
--auto-list-sources Search after no video is found (default: on)
|
|
62
67
|
--source <n> Select source number from that page (also for scripts)
|
|
63
68
|
--deep-scan Check every media candidate found during source search
|
|
64
69
|
--timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
|
|
@@ -66,7 +71,7 @@ Options:
|
|
|
66
71
|
--json Print one JSON object per URL instead of prose
|
|
67
72
|
--profile <name> Apply a named config profile
|
|
68
73
|
--batch-file <file> Read URLs from a file (one per line; # comments)
|
|
69
|
-
--retry-failed <file>
|
|
74
|
+
--retry-failed <id|file> Retry failed/unfinished items by run ID or job file
|
|
70
75
|
--playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
|
|
71
76
|
--skip-existing Skip matching downloads still present on disk
|
|
72
77
|
--no-<boolean-option> Disable a stored boolean default, e.g. --no-open
|
|
@@ -89,8 +94,8 @@ Commands:
|
|
|
89
94
|
veo alias list|add|remove Manage extra command names (wrappers calling veo)
|
|
90
95
|
veo uninstall [-p <name>] Remove one alias, or everything with --yes
|
|
91
96
|
veo version Show the installed version
|
|
92
|
-
veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
|
|
93
|
-
veo profile [list|reset|name] Show, list or change the default profile
|
|
97
|
+
veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
|
|
98
|
+
veo profile [list|reset|name] Show, list or change the default profile
|
|
94
99
|
veo config edit [--external|--terminal] Choose the configuration editor
|
|
95
100
|
|
|
96
101
|
Run veo without arguments in a terminal for interactive setup.
|
|
@@ -134,6 +139,7 @@ const STRING_OPTIONS = {
|
|
|
134
139
|
};
|
|
135
140
|
|
|
136
141
|
const BOOLEAN_OPTIONS = {
|
|
142
|
+
'experimental-fragments': {},
|
|
137
143
|
compatible: {},
|
|
138
144
|
recode: {},
|
|
139
145
|
'adaptive-concurrency': {},
|
|
@@ -149,6 +155,8 @@ const BOOLEAN_OPTIONS = {
|
|
|
149
155
|
'embed-metadata': {},
|
|
150
156
|
'embed-thumbnail': {},
|
|
151
157
|
'closest-quality': {},
|
|
158
|
+
'list-qualities': {},
|
|
159
|
+
'list-qualitys': {},
|
|
152
160
|
'list-formats': {},
|
|
153
161
|
'list-sources': {},
|
|
154
162
|
'auto-list-sources': {},
|
|
@@ -180,6 +188,7 @@ export function optionDefaults(config = {}) {
|
|
|
180
188
|
cookies: config.cookies,
|
|
181
189
|
'cookies-from-browser': config.cookiesFromBrowser,
|
|
182
190
|
'concurrent-fragments': String(config.concurrentFragments ?? 8),
|
|
191
|
+
'experimental-fragments': config.experimentalFragments ?? false,
|
|
183
192
|
'sub-langs': config.subLangs,
|
|
184
193
|
'sponsorblock-remove': config.sponsorblockRemove,
|
|
185
194
|
section: config.section,
|
|
@@ -199,11 +208,11 @@ export function optionDefaults(config = {}) {
|
|
|
199
208
|
timeout: config.timeout,
|
|
200
209
|
'deep-scan': config.deepScan ?? false,
|
|
201
210
|
'list-sources': config.listSources ?? false,
|
|
202
|
-
'auto-list-sources': config.autoListSources ?? true,
|
|
211
|
+
'auto-list-sources': config.autoListSources ?? true,
|
|
203
212
|
};
|
|
204
213
|
}
|
|
205
214
|
|
|
206
|
-
export function cliOptions(config = {}) {
|
|
215
|
+
export function cliOptions(config = {}) {
|
|
207
216
|
const defaults = optionDefaults(config);
|
|
208
217
|
const options = {};
|
|
209
218
|
for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
|
|
@@ -215,42 +224,42 @@ export function cliOptions(config = {}) {
|
|
|
215
224
|
if (defaults[name] !== undefined) options[name].default = defaults[name];
|
|
216
225
|
}
|
|
217
226
|
return options;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const COMMAND_FLAGS = {
|
|
221
|
-
stats: ['--json'], history: ['--json', '--limit', '--failed'],
|
|
222
|
-
runs: ['--json'], stop: [], flush: ['--stats'],
|
|
223
|
-
inspect: ['--json', '--check-audio', '--search'],
|
|
224
|
-
doctor: ['--offline', '--output', '-o'],
|
|
225
|
-
update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
|
|
226
|
-
backend: ['--check', '--keep-files'],
|
|
227
|
-
alias: ['--json', '--force', '--bin-dir'],
|
|
228
|
-
uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
|
|
229
|
-
config: ['--profile', '--external', '--terminal'],
|
|
230
|
-
profile: [],
|
|
231
|
-
};
|
|
232
|
-
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
|
|
233
|
-
|
|
234
|
-
function mistypedCommandOption(args) {
|
|
235
|
-
const command = args[0];
|
|
236
|
-
if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
|
|
237
|
-
const suggestion = suggestOption(command, COMMAND_NAMES);
|
|
238
|
-
if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
|
|
239
|
-
}
|
|
240
|
-
const options = Object.hasOwn(COMMAND_FLAGS, command)
|
|
241
|
-
? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
|
|
242
|
-
: command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
|
|
243
|
-
if (!options) return null; // Download flags are checked by parseCli itself.
|
|
244
|
-
const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
|
|
245
|
-
for (const arg of args.slice(start)) {
|
|
246
|
-
if (arg === '--') break;
|
|
247
|
-
const name = arg.split('=')[0];
|
|
248
|
-
if (!name.startsWith('-') || options.includes(name)) continue;
|
|
249
|
-
const suggestion = suggestOption(name, options);
|
|
250
|
-
if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
|
|
251
|
-
}
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const COMMAND_FLAGS = {
|
|
230
|
+
stats: ['--json'], history: ['--json', '--limit', '--failed'],
|
|
231
|
+
runs: ['--json'], stop: [], flush: ['--stats'],
|
|
232
|
+
inspect: ['--json', '--check-audio', '--search'],
|
|
233
|
+
doctor: ['--offline', '--output', '-o'],
|
|
234
|
+
update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
|
|
235
|
+
backend: ['--check', '--keep-files'],
|
|
236
|
+
alias: ['--json', '--force', '--bin-dir'],
|
|
237
|
+
uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
|
|
238
|
+
config: ['--profile', '--external', '--terminal'],
|
|
239
|
+
profile: [],
|
|
240
|
+
};
|
|
241
|
+
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'help', 'version'];
|
|
242
|
+
|
|
243
|
+
function mistypedCommandOption(args) {
|
|
244
|
+
const command = args[0];
|
|
245
|
+
if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
|
|
246
|
+
const suggestion = suggestOption(command, COMMAND_NAMES);
|
|
247
|
+
if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
|
|
248
|
+
}
|
|
249
|
+
const options = Object.hasOwn(COMMAND_FLAGS, command)
|
|
250
|
+
? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
|
|
251
|
+
: command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
|
|
252
|
+
if (!options) return null; // Download flags are checked by parseCli itself.
|
|
253
|
+
const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
|
|
254
|
+
for (const arg of args.slice(start)) {
|
|
255
|
+
if (arg === '--') break;
|
|
256
|
+
const name = arg.split('=')[0];
|
|
257
|
+
if (!name.startsWith('-') || options.includes(name)) continue;
|
|
258
|
+
const suggestion = suggestOption(name, options);
|
|
259
|
+
if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
254
263
|
|
|
255
264
|
export function parseCli(args, { config = {} } = {}) {
|
|
256
265
|
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
@@ -258,15 +267,15 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
258
267
|
if (args.length !== 1) throw new Error('Usage: veo version');
|
|
259
268
|
return { version: true };
|
|
260
269
|
}
|
|
261
|
-
let preliminary;
|
|
262
|
-
try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
|
|
263
|
-
catch (error) { throw explainUnknownOption(error, cliOptions()); }
|
|
264
|
-
const profileName = selectedProfileName(config, preliminary.values.profile);
|
|
265
|
-
config = applyProfile(config, preliminary.values.profile);
|
|
266
|
-
let parsed;
|
|
267
|
-
try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
|
|
268
|
-
catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
|
|
269
|
-
const { values, positionals, tokens } = parsed;
|
|
270
|
+
let preliminary;
|
|
271
|
+
try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
|
|
272
|
+
catch (error) { throw explainUnknownOption(error, cliOptions()); }
|
|
273
|
+
const profileName = selectedProfileName(config, preliminary.values.profile);
|
|
274
|
+
config = applyProfile(config, preliminary.values.profile);
|
|
275
|
+
let parsed;
|
|
276
|
+
try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
|
|
277
|
+
catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
|
|
278
|
+
const { values, positionals, tokens } = parsed;
|
|
270
279
|
if (values.help || values.version) return values;
|
|
271
280
|
// A stored default conflicting with a flag typed right now is a user error;
|
|
272
281
|
// a stored default merely ignored by another flag is not.
|
|
@@ -293,6 +302,13 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
293
302
|
if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
|
|
294
303
|
if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
|
|
295
304
|
if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
|
|
305
|
+
values['list-qualities'] = Boolean(values['list-qualities'] || values['list-qualitys']);
|
|
306
|
+
delete values['list-qualitys'];
|
|
307
|
+
if (values['list-qualities']) {
|
|
308
|
+
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.');
|
|
310
|
+
if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
|
|
311
|
+
}
|
|
296
312
|
if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
|
|
297
313
|
if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
298
314
|
if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
|
|
@@ -310,7 +326,7 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
310
326
|
if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
|
|
311
327
|
if (values['concurrent-fragments'] !== undefined) {
|
|
312
328
|
const fragments = Number(values['concurrent-fragments']);
|
|
313
|
-
|
|
329
|
+
validateFragments(fragments);
|
|
314
330
|
}
|
|
315
331
|
const concurrency = Number(values['playlist-concurrency']);
|
|
316
332
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
|
|
@@ -343,18 +359,18 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
343
359
|
if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
|
|
344
360
|
const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
|
|
345
361
|
if (options.subLangs && !disableSubs) options.subs = true;
|
|
346
|
-
if (disableSubs) {
|
|
347
|
-
options.subLangs = undefined;
|
|
348
|
-
options.embedSubs = false;
|
|
349
|
-
}
|
|
350
|
-
options.profile = profileName;
|
|
351
|
-
return options;
|
|
352
|
-
}
|
|
362
|
+
if (disableSubs) {
|
|
363
|
+
options.subLangs = undefined;
|
|
364
|
+
options.embedSubs = false;
|
|
365
|
+
}
|
|
366
|
+
options.profile = profileName;
|
|
367
|
+
return options;
|
|
368
|
+
}
|
|
353
369
|
|
|
354
|
-
export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
355
|
-
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
356
|
-
const mistyped = mistypedCommandOption(args);
|
|
357
|
-
if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
|
|
370
|
+
export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
371
|
+
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
372
|
+
const mistyped = mistypedCommandOption(args);
|
|
373
|
+
if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
|
|
358
374
|
let color = !args.includes('--no-color') && !args.includes('--json');
|
|
359
375
|
let display;
|
|
360
376
|
try {
|
|
@@ -421,7 +437,7 @@ async function runMain(args, { config }) {
|
|
|
421
437
|
try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
|
|
422
438
|
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
423
439
|
}
|
|
424
|
-
if (args[0] === 'retry') {
|
|
440
|
+
if (args[0] === 'retry') {
|
|
425
441
|
if (args[1] !== '--last') {
|
|
426
442
|
stderr.write('veo: Usage: veo retry --last [download options]\n');
|
|
427
443
|
return 1;
|
|
@@ -430,12 +446,12 @@ async function runMain(args, { config }) {
|
|
|
430
446
|
const { latestFailedJob } = await import('./jobs.js');
|
|
431
447
|
args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
|
|
432
448
|
} catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
433
|
-
}
|
|
434
|
-
if (args[0] === 'profile') {
|
|
435
|
-
try { return await profileMain(args.slice(1)); }
|
|
436
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
437
|
-
}
|
|
438
|
-
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
449
|
+
}
|
|
450
|
+
if (args[0] === 'profile') {
|
|
451
|
+
try { return await profileMain(args.slice(1)); }
|
|
452
|
+
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
453
|
+
}
|
|
454
|
+
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
439
455
|
await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
|
|
440
456
|
// update/upgrade/up/check subcommands are handled before URL validation.
|
|
441
457
|
if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
|
|
@@ -446,10 +462,10 @@ async function runMain(args, { config }) {
|
|
|
446
462
|
}
|
|
447
463
|
return updateMain(args, { registry: defaultRegistry() });
|
|
448
464
|
}
|
|
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
|
-
}
|
|
465
|
+
if (args[0] === 'config') {
|
|
466
|
+
try { return await configMain(args.slice(1)); }
|
|
467
|
+
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
468
|
+
}
|
|
453
469
|
if (args[0] === 'doctor') {
|
|
454
470
|
try {
|
|
455
471
|
const { doctorMain } = await import('./doctor.js');
|
|
@@ -515,14 +531,14 @@ async function runMain(args, { config }) {
|
|
|
515
531
|
retryItems = await retryOptions(options.retryFailed);
|
|
516
532
|
// Revalidate stored options and apply only flags explicitly supplied now.
|
|
517
533
|
const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
|
|
518
|
-
const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
|
|
519
|
-
retryItems = retryItems.map(item => {
|
|
520
|
-
const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
|
|
521
|
-
? { ...item, profiles: loaded.config?.profiles }
|
|
522
|
-
: item });
|
|
523
|
-
if (!overrideProfile) resumed.profile = item.profile;
|
|
524
|
-
return resumed;
|
|
525
|
-
});
|
|
534
|
+
const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
|
|
535
|
+
retryItems = retryItems.map(item => {
|
|
536
|
+
const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
|
|
537
|
+
? { ...item, profiles: loaded.config?.profiles }
|
|
538
|
+
: item });
|
|
539
|
+
if (!overrideProfile) resumed.profile = item.profile;
|
|
540
|
+
return resumed;
|
|
541
|
+
});
|
|
526
542
|
options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
|
|
527
543
|
}
|
|
528
544
|
if (options.batchFile) {
|
|
@@ -533,25 +549,26 @@ async function runMain(args, { config }) {
|
|
|
533
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.');
|
|
534
550
|
if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
535
551
|
}
|
|
552
|
+
await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
|
|
536
553
|
// The job file is created before the first download, so another terminal can
|
|
537
554
|
// follow this run's per-item progress with `veo runs <id>`.
|
|
538
|
-
const jobFile = options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
|
|
539
|
-
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
540
|
-
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
541
|
-
playlist: options.playlist, job: jobFile });
|
|
542
|
-
reporter.configure?.({ color: options.color && !options.json });
|
|
543
|
-
reporter.start(options.rename);
|
|
544
|
-
if (!options.dryRun && !options.listFormats && !options.listSources) reporter.profile(options.profile);
|
|
545
|
-
const preparedBackends = new Map();
|
|
546
|
-
const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
|
|
547
|
-
const cookieWarning = cookieFileWarning(options.cookies);
|
|
555
|
+
const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
|
|
556
|
+
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
557
|
+
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
558
|
+
playlist: options.playlist, job: jobFile });
|
|
559
|
+
reporter.configure?.({ color: options.color && !options.json });
|
|
560
|
+
reporter.start(options.rename);
|
|
561
|
+
if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources) reporter.profile(options.profile);
|
|
562
|
+
const preparedBackends = new Map();
|
|
563
|
+
const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
|
|
564
|
+
const cookieWarning = cookieFileWarning(options.cookies);
|
|
548
565
|
if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
|
|
549
566
|
|
|
550
567
|
// For a single page, fall back to media requests observed while its player loads.
|
|
551
568
|
// Explicit --source always rediscovers: signed media URLs can expire between runs.
|
|
552
569
|
if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
|
|
553
|
-
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
|
|
554
|
-
onBackend: backend => preparedBackends.set(options.url, backend) });
|
|
570
|
+
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
|
|
571
|
+
onBackend: backend => preparedBackends.set(options.url, backend) });
|
|
555
572
|
if (options.listSources) {
|
|
556
573
|
if (options.json) stdout.write(`${JSON.stringify({ url: options.url, status: 'sources', timedOut: Boolean(discovery.sources.timedOut), sources: discovery.sources.map(({ url, pageUrl, ...safe }) => safe) })}\n`);
|
|
557
574
|
else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
|
|
@@ -574,21 +591,31 @@ async function runMain(args, { config }) {
|
|
|
574
591
|
}
|
|
575
592
|
if (retryItems) for (const item of retryItems) {
|
|
576
593
|
if (!item.source) continue;
|
|
577
|
-
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
578
|
-
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
594
|
+
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
595
|
+
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
579
596
|
item.mediaUrl = discovery.mediaUrl;
|
|
580
597
|
}
|
|
581
598
|
|
|
599
|
+
if (options.listQualities) {
|
|
600
|
+
const { listQualities } = await import('./downloader.js');
|
|
601
|
+
const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
602
|
+
if (options.json) stdout.write(JSON.stringify(result) + '\n');
|
|
603
|
+
else {
|
|
604
|
+
stdout.write('Available qualities: ' + (result.qualities.join(', ') || 'resolution unknown') + '\n');
|
|
605
|
+
if (result.qualities.length) stdout.write('Choose with --quality ' + result.qualities[0] + '\n');
|
|
606
|
+
}
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
582
609
|
if (options.listFormats) {
|
|
583
610
|
const { listFormats } = await import('./downloader.js');
|
|
584
|
-
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
611
|
+
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
585
612
|
return 0;
|
|
586
613
|
}
|
|
587
614
|
if (options.dryRun) {
|
|
588
615
|
const { planDownload } = await import('./downloader.js');
|
|
589
616
|
for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
|
|
590
617
|
const { url } = request;
|
|
591
|
-
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
618
|
+
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
592
619
|
if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
|
|
593
620
|
else {
|
|
594
621
|
stdout.write(`URL: ${url}\n`);
|
|
@@ -602,14 +629,17 @@ async function runMain(args, { config }) {
|
|
|
602
629
|
const { download } = await import('./downloader.js');
|
|
603
630
|
const { createStatsRecorder } = await import('./stats.js');
|
|
604
631
|
const { createHistoryRecorder } = await import('./history.js');
|
|
605
|
-
|
|
606
|
-
|
|
632
|
+
if (!options.json && process.stdout.isTTY) reporter.enableInline();
|
|
633
|
+
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() });
|
|
607
635
|
return result;
|
|
608
636
|
} catch (error) {
|
|
609
637
|
reporter.fail(controller.signal.aborted);
|
|
638
|
+
reporter.dispose();
|
|
610
639
|
stderr.write(`veo: ${readableError(error)}\n`);
|
|
611
640
|
return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
|
|
612
641
|
} finally {
|
|
642
|
+
reporter.dispose();
|
|
613
643
|
await run?.unregister();
|
|
614
644
|
process.removeListener('SIGINT', cancel);
|
|
615
645
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -622,12 +652,12 @@ async function askSourceQuestion(prompt, signal) {
|
|
|
622
652
|
finally { rl.close(); }
|
|
623
653
|
}
|
|
624
654
|
|
|
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) }) {
|
|
655
|
+
export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
|
|
656
|
+
backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
|
|
627
657
|
const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
|
|
628
658
|
const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
|
|
629
|
-
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
|
|
630
|
-
if (backend) onBackend?.(backend);
|
|
659
|
+
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
|
|
660
|
+
if (backend) onBackend?.(backend);
|
|
631
661
|
const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
|
|
632
662
|
let originalError;
|
|
633
663
|
if (!options.source && !options.listSources) {
|
|
@@ -638,10 +668,10 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
|
|
|
638
668
|
}
|
|
639
669
|
return {};
|
|
640
670
|
}
|
|
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');
|
|
671
|
+
catch (error) { originalError = error; }
|
|
672
|
+
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
673
|
+
if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
|
|
674
|
+
stderr.write('veo: No direct video found; searching the page for a playable source…\n');
|
|
645
675
|
}
|
|
646
676
|
let sources;
|
|
647
677
|
try {
|
|
@@ -656,18 +686,18 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
|
|
|
656
686
|
}
|
|
657
687
|
if (options.listSources) return { sources, formatSource };
|
|
658
688
|
if (sources.timedOut) stderr.write(`veo: Source search timed out after ${options.timeout || (options.deepScan ? '120s' : '45s')}; showing verified sources found so far.\n`);
|
|
659
|
-
if (!sources.length) {
|
|
660
|
-
if (originalError) throw originalError;
|
|
661
|
-
throw new Error('No downloadable video sources were found on this page.');
|
|
662
|
-
}
|
|
663
|
-
let index = Number(options.source);
|
|
664
|
-
if (!index) {
|
|
665
|
-
if (sources.length === 1) index = 1;
|
|
666
|
-
else if (!interactive) return { sources, formatSource, needsSelection: true };
|
|
667
|
-
}
|
|
668
|
-
if (!index) {
|
|
669
|
-
for (const source of sources) stderr.write(`${formatSource(source)}\n`);
|
|
670
|
-
const answer = await ask('Source number (Enter to cancel): ', signal);
|
|
689
|
+
if (!sources.length) {
|
|
690
|
+
if (originalError) throw originalError;
|
|
691
|
+
throw new Error('No downloadable video sources were found on this page.');
|
|
692
|
+
}
|
|
693
|
+
let index = Number(options.source);
|
|
694
|
+
if (!index) {
|
|
695
|
+
if (sources.length === 1) index = 1;
|
|
696
|
+
else if (!interactive) return { sources, formatSource, needsSelection: true };
|
|
697
|
+
}
|
|
698
|
+
if (!index) {
|
|
699
|
+
for (const source of sources) stderr.write(`${formatSource(source)}\n`);
|
|
700
|
+
const answer = await ask('Source number (Enter to cancel): ', signal);
|
|
671
701
|
if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
|
|
672
702
|
index = Number(answer.trim());
|
|
673
703
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, parseConfigText, effectiveConfig, applyProfile } from './config.js';
|
|
1
|
+
import { CONFIG_KEYS, RESERVED_PROFILE_NAMES, parseConfigText, effectiveConfig, applyProfile } from './config.js';
|
|
2
2
|
import { stripConfigComments } from './config-template.js';
|
|
3
3
|
import { QUALITIES, AUDIO_FORMATS, VIDEO_FORMATS } from './utils.js';
|
|
4
4
|
|
|
@@ -64,7 +64,7 @@ function valueChoices(key, config = {}) {
|
|
|
64
64
|
if (key === 'format') return config.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
65
65
|
if (key === 'timeout') return ['30s', '45s', '1m', '2m', '5m'];
|
|
66
66
|
if (['concurrentDownloads', 'playlistConcurrency'].includes(key)) return [1, 2, 3, 4];
|
|
67
|
-
if (key === 'concurrentFragments') return Array.from({ length:
|
|
67
|
+
if (key === 'concurrentFragments') return Array.from({ length: 64 }, (_, i) => i + 1);
|
|
68
68
|
return [];
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -75,7 +75,7 @@ export async function semanticIssue(text) {
|
|
|
75
75
|
if (!object(data)) return located(text, node([]), 'The config must contain a JSON object.');
|
|
76
76
|
const check = (settings, path) => {
|
|
77
77
|
for (const [key, value] of Object.entries(settings)) {
|
|
78
|
-
if (!path.length && ['$schema', 'profiles', 'activeProfile'].includes(key)) continue;
|
|
78
|
+
if (!path.length && ['$schema', 'profiles', 'activeProfile'].includes(key)) continue;
|
|
79
79
|
const target = node([...path, key]);
|
|
80
80
|
if (!Object.hasOwn(CONFIG_KEYS, key)) {
|
|
81
81
|
const proposed = suggestion(key, path.length ? keys : [...keys, 'profiles', '$schema']);
|
|
@@ -90,22 +90,22 @@ export async function semanticIssue(text) {
|
|
|
90
90
|
};
|
|
91
91
|
let issue = check(data, []);
|
|
92
92
|
if (issue) return issue;
|
|
93
|
-
if (Object.hasOwn(data, 'profiles')) {
|
|
94
|
-
if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
|
|
95
|
-
for (const [name, profile] of Object.entries(data.profiles)) {
|
|
96
|
-
if (RESERVED_PROFILE_NAMES.includes(name)) {
|
|
97
|
-
return located(text, node(['profiles', name]), `Profile name "${name}" is reserved for veo profile ${name}. Rename this profile.`, true);
|
|
98
|
-
}
|
|
99
|
-
if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
|
|
93
|
+
if (Object.hasOwn(data, 'profiles')) {
|
|
94
|
+
if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
|
|
95
|
+
for (const [name, profile] of Object.entries(data.profiles)) {
|
|
96
|
+
if (RESERVED_PROFILE_NAMES.includes(name)) {
|
|
97
|
+
return located(text, node(['profiles', name]), `Profile name "${name}" is reserved for veo profile ${name}. Rename this profile.`, true);
|
|
98
|
+
}
|
|
99
|
+
if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
|
|
100
100
|
issue = check(profile, ['profiles', name]); if (issue) return issue;
|
|
101
101
|
}
|
|
102
|
-
}
|
|
103
|
-
if (Object.hasOwn(data, 'activeProfile')) {
|
|
104
|
-
if (typeof data.activeProfile !== 'string' || !data.activeProfile) return located(text, node(['activeProfile']), '"activeProfile" must be a non-empty profile name.');
|
|
105
|
-
if (!object(data.profiles) || !Object.hasOwn(data.profiles, data.activeProfile)) {
|
|
106
|
-
return located(text, node(['activeProfile']), `Unknown active profile "${data.activeProfile}". Choose: ${Object.keys(data.profiles || {}).join(', ') || 'none'}.`, false, Object.keys(data.profiles || {}));
|
|
107
|
-
}
|
|
108
|
-
}
|
|
102
|
+
}
|
|
103
|
+
if (Object.hasOwn(data, 'activeProfile')) {
|
|
104
|
+
if (typeof data.activeProfile !== 'string' || !data.activeProfile) return located(text, node(['activeProfile']), '"activeProfile" must be a non-empty profile name.');
|
|
105
|
+
if (!object(data.profiles) || !Object.hasOwn(data.profiles, data.activeProfile)) {
|
|
106
|
+
return located(text, node(['activeProfile']), `Unknown active profile "${data.activeProfile}". Choose: ${Object.keys(data.profiles || {}).join(', ') || 'none'}.`, false, Object.keys(data.profiles || {}));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
109
|
const { config } = parseConfigText(text);
|
|
110
110
|
for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
|
|
111
111
|
try { await effectiveConfig(config, profile); }
|
|
@@ -114,7 +114,7 @@ export async function semanticIssue(text) {
|
|
|
114
114
|
let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
|
|
115
115
|
/output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
|
|
116
116
|
/--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
117
|
-
const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
|
|
117
|
+
const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
|
|
118
118
|
const selected = applyProfile(config, profile);
|
|
119
119
|
// Template errors do not name the CLI option. Locate the failing template.
|
|
120
120
|
if (!key) {
|
|
@@ -143,8 +143,8 @@ export function editorCompletions(text, cursor) {
|
|
|
143
143
|
const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
|
|
144
144
|
if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
|
|
145
145
|
const profile = path.length === 3 ? path[1] : undefined;
|
|
146
|
-
const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
|
|
147
|
-
key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
|
|
146
|
+
const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
|
|
147
|
+
key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
|
|
148
148
|
if (!choices.length) return null;
|
|
149
149
|
const proposed = property ? suggestion(key, choices) : null;
|
|
150
150
|
return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,
|