veodl 1.9.1 → 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 +35 -0
- package/README.md +75 -40
- package/docs/AGENT_GUIDE.md +17 -6
- package/package.json +61 -58
- package/src/backend.js +64 -36
- package/src/cli.js +126 -74
- package/src/config-diagnostics.js +15 -5
- package/src/config-editor.js +188 -69
- package/src/config-template.js +63 -68
- package/src/config.js +96 -11
- package/src/downloader.js +28 -11
- package/src/experimental-fragments.js +29 -0
- package/src/history.js +1 -1
- package/src/inline-region.js +72 -0
- package/src/interactive.js +19 -19
- package/src/jobs.js +42 -9
- package/src/output.js +77 -60
- package/src/path-links.js +53 -0
- package/src/progress.js +341 -152
- package/src/runs.js +8 -5
- package/src/source-discovery.js +20 -9
- package/src/terminal-size.js +8 -0
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
|
-
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
@@ -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 } 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
|
|
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,7 +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|check|show|reset Manage defaults and named profiles
|
|
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
|
|
93
99
|
veo config edit [--external|--terminal] Choose the configuration editor
|
|
94
100
|
|
|
95
101
|
Run veo without arguments in a terminal for interactive setup.
|
|
@@ -133,6 +139,7 @@ const STRING_OPTIONS = {
|
|
|
133
139
|
};
|
|
134
140
|
|
|
135
141
|
const BOOLEAN_OPTIONS = {
|
|
142
|
+
'experimental-fragments': {},
|
|
136
143
|
compatible: {},
|
|
137
144
|
recode: {},
|
|
138
145
|
'adaptive-concurrency': {},
|
|
@@ -148,6 +155,8 @@ const BOOLEAN_OPTIONS = {
|
|
|
148
155
|
'embed-metadata': {},
|
|
149
156
|
'embed-thumbnail': {},
|
|
150
157
|
'closest-quality': {},
|
|
158
|
+
'list-qualities': {},
|
|
159
|
+
'list-qualitys': {},
|
|
151
160
|
'list-formats': {},
|
|
152
161
|
'list-sources': {},
|
|
153
162
|
'auto-list-sources': {},
|
|
@@ -179,6 +188,7 @@ export function optionDefaults(config = {}) {
|
|
|
179
188
|
cookies: config.cookies,
|
|
180
189
|
'cookies-from-browser': config.cookiesFromBrowser,
|
|
181
190
|
'concurrent-fragments': String(config.concurrentFragments ?? 8),
|
|
191
|
+
'experimental-fragments': config.experimentalFragments ?? false,
|
|
182
192
|
'sub-langs': config.subLangs,
|
|
183
193
|
'sponsorblock-remove': config.sponsorblockRemove,
|
|
184
194
|
section: config.section,
|
|
@@ -198,11 +208,11 @@ export function optionDefaults(config = {}) {
|
|
|
198
208
|
timeout: config.timeout,
|
|
199
209
|
'deep-scan': config.deepScan ?? false,
|
|
200
210
|
'list-sources': config.listSources ?? false,
|
|
201
|
-
'auto-list-sources': config.autoListSources ??
|
|
211
|
+
'auto-list-sources': config.autoListSources ?? true,
|
|
202
212
|
};
|
|
203
213
|
}
|
|
204
214
|
|
|
205
|
-
export function cliOptions(config = {}) {
|
|
215
|
+
export function cliOptions(config = {}) {
|
|
206
216
|
const defaults = optionDefaults(config);
|
|
207
217
|
const options = {};
|
|
208
218
|
for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
|
|
@@ -214,41 +224,42 @@ export function cliOptions(config = {}) {
|
|
|
214
224
|
if (defaults[name] !== undefined) options[name].default = defaults[name];
|
|
215
225
|
}
|
|
216
226
|
return options;
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
}
|
|
252
263
|
|
|
253
264
|
export function parseCli(args, { config = {} } = {}) {
|
|
254
265
|
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
@@ -256,14 +267,15 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
256
267
|
if (args.length !== 1) throw new Error('Usage: veo version');
|
|
257
268
|
return { version: true };
|
|
258
269
|
}
|
|
259
|
-
let preliminary;
|
|
260
|
-
try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
|
|
261
|
-
catch (error) { throw explainUnknownOption(error, cliOptions()); }
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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;
|
|
267
279
|
if (values.help || values.version) return values;
|
|
268
280
|
// A stored default conflicting with a flag typed right now is a user error;
|
|
269
281
|
// a stored default merely ignored by another flag is not.
|
|
@@ -290,6 +302,13 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
290
302
|
if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
|
|
291
303
|
if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
|
|
292
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
|
+
}
|
|
293
312
|
if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
|
|
294
313
|
if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
295
314
|
if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
|
|
@@ -307,7 +326,7 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
307
326
|
if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
|
|
308
327
|
if (values['concurrent-fragments'] !== undefined) {
|
|
309
328
|
const fragments = Number(values['concurrent-fragments']);
|
|
310
|
-
|
|
329
|
+
validateFragments(fragments);
|
|
311
330
|
}
|
|
312
331
|
const concurrency = Number(values['playlist-concurrency']);
|
|
313
332
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
|
|
@@ -344,13 +363,14 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
344
363
|
options.subLangs = undefined;
|
|
345
364
|
options.embedSubs = false;
|
|
346
365
|
}
|
|
366
|
+
options.profile = profileName;
|
|
347
367
|
return options;
|
|
348
368
|
}
|
|
349
369
|
|
|
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; }
|
|
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; }
|
|
354
374
|
let color = !args.includes('--no-color') && !args.includes('--json');
|
|
355
375
|
let display;
|
|
356
376
|
try {
|
|
@@ -427,6 +447,10 @@ async function runMain(args, { config }) {
|
|
|
427
447
|
args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
|
|
428
448
|
} catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
429
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
|
+
}
|
|
430
454
|
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
431
455
|
await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
|
|
432
456
|
// update/upgrade/up/check subcommands are handled before URL validation.
|
|
@@ -507,7 +531,14 @@ async function runMain(args, { config }) {
|
|
|
507
531
|
retryItems = await retryOptions(options.retryFailed);
|
|
508
532
|
// Revalidate stored options and apply only flags explicitly supplied now.
|
|
509
533
|
const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
|
|
510
|
-
|
|
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
|
+
});
|
|
511
542
|
options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
|
|
512
543
|
}
|
|
513
544
|
if (options.batchFile) {
|
|
@@ -518,20 +549,26 @@ async function runMain(args, { config }) {
|
|
|
518
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.');
|
|
519
550
|
if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
520
551
|
}
|
|
552
|
+
await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
|
|
521
553
|
// The job file is created before the first download, so another terminal can
|
|
522
554
|
// follow this run's per-item progress with `veo runs <id>`.
|
|
523
|
-
const jobFile = options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
|
|
555
|
+
const jobFile = options.listQualities || options.listFormats || options.listSources || options.dryRun ? null : jobFilePath();
|
|
524
556
|
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
525
|
-
audio: options.audio, quality: options.quality, format: options.format,
|
|
557
|
+
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
558
|
+
playlist: options.playlist, job: jobFile });
|
|
526
559
|
reporter.configure?.({ color: options.color && !options.json });
|
|
527
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;
|
|
528
564
|
const cookieWarning = cookieFileWarning(options.cookies);
|
|
529
565
|
if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
|
|
530
566
|
|
|
531
567
|
// For a single page, fall back to media requests observed while its player loads.
|
|
532
568
|
// Explicit --source always rediscovers: signed media URLs can expire between runs.
|
|
533
569
|
if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
|
|
534
|
-
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr
|
|
570
|
+
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
|
|
571
|
+
onBackend: backend => preparedBackends.set(options.url, backend) });
|
|
535
572
|
if (options.listSources) {
|
|
536
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`);
|
|
537
574
|
else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
|
|
@@ -554,20 +591,31 @@ async function runMain(args, { config }) {
|
|
|
554
591
|
}
|
|
555
592
|
if (retryItems) for (const item of retryItems) {
|
|
556
593
|
if (!item.source) continue;
|
|
557
|
-
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr
|
|
594
|
+
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
595
|
+
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
558
596
|
item.mediaUrl = discovery.mediaUrl;
|
|
559
597
|
}
|
|
560
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
|
+
}
|
|
561
609
|
if (options.listFormats) {
|
|
562
610
|
const { listFormats } = await import('./downloader.js');
|
|
563
|
-
stdout.write(await listFormats(options, { signal: controller.signal, reporter }));
|
|
611
|
+
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
564
612
|
return 0;
|
|
565
613
|
}
|
|
566
614
|
if (options.dryRun) {
|
|
567
615
|
const { planDownload } = await import('./downloader.js');
|
|
568
616
|
for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
|
|
569
617
|
const { url } = request;
|
|
570
|
-
const plan = await planDownload(request, { signal: controller.signal, reporter });
|
|
618
|
+
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
571
619
|
if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
|
|
572
620
|
else {
|
|
573
621
|
stdout.write(`URL: ${url}\n`);
|
|
@@ -581,13 +629,17 @@ async function runMain(args, { config }) {
|
|
|
581
629
|
const { download } = await import('./downloader.js');
|
|
582
630
|
const { createStatsRecorder } = await import('./stats.js');
|
|
583
631
|
const { createHistoryRecorder } = await import('./history.js');
|
|
584
|
-
|
|
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() });
|
|
585
635
|
return result;
|
|
586
636
|
} catch (error) {
|
|
587
637
|
reporter.fail(controller.signal.aborted);
|
|
638
|
+
reporter.dispose();
|
|
588
639
|
stderr.write(`veo: ${readableError(error)}\n`);
|
|
589
640
|
return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
|
|
590
641
|
} finally {
|
|
642
|
+
reporter.dispose();
|
|
591
643
|
await run?.unregister();
|
|
592
644
|
process.removeListener('SIGINT', cancel);
|
|
593
645
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -600,11 +652,12 @@ async function askSourceQuestion(prompt, signal) {
|
|
|
600
652
|
finally { rl.close(); }
|
|
601
653
|
}
|
|
602
654
|
|
|
603
|
-
export async function resolvePageSource(options, { signal, stderr, inspect, discover, ask = askSourceQuestion,
|
|
655
|
+
export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
|
|
604
656
|
backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
|
|
605
657
|
const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
|
|
606
658
|
const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
|
|
607
|
-
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal });
|
|
659
|
+
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
|
|
660
|
+
if (backend) onBackend?.(backend);
|
|
608
661
|
const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
|
|
609
662
|
let originalError;
|
|
610
663
|
if (!options.source && !options.listSources) {
|
|
@@ -617,11 +670,8 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
|
|
|
617
670
|
}
|
|
618
671
|
catch (error) { originalError = error; }
|
|
619
672
|
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
620
|
-
if (
|
|
621
|
-
|
|
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
|
-
}
|
|
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');
|
|
625
675
|
}
|
|
626
676
|
let sources;
|
|
627
677
|
try {
|
|
@@ -642,9 +692,11 @@ export async function resolvePageSource(options, { signal, stderr, inspect, disc
|
|
|
642
692
|
}
|
|
643
693
|
let index = Number(options.source);
|
|
644
694
|
if (!index) {
|
|
645
|
-
if (
|
|
695
|
+
if (sources.length === 1) index = 1;
|
|
696
|
+
else if (!interactive) return { sources, formatSource, needsSelection: true };
|
|
697
|
+
}
|
|
698
|
+
if (!index) {
|
|
646
699
|
for (const source of sources) stderr.write(`${formatSource(source)}\n`);
|
|
647
|
-
if (!interactive) throw new Error('Select a source with --source <number>.');
|
|
648
700
|
const answer = await ask('Source number (Enter to cancel): ', signal);
|
|
649
701
|
if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
|
|
650
702
|
index = Number(answer.trim());
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG_KEYS, 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'].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']);
|
|
@@ -93,10 +93,19 @@ export async function semanticIssue(text) {
|
|
|
93
93
|
if (Object.hasOwn(data, 'profiles')) {
|
|
94
94
|
if (!object(data.profiles)) return located(text, node(['profiles']), 'Profiles must be an object.');
|
|
95
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
|
+
}
|
|
96
99
|
if (!object(profile)) return located(text, node(['profiles', name]), `Profile "${name}" must be an object.`);
|
|
97
100
|
issue = check(profile, ['profiles', name]); if (issue) return issue;
|
|
98
101
|
}
|
|
99
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
|
+
}
|
|
100
109
|
const { config } = parseConfigText(text);
|
|
101
110
|
for (const profile of [undefined, ...Object.keys(config.profiles || {})]) {
|
|
102
111
|
try { await effectiveConfig(config, profile); }
|
|
@@ -105,7 +114,7 @@ export async function semanticIssue(text) {
|
|
|
105
114
|
let key = /Invalid quality/.test(message) ? 'quality' : /Invalid (?:audio|video) format/.test(message) ? 'format' :
|
|
106
115
|
/output directory/.test(message) ? 'output' : /custom filename/.test(message) ? 'rename' :
|
|
107
116
|
/--([a-z-]+)/.exec(message)?.[1]?.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
108
|
-
const name = profile ?? (config.profiles?.default ? 'default' : undefined);
|
|
117
|
+
const name = profile ?? config.activeProfile ?? (config.profiles?.default ? 'default' : undefined);
|
|
109
118
|
const selected = applyProfile(config, profile);
|
|
110
119
|
// Template errors do not name the CLI option. Locate the failing template.
|
|
111
120
|
if (!key) {
|
|
@@ -134,7 +143,8 @@ export function editorCompletions(text, cursor) {
|
|
|
134
143
|
const key = path.at(-1), property = cursor >= item.keyStart && cursor <= item.keyEnd;
|
|
135
144
|
if (!property && !(cursor >= item.start && cursor <= item.end)) continue;
|
|
136
145
|
const profile = path.length === 3 ? path[1] : undefined;
|
|
137
|
-
const choices = property ? (path.length === 1 ? [...keys, 'profiles', '$schema'] : keys) :
|
|
146
|
+
const choices = property ? (path.length === 1 ? [...keys, 'profiles', 'activeProfile', '$schema'] : keys) :
|
|
147
|
+
key === 'activeProfile' ? Object.keys(data.profiles || {}) : valueChoices(key, applyProfile(data, profile));
|
|
138
148
|
if (!choices.length) return null;
|
|
139
149
|
const proposed = property ? suggestion(key, choices) : null;
|
|
140
150
|
return { start: property ? item.keyStart : item.start, end: property ? item.keyEnd : item.end,
|