veodl 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +29 -0
- package/README.md +128 -67
- package/docs/AGENT_GUIDE.md +23 -9
- package/package.json +61 -58
- package/src/cli.js +227 -133
- package/src/config-diagnostics.js +20 -20
- package/src/config-editor.js +21 -17
- package/src/config-template.js +79 -66
- package/src/config.js +117 -110
- package/src/downloader.js +103 -42
- package/src/experimental-fragments.js +29 -0
- package/src/history.js +1 -1
- package/src/inline-region.js +72 -0
- package/src/inspect-media.js +11 -0
- package/src/interactive.js +9 -4
- package/src/jobs.js +51 -14
- package/src/naming.js +4 -0
- 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,9 +44,14 @@ 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
|
+
--auto-subs Include automatically generated subtitles
|
|
52
|
+
--sub-langs <langs> Subtitle languages, e.g. "de,en" (manual unless --auto-subs)
|
|
53
|
+
--sub-format <format> Preferred subtitle format, e.g. srt/vtt/best
|
|
54
|
+
--list-subs Show manual and automatic subtitle languages, then exit
|
|
49
55
|
--embed-subs Embed subtitles into the video file
|
|
50
56
|
--embed-metadata Embed title, date and other metadata
|
|
51
57
|
--embed-thumbnail Embed the thumbnail
|
|
@@ -56,17 +62,22 @@ Options:
|
|
|
56
62
|
--cookies-from-browser <browser[:profile]>
|
|
57
63
|
Read cookies from an installed browser
|
|
58
64
|
--resume Keep partial data and continue an interrupted download
|
|
65
|
+
--incognito Save media without run IDs, history, stats or retry data
|
|
66
|
+
--neutral-filename Use a random video name; omit title and naming templates
|
|
67
|
+
--list-qualities Show available video resolutions and exit
|
|
68
|
+
--list-qualitys Alias for --list-qualities
|
|
59
69
|
--list-formats Show the available formats and exit
|
|
60
70
|
--list-sources Find video sources loaded by a web page and exit
|
|
61
|
-
--auto-list-sources Search after no video is found (default: on)
|
|
71
|
+
--auto-list-sources Search after no video is found (default: on)
|
|
62
72
|
--source <n> Select source number from that page (also for scripts)
|
|
63
73
|
--deep-scan Check every media candidate found during source search
|
|
64
74
|
--timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
|
|
65
75
|
--dry-run Show what would be downloaded and exit
|
|
76
|
+
--verify Probe saved media files with FFprobe (default: off)
|
|
66
77
|
--json Print one JSON object per URL instead of prose
|
|
67
78
|
--profile <name> Apply a named config profile
|
|
68
79
|
--batch-file <file> Read URLs from a file (one per line; # comments)
|
|
69
|
-
--retry-failed <file>
|
|
80
|
+
--retry-failed <id|file> Retry failed/unfinished items by run ID or job file
|
|
70
81
|
--playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
|
|
71
82
|
--skip-existing Skip matching downloads still present on disk
|
|
72
83
|
--no-<boolean-option> Disable a stored boolean default, e.g. --no-open
|
|
@@ -84,13 +95,14 @@ Commands:
|
|
|
84
95
|
veo history --failed --limit 20 Filter and extend download history
|
|
85
96
|
veo runs [id] List active runs; add --json for metadata and progress
|
|
86
97
|
veo inspect <file> Read media metadata; --check-audio measures audio signal
|
|
98
|
+
veo subs <url> List available manual and automatic subtitles
|
|
87
99
|
veo inspect run <id> Inspect saved files from a finished run by its id
|
|
88
100
|
veo stop [id] Stop one run, or every active run
|
|
89
101
|
veo alias list|add|remove Manage extra command names (wrappers calling veo)
|
|
90
102
|
veo uninstall [-p <name>] Remove one alias, or everything with --yes
|
|
91
103
|
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
|
|
104
|
+
veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
|
|
105
|
+
veo profile [list|reset|name] Show, list or change the default profile
|
|
94
106
|
veo config edit [--external|--terminal] Choose the configuration editor
|
|
95
107
|
|
|
96
108
|
Run veo without arguments in a terminal for interactive setup.
|
|
@@ -123,6 +135,7 @@ const STRING_OPTIONS = {
|
|
|
123
135
|
'cookies-from-browser': {},
|
|
124
136
|
'concurrent-fragments': { short: 'N' },
|
|
125
137
|
'sub-langs': {},
|
|
138
|
+
'sub-format': {},
|
|
126
139
|
'sponsorblock-remove': {},
|
|
127
140
|
section: {},
|
|
128
141
|
profile: {},
|
|
@@ -134,6 +147,7 @@ const STRING_OPTIONS = {
|
|
|
134
147
|
};
|
|
135
148
|
|
|
136
149
|
const BOOLEAN_OPTIONS = {
|
|
150
|
+
'experimental-fragments': {},
|
|
137
151
|
compatible: {},
|
|
138
152
|
recode: {},
|
|
139
153
|
'adaptive-concurrency': {},
|
|
@@ -143,12 +157,19 @@ const BOOLEAN_OPTIONS = {
|
|
|
143
157
|
open: {},
|
|
144
158
|
audio: {},
|
|
145
159
|
resume: {},
|
|
160
|
+
incognito: {},
|
|
161
|
+
'neutral-filename': {},
|
|
146
162
|
playlist: {},
|
|
147
163
|
subs: {},
|
|
164
|
+
'auto-subs': {},
|
|
165
|
+
'list-subs': {},
|
|
166
|
+
verify: {},
|
|
148
167
|
'embed-subs': {},
|
|
149
168
|
'embed-metadata': {},
|
|
150
169
|
'embed-thumbnail': {},
|
|
151
170
|
'closest-quality': {},
|
|
171
|
+
'list-qualities': {},
|
|
172
|
+
'list-qualitys': {},
|
|
152
173
|
'list-formats': {},
|
|
153
174
|
'list-sources': {},
|
|
154
175
|
'auto-list-sources': {},
|
|
@@ -180,14 +201,20 @@ export function optionDefaults(config = {}) {
|
|
|
180
201
|
cookies: config.cookies,
|
|
181
202
|
'cookies-from-browser': config.cookiesFromBrowser,
|
|
182
203
|
'concurrent-fragments': String(config.concurrentFragments ?? 8),
|
|
204
|
+
'experimental-fragments': config.experimentalFragments ?? false,
|
|
183
205
|
'sub-langs': config.subLangs,
|
|
184
206
|
'sponsorblock-remove': config.sponsorblockRemove,
|
|
185
207
|
section: config.section,
|
|
186
208
|
open: config.open ?? false,
|
|
187
209
|
audio: config.audio ?? false,
|
|
188
210
|
resume: config.resume ?? false,
|
|
211
|
+
incognito: config.incognito ?? false,
|
|
212
|
+
'neutral-filename': config.neutralFilename ?? false,
|
|
189
213
|
playlist: config.playlist ?? false,
|
|
190
214
|
subs: config.subs ?? false,
|
|
215
|
+
'auto-subs': config.autoSubs ?? false,
|
|
216
|
+
'sub-format': config.subFormat,
|
|
217
|
+
verify: config.verify ?? false,
|
|
191
218
|
'embed-subs': config.embedSubs ?? false,
|
|
192
219
|
'embed-metadata': config.embedMetadata ?? false,
|
|
193
220
|
'embed-thumbnail': config.embedThumbnail ?? false,
|
|
@@ -199,11 +226,11 @@ export function optionDefaults(config = {}) {
|
|
|
199
226
|
timeout: config.timeout,
|
|
200
227
|
'deep-scan': config.deepScan ?? false,
|
|
201
228
|
'list-sources': config.listSources ?? false,
|
|
202
|
-
'auto-list-sources': config.autoListSources ?? true,
|
|
229
|
+
'auto-list-sources': config.autoListSources ?? true,
|
|
203
230
|
};
|
|
204
231
|
}
|
|
205
232
|
|
|
206
|
-
export function cliOptions(config = {}) {
|
|
233
|
+
export function cliOptions(config = {}) {
|
|
207
234
|
const defaults = optionDefaults(config);
|
|
208
235
|
const options = {};
|
|
209
236
|
for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
|
|
@@ -215,42 +242,42 @@ export function cliOptions(config = {}) {
|
|
|
215
242
|
if (defaults[name] !== undefined) options[name].default = defaults[name];
|
|
216
243
|
}
|
|
217
244
|
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
|
-
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const COMMAND_FLAGS = {
|
|
248
|
+
stats: ['--json'], history: ['--json', '--limit', '--failed'],
|
|
249
|
+
runs: ['--json'], stop: [], flush: ['--stats'],
|
|
250
|
+
inspect: ['--json', '--check-audio', '--search'],
|
|
251
|
+
doctor: ['--offline', '--output', '-o'],
|
|
252
|
+
update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
|
|
253
|
+
backend: ['--check', '--keep-files'],
|
|
254
|
+
alias: ['--json', '--force', '--bin-dir'],
|
|
255
|
+
uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
|
|
256
|
+
config: ['--profile', '--external', '--terminal'],
|
|
257
|
+
profile: [],
|
|
258
|
+
};
|
|
259
|
+
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), 'retry', 'subs', 'help', 'version'];
|
|
260
|
+
|
|
261
|
+
function mistypedCommandOption(args) {
|
|
262
|
+
const command = args[0];
|
|
263
|
+
if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
|
|
264
|
+
const suggestion = suggestOption(command, COMMAND_NAMES);
|
|
265
|
+
if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
|
|
266
|
+
}
|
|
267
|
+
const options = Object.hasOwn(COMMAND_FLAGS, command)
|
|
268
|
+
? [...COMMAND_FLAGS[command], '--help', '-h', '--no-color', '--color', '--profile']
|
|
269
|
+
: command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
|
|
270
|
+
if (!options) return null; // Download flags are checked by parseCli itself.
|
|
271
|
+
const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
|
|
272
|
+
for (const arg of args.slice(start)) {
|
|
273
|
+
if (arg === '--') break;
|
|
274
|
+
const name = arg.split('=')[0];
|
|
275
|
+
if (!name.startsWith('-') || options.includes(name)) continue;
|
|
276
|
+
const suggestion = suggestOption(name, options);
|
|
277
|
+
if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
254
281
|
|
|
255
282
|
export function parseCli(args, { config = {} } = {}) {
|
|
256
283
|
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
@@ -258,15 +285,15 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
258
285
|
if (args.length !== 1) throw new Error('Usage: veo version');
|
|
259
286
|
return { version: true };
|
|
260
287
|
}
|
|
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;
|
|
288
|
+
let preliminary;
|
|
289
|
+
try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
|
|
290
|
+
catch (error) { throw explainUnknownOption(error, cliOptions()); }
|
|
291
|
+
const profileName = selectedProfileName(config, preliminary.values.profile);
|
|
292
|
+
config = applyProfile(config, preliminary.values.profile);
|
|
293
|
+
let parsed;
|
|
294
|
+
try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
|
|
295
|
+
catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
|
|
296
|
+
const { values, positionals, tokens } = parsed;
|
|
270
297
|
if (values.help || values.version) return values;
|
|
271
298
|
// A stored default conflicting with a flag typed right now is a user error;
|
|
272
299
|
// a stored default merely ignored by another flag is not.
|
|
@@ -287,14 +314,36 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
287
314
|
} else { validateItems(values['playlist-items']); values.playlist = true; }
|
|
288
315
|
}
|
|
289
316
|
if (values['retry-failed'] && (positionals.length || values['batch-file'])) throw new Error('--retry-failed cannot be combined with URLs or --batch-file.');
|
|
317
|
+
if (values.incognito && values['retry-failed']) throw new Error('--incognito cannot retry a saved job. Use --no-incognito for this request.');
|
|
318
|
+
if (values.incognito && values.resume) {
|
|
319
|
+
if (tokens.some(token => token.name === 'resume')) throw new Error('--incognito cannot be combined with --resume.');
|
|
320
|
+
values.resume = false;
|
|
321
|
+
}
|
|
322
|
+
if (values.incognito && values['skip-existing']) {
|
|
323
|
+
if (tokens.some(token => token.name === 'skip-existing')) throw new Error('--incognito cannot be combined with --skip-existing.');
|
|
324
|
+
values['skip-existing'] = false;
|
|
325
|
+
}
|
|
290
326
|
if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
|
|
291
327
|
if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
|
|
292
|
-
if (positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
328
|
+
if (!values['neutral-filename'] && positionals.length > 1 && values.rename !== undefined && !values.rename.includes('*')) throw new Error('--rename only applies to a single URL unless the name contains * for the original title.');
|
|
293
329
|
if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
|
|
294
330
|
if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
|
|
295
331
|
if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
|
|
332
|
+
values['list-qualities'] = Boolean(values['list-qualities'] || values['list-qualitys']);
|
|
333
|
+
delete values['list-qualitys'];
|
|
334
|
+
if (values['list-qualities']) {
|
|
335
|
+
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-qualities accepts exactly one URL.');
|
|
336
|
+
if (values['list-formats'] || values['list-sources'] || values['list-subs']) throw new Error('--list-qualities cannot be combined with other listing actions.');
|
|
337
|
+
if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
|
|
338
|
+
}
|
|
296
339
|
if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
|
|
297
340
|
if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
341
|
+
if (values['list-subs']) {
|
|
342
|
+
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-subs accepts exactly one URL.');
|
|
343
|
+
if (values['list-formats'] || values['list-sources']) throw new Error('--list-subs cannot be combined with other listing actions.');
|
|
344
|
+
if (values.playlist) throw new Error('--list-subs requires a single video without --playlist.');
|
|
345
|
+
}
|
|
346
|
+
if (values['sub-format'] && (!/^[a-z0-9]+(?:\/[a-z0-9]+)*$/i.test(values['sub-format']) || values['sub-format'].length > 100)) throw new Error('--sub-format must be a format or preference list such as srt/vtt/best.');
|
|
298
347
|
if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
|
|
299
348
|
if (values.source && values['list-sources'] && !tokens.some(token => token.name === 'list-sources')) values['list-sources'] = false;
|
|
300
349
|
if (values['list-sources'] && !tokens.some(token => token.name === 'list-sources')
|
|
@@ -310,7 +359,7 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
310
359
|
if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
|
|
311
360
|
if (values['concurrent-fragments'] !== undefined) {
|
|
312
361
|
const fragments = Number(values['concurrent-fragments']);
|
|
313
|
-
|
|
362
|
+
validateFragments(fragments);
|
|
314
363
|
}
|
|
315
364
|
const concurrency = Number(values['playlist-concurrency']);
|
|
316
365
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
|
|
@@ -340,26 +389,28 @@ export function parseCli(args, { config = {} } = {}) {
|
|
|
340
389
|
if (!Number.isInteger(options.concurrentDownloads) || options.concurrentDownloads < 1 || options.concurrentDownloads > 4) throw new Error('--concurrent-downloads must be a whole number between 1 and 4.');
|
|
341
390
|
if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
|
|
342
391
|
if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
|
|
343
|
-
if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
|
|
392
|
+
if (options.rename && options.filenameTemplate && !options.neutralFilename) throw new Error('--rename and --filename-template cannot be combined.');
|
|
344
393
|
const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
|
|
345
|
-
if (options.subLangs && !disableSubs) options.subs = true;
|
|
346
|
-
if (disableSubs) {
|
|
347
|
-
options.
|
|
348
|
-
options.
|
|
349
|
-
}
|
|
350
|
-
options.profile = profileName;
|
|
351
|
-
return options;
|
|
352
|
-
}
|
|
394
|
+
if (options.subLangs && !disableSubs && !options.autoSubs) options.subs = true;
|
|
395
|
+
if (disableSubs) {
|
|
396
|
+
options.embedSubs = false;
|
|
397
|
+
if (!options.autoSubs) options.subLangs = undefined;
|
|
398
|
+
}
|
|
399
|
+
options.profile = profileName;
|
|
400
|
+
return options;
|
|
401
|
+
}
|
|
353
402
|
|
|
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; }
|
|
403
|
+
export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
404
|
+
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
405
|
+
const mistyped = mistypedCommandOption(args);
|
|
406
|
+
if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
|
|
358
407
|
let color = !args.includes('--no-color') && !args.includes('--json');
|
|
359
408
|
let display;
|
|
409
|
+
let loadedConfig;
|
|
360
410
|
try {
|
|
361
411
|
display = outputOptions(args);
|
|
362
412
|
const loaded = config ?? await loadConfig();
|
|
413
|
+
loadedConfig = loaded.config || {};
|
|
363
414
|
color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
|
|
364
415
|
// Validate explicit profiles even when a color flag overrides their setting.
|
|
365
416
|
if (display.profile) applyProfile(loaded.config || {}, display.profile);
|
|
@@ -375,7 +426,12 @@ export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
|
375
426
|
// One throttled (once per day) update hint after every successful command.
|
|
376
427
|
// Failures, cancellations, help/version output and the update commands
|
|
377
428
|
// themselves never trigger it; VEO_NO_UPDATE_CHECK=1 disables it for scripts.
|
|
378
|
-
|
|
429
|
+
let incognitoRequest = !args.length;
|
|
430
|
+
if (!incognitoRequest && code === 0 && shouldUpdateNotice(args)) {
|
|
431
|
+
try { incognitoRequest = Boolean(parseCli(args, { config: loadedConfig }).incognito); }
|
|
432
|
+
catch { /* Non-download commands have their own argument parser. */ }
|
|
433
|
+
}
|
|
434
|
+
if (code === 0 && shouldUpdateNotice(args) && !incognitoRequest) {
|
|
379
435
|
try {
|
|
380
436
|
const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
|
|
381
437
|
if (notice) process.stderr.write(`${notice}\n`);
|
|
@@ -421,7 +477,7 @@ async function runMain(args, { config }) {
|
|
|
421
477
|
try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
|
|
422
478
|
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
423
479
|
}
|
|
424
|
-
if (args[0] === 'retry') {
|
|
480
|
+
if (args[0] === 'retry') {
|
|
425
481
|
if (args[1] !== '--last') {
|
|
426
482
|
stderr.write('veo: Usage: veo retry --last [download options]\n');
|
|
427
483
|
return 1;
|
|
@@ -430,12 +486,23 @@ async function runMain(args, { config }) {
|
|
|
430
486
|
const { latestFailedJob } = await import('./jobs.js');
|
|
431
487
|
args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
|
|
432
488
|
} catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
433
|
-
}
|
|
434
|
-
if (args[0] === '
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
489
|
+
}
|
|
490
|
+
if (args[0] === 'subs') {
|
|
491
|
+
if (args[1] === '--help' || args[1] === '-h') {
|
|
492
|
+
stdout.write('veo subs <url> [--json] [--source <n>] [--profile <name>]\n\nList manual and automatically generated subtitle languages and source formats without downloading media.\n');
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
if (!args[1] || args[1].startsWith('-')) {
|
|
496
|
+
stderr.write('veo: Usage: veo subs <url> [--json] [--source <n>]\n');
|
|
497
|
+
return 1;
|
|
498
|
+
}
|
|
499
|
+
args = [args[1], '--list-subs', ...args.slice(2)];
|
|
500
|
+
}
|
|
501
|
+
if (args[0] === 'profile') {
|
|
502
|
+
try { return await profileMain(args.slice(1)); }
|
|
503
|
+
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
504
|
+
}
|
|
505
|
+
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
439
506
|
await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
|
|
440
507
|
// update/upgrade/up/check subcommands are handled before URL validation.
|
|
441
508
|
if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
|
|
@@ -446,10 +513,10 @@ async function runMain(args, { config }) {
|
|
|
446
513
|
}
|
|
447
514
|
return updateMain(args, { registry: defaultRegistry() });
|
|
448
515
|
}
|
|
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
|
-
}
|
|
516
|
+
if (args[0] === 'config') {
|
|
517
|
+
try { return await configMain(args.slice(1)); }
|
|
518
|
+
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
519
|
+
}
|
|
453
520
|
if (args[0] === 'doctor') {
|
|
454
521
|
try {
|
|
455
522
|
const { doctorMain } = await import('./doctor.js');
|
|
@@ -493,9 +560,6 @@ async function runMain(args, { config }) {
|
|
|
493
560
|
process.once('SIGINT', cancel);
|
|
494
561
|
process.once('SIGTERM', cancel);
|
|
495
562
|
try {
|
|
496
|
-
if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version')) {
|
|
497
|
-
run = await (await import('./runs.js')).registerRun(cancel);
|
|
498
|
-
}
|
|
499
563
|
const loaded = config ?? await loadConfig();
|
|
500
564
|
for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
|
|
501
565
|
if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
|
|
@@ -510,19 +574,20 @@ async function runMain(args, { config }) {
|
|
|
510
574
|
stdout.write(`${pkg.version}\n`);
|
|
511
575
|
return 0;
|
|
512
576
|
}
|
|
577
|
+
if (!options.incognito) run = await (await import('./runs.js')).registerRun(cancel);
|
|
513
578
|
let retryItems;
|
|
514
579
|
if (options.retryFailed) {
|
|
515
580
|
retryItems = await retryOptions(options.retryFailed);
|
|
516
581
|
// Revalidate stored options and apply only flags explicitly supplied now.
|
|
517
582
|
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
|
-
});
|
|
583
|
+
const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
|
|
584
|
+
retryItems = retryItems.map(item => {
|
|
585
|
+
const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
|
|
586
|
+
? { ...item, profiles: loaded.config?.profiles }
|
|
587
|
+
: item });
|
|
588
|
+
if (!overrideProfile) resumed.profile = item.profile;
|
|
589
|
+
return resumed;
|
|
590
|
+
});
|
|
526
591
|
options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
|
|
527
592
|
}
|
|
528
593
|
if (options.batchFile) {
|
|
@@ -530,28 +595,29 @@ async function runMain(args, { config }) {
|
|
|
530
595
|
options.urls.push(...lines.map(validateUrl));
|
|
531
596
|
options.url = options.urls[0];
|
|
532
597
|
if (!options.urls.length) throw new Error('The URL list is empty.');
|
|
533
|
-
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.');
|
|
598
|
+
if (!options.neutralFilename && 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
599
|
if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
535
600
|
}
|
|
601
|
+
await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
|
|
536
602
|
// The job file is created before the first download, so another terminal can
|
|
537
603
|
// 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);
|
|
604
|
+
const jobFile = options.incognito || options.listQualities || options.listFormats || options.listSources || options.listSubs || options.dryRun ? null : jobFilePath();
|
|
605
|
+
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
606
|
+
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
607
|
+
playlist: options.playlist, job: jobFile });
|
|
608
|
+
reporter.configure?.({ color: options.color && !options.json });
|
|
609
|
+
reporter.start(options.rename);
|
|
610
|
+
if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources && !options.listSubs) reporter.profile(options.profile);
|
|
611
|
+
const preparedBackends = new Map();
|
|
612
|
+
const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
|
|
613
|
+
const cookieWarning = cookieFileWarning(options.cookies);
|
|
548
614
|
if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
|
|
549
615
|
|
|
550
616
|
// For a single page, fall back to media requests observed while its player loads.
|
|
551
617
|
// Explicit --source always rediscovers: signed media URLs can expire between runs.
|
|
552
618
|
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) });
|
|
619
|
+
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
|
|
620
|
+
onBackend: backend => preparedBackends.set(options.url, backend) });
|
|
555
621
|
if (options.listSources) {
|
|
556
622
|
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
623
|
else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
|
|
@@ -574,21 +640,45 @@ async function runMain(args, { config }) {
|
|
|
574
640
|
}
|
|
575
641
|
if (retryItems) for (const item of retryItems) {
|
|
576
642
|
if (!item.source) continue;
|
|
577
|
-
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
578
|
-
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
643
|
+
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
644
|
+
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
579
645
|
item.mediaUrl = discovery.mediaUrl;
|
|
580
646
|
}
|
|
581
647
|
|
|
648
|
+
if (options.listSubs) {
|
|
649
|
+
const { listSubtitles } = await import('./downloader.js');
|
|
650
|
+
const result = await listSubtitles(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
651
|
+
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
652
|
+
else {
|
|
653
|
+
stdout.write(`Subtitles: ${cleanText(result.title || options.url)}\n`);
|
|
654
|
+
for (const [label, entries] of [['Manual', result.manual], ['Automatic', result.automatic]]) {
|
|
655
|
+
stdout.write(`${label}:\n`);
|
|
656
|
+
if (!entries.length) stdout.write(' none\n');
|
|
657
|
+
for (const entry of entries) stdout.write(` ${cleanText(entry.language)}${entry.name ? ` (${cleanText(entry.name)})` : ''}: ${entry.formats.join(', ') || 'unknown'}\n`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
if (options.listQualities) {
|
|
663
|
+
const { listQualities } = await import('./downloader.js');
|
|
664
|
+
const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
665
|
+
if (options.json) stdout.write(JSON.stringify(result) + '\n');
|
|
666
|
+
else {
|
|
667
|
+
stdout.write('Available qualities: ' + (result.qualities.join(', ') || 'resolution unknown') + '\n');
|
|
668
|
+
if (result.qualities.length) stdout.write('Choose with --quality ' + result.qualities[0] + '\n');
|
|
669
|
+
}
|
|
670
|
+
return 0;
|
|
671
|
+
}
|
|
582
672
|
if (options.listFormats) {
|
|
583
673
|
const { listFormats } = await import('./downloader.js');
|
|
584
|
-
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
674
|
+
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
585
675
|
return 0;
|
|
586
676
|
}
|
|
587
677
|
if (options.dryRun) {
|
|
588
678
|
const { planDownload } = await import('./downloader.js');
|
|
589
679
|
for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
|
|
590
680
|
const { url } = request;
|
|
591
|
-
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
681
|
+
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
592
682
|
if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
|
|
593
683
|
else {
|
|
594
684
|
stdout.write(`URL: ${url}\n`);
|
|
@@ -602,14 +692,18 @@ async function runMain(args, { config }) {
|
|
|
602
692
|
const { download } = await import('./downloader.js');
|
|
603
693
|
const { createStatsRecorder } = await import('./stats.js');
|
|
604
694
|
const { createHistoryRecorder } = await import('./history.js');
|
|
605
|
-
|
|
606
|
-
|
|
695
|
+
if (!options.json && process.stdout.isTTY) reporter.enableInline();
|
|
696
|
+
const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
|
|
697
|
+
reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id,
|
|
698
|
+
recordStats: options.incognito ? undefined : createStatsRecorder(), recordHistory: options.incognito ? undefined : createHistoryRecorder() });
|
|
607
699
|
return result;
|
|
608
700
|
} catch (error) {
|
|
609
701
|
reporter.fail(controller.signal.aborted);
|
|
702
|
+
reporter.dispose();
|
|
610
703
|
stderr.write(`veo: ${readableError(error)}\n`);
|
|
611
704
|
return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
|
|
612
705
|
} finally {
|
|
706
|
+
reporter.dispose();
|
|
613
707
|
await run?.unregister();
|
|
614
708
|
process.removeListener('SIGINT', cancel);
|
|
615
709
|
process.removeListener('SIGTERM', cancel);
|
|
@@ -622,12 +716,12 @@ async function askSourceQuestion(prompt, signal) {
|
|
|
622
716
|
finally { rl.close(); }
|
|
623
717
|
}
|
|
624
718
|
|
|
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) }) {
|
|
719
|
+
export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
|
|
720
|
+
backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
|
|
627
721
|
const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
|
|
628
722
|
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);
|
|
723
|
+
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
|
|
724
|
+
if (backend) onBackend?.(backend);
|
|
631
725
|
const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
|
|
632
726
|
let originalError;
|
|
633
727
|
if (!options.source && !options.listSources) {
|
|
@@ -638,10 +732,10 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
|
|
|
638
732
|
}
|
|
639
733
|
return {};
|
|
640
734
|
}
|
|
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');
|
|
735
|
+
catch (error) { originalError = error; }
|
|
736
|
+
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
737
|
+
if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
|
|
738
|
+
stderr.write('veo: No direct video found; searching the page for a playable source…\n');
|
|
645
739
|
}
|
|
646
740
|
let sources;
|
|
647
741
|
try {
|
|
@@ -656,18 +750,18 @@ export async function resolvePageSource(options, { signal, stderr, reporter, onB
|
|
|
656
750
|
}
|
|
657
751
|
if (options.listSources) return { sources, formatSource };
|
|
658
752
|
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);
|
|
753
|
+
if (!sources.length) {
|
|
754
|
+
if (originalError) throw originalError;
|
|
755
|
+
throw new Error('No downloadable video sources were found on this page.');
|
|
756
|
+
}
|
|
757
|
+
let index = Number(options.source);
|
|
758
|
+
if (!index) {
|
|
759
|
+
if (sources.length === 1) index = 1;
|
|
760
|
+
else if (!interactive) return { sources, formatSource, needsSelection: true };
|
|
761
|
+
}
|
|
762
|
+
if (!index) {
|
|
763
|
+
for (const source of sources) stderr.write(`${formatSource(source)}\n`);
|
|
764
|
+
const answer = await ask('Source number (Enter to cancel): ', signal);
|
|
671
765
|
if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
|
|
672
766
|
index = Number(answer.trim());
|
|
673
767
|
}
|