veodl 1.12.0 → 2.0.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/CHANGELOG.md +27 -0
- package/README.md +183 -100
- package/bin/veo.js +47 -2
- package/docs/AGENT_GUIDE.md +1 -0
- package/native/android-arm64/veo +0 -0
- package/native/darwin-arm64/veo +0 -0
- package/native/darwin-x64/veo +0 -0
- package/native/linux-arm64/veo +0 -0
- package/native/linux-x64/veo +0 -0
- package/native/win32-arm64/veo.exe +0 -0
- package/native/win32-x64/veo.exe +0 -0
- package/package.json +53 -61
- package/src/alias.js +0 -258
- package/src/backend-update.js +0 -191
- package/src/backend.js +0 -548
- package/src/changes.js +0 -278
- package/src/cli.js +0 -781
- package/src/compatibility.js +0 -39
- package/src/config-clipboard.js +0 -67
- package/src/config-diagnostics.js +0 -155
- package/src/config-editor.js +0 -404
- package/src/config-errors.js +0 -64
- package/src/config-reset.js +0 -39
- package/src/config-template.js +0 -227
- package/src/config.js +0 -307
- package/src/disk-space.js +0 -68
- package/src/doctor.js +0 -302
- package/src/download-cache.js +0 -31
- package/src/downloader.js +0 -669
- package/src/execution.js +0 -68
- package/src/experimental-fragments.js +0 -29
- package/src/flush.js +0 -66
- package/src/history.js +0 -173
- package/src/inline-region.js +0 -72
- package/src/inspect-media.js +0 -179
- package/src/interactive.js +0 -120
- package/src/jobs.js +0 -218
- package/src/legacy-config-comments.js +0 -106
- package/src/naming.js +0 -54
- package/src/open-file.js +0 -15
- package/src/option-suggestions.js +0 -43
- package/src/output.js +0 -81
- package/src/path-links.js +0 -53
- package/src/paths.js +0 -26
- package/src/playlist.js +0 -31
- package/src/progress.js +0 -341
- package/src/run-archive.js +0 -148
- package/src/runs.js +0 -346
- package/src/source-discovery.js +0 -303
- package/src/state.js +0 -27
- package/src/stats.js +0 -63
- package/src/terminal-size.js +0 -8
- package/src/terminal-title.js +0 -19
- package/src/tool-setup.js +0 -151
- package/src/uninstall.js +0 -222
- package/src/updater.js +0 -229
- package/src/utils.js +0 -208
- package/src/version.js +0 -38
package/src/cli.js
DELETED
|
@@ -1,781 +0,0 @@
|
|
|
1
|
-
import { outputOptions, outputStream, withOutputSettings } from './output.js';
|
|
2
|
-
import { validateFragments, confirmExperimentalFragments } from './experimental-fragments.js';
|
|
3
|
-
import { validateTemplate } from './naming.js';
|
|
4
|
-
import { parseArgs } from 'node:util';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { readFile } from 'node:fs/promises';
|
|
7
|
-
import { createInterface } from 'node:readline/promises';
|
|
8
|
-
import { createReporter } from './progress.js';
|
|
9
|
-
import { openFile } from './open-file.js';
|
|
10
|
-
import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
|
|
11
|
-
import { applyProfile, configMain, loadConfig, profileMain, selectedProfileName } from './config.js';
|
|
12
|
-
import { validateItems, describeEstimate } from './playlist.js';
|
|
13
|
-
import { parseSourceTimeout } from './source-discovery.js';
|
|
14
|
-
import { explainUnknownOption, optionSpellings, suggestOption } from './option-suggestions.js';
|
|
15
|
-
import { retryOptions, runJob, jobFilePath } from './jobs.js';
|
|
16
|
-
import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText, validateCookieFile, validateBrowserSpec, cookieFileWarning } from './utils.js';
|
|
17
|
-
|
|
18
|
-
export const HELP = `veo - simple video downloader
|
|
19
|
-
|
|
20
|
-
Usage:
|
|
21
|
-
veo <url> [<url>...] [options]
|
|
22
|
-
|
|
23
|
-
Options:
|
|
24
|
-
-q, --quality <quality> Video quality (best, 2160p, 1440p, 1080p, 720p, 480p, 360p)
|
|
25
|
-
Numeric qualities are an upper bound: -q 720p never
|
|
26
|
-
downloads 2160p. Use --closest-quality for the nearest
|
|
27
|
-
available resolution instead.
|
|
28
|
-
-o, --output <path> Output directory (default: current directory)
|
|
29
|
-
-r, --rename <name> Filename without extension; * inserts the original title
|
|
30
|
-
--closest-quality Pick the nearest available resolution, above or below
|
|
31
|
-
--open Open the saved file with your default app
|
|
32
|
-
--audio Download audio only (default: mp3)
|
|
33
|
-
--format <format> Video: mp4, mkv, webm, mov; audio: mp3, m4a, aac, opus, flac, wav
|
|
34
|
-
Video uses lossless remux; incompatible codecs fail.
|
|
35
|
-
--compatible Ensure MP4 H.264/AAC; converts only when needed (may lose quality)
|
|
36
|
-
--recode Allow video conversion (requires --format; may lose quality)
|
|
37
|
-
--concurrent-downloads <n> Parallel URLs/batch entries (1-4; default: 2)
|
|
38
|
-
--adaptive-concurrency Reduce connections and retry temporary failures (default: on)
|
|
39
|
-
--filename-template <s> Filename without extension, e.g. {index} - {title}
|
|
40
|
-
--folder-template <s> Relative folders, e.g. {channel}/{year}
|
|
41
|
-
--check-space Estimate cache/output space before downloading (default: on)
|
|
42
|
-
--timings Show phase timings (default: on; --no-timings disables)
|
|
43
|
-
--no-color Disable terminal colors (also respects NO_COLOR)
|
|
44
|
-
--playlist-concurrency <n> Simultaneous playlist downloads (1-4; default: 2)
|
|
45
|
-
--playlist Download every entry of a playlist or channel URL
|
|
46
|
-
-N, --concurrent-fragments <n>
|
|
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
|
|
50
|
-
--subs Download subtitles (default languages: en)
|
|
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
|
|
55
|
-
--embed-subs Embed subtitles into the video file
|
|
56
|
-
--embed-metadata Embed title, date and other metadata
|
|
57
|
-
--embed-thumbnail Embed the thumbnail
|
|
58
|
-
--sponsorblock-remove <categories>
|
|
59
|
-
Remove sponsor segments, e.g. "sponsor,selfpromo"
|
|
60
|
-
--section <range> Download only a time range, e.g. "*10:00-12:00"
|
|
61
|
-
--cookies <file> Netscape cookie file, for content you may access
|
|
62
|
-
--cookies-from-browser <browser[:profile]>
|
|
63
|
-
Read cookies from an installed browser
|
|
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
|
|
69
|
-
--list-formats Show the available formats and exit
|
|
70
|
-
--list-sources Find video sources loaded by a web page and exit
|
|
71
|
-
--auto-list-sources Search after no video is found (default: on)
|
|
72
|
-
--source <n> Select source number from that page (also for scripts)
|
|
73
|
-
--deep-scan Check every media candidate found during source search
|
|
74
|
-
--timeout <duration> Source search deadline, e.g. 30, 30s or 2m (5s-10m)
|
|
75
|
-
--dry-run Show what would be downloaded and exit
|
|
76
|
-
--verify Probe saved media files with FFprobe (default: off)
|
|
77
|
-
--json Print one JSON object per URL instead of prose
|
|
78
|
-
--profile <name> Apply a named config profile
|
|
79
|
-
--batch-file <file> Read URLs from a file (one per line; # comments)
|
|
80
|
-
--retry-failed <id|file> Retry failed/unfinished items by run ID or job file
|
|
81
|
-
--playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
|
|
82
|
-
--skip-existing Skip matching downloads still present on disk
|
|
83
|
-
--no-<boolean-option> Disable a stored boolean default, e.g. --no-open
|
|
84
|
-
-v, --version Show installed version (also: veo version)
|
|
85
|
-
-h, --help Show help (also: veo help)
|
|
86
|
-
|
|
87
|
-
Commands:
|
|
88
|
-
veo update|up [--check] Update veo itself with npm (up is a short alias)
|
|
89
|
-
veo backend update Install a newer yt-dlp release (see veo backend --help)
|
|
90
|
-
veo doctor Diagnose the local setup
|
|
91
|
-
veo flush Stop veo runs and clear temporary downloads and jobs
|
|
92
|
-
veo stats Show persistent download statistics
|
|
93
|
-
veo history Show the last 5 downloads (--json for scripting)
|
|
94
|
-
veo retry --last Retry the newest failed or unfinished job
|
|
95
|
-
veo history --failed --limit 20 Filter and extend download history
|
|
96
|
-
veo changes [<version>] List release notes; --since <version> for newer changes
|
|
97
|
-
veo runs [id] List active runs; add --json for metadata and progress
|
|
98
|
-
veo inspect <file> Read media metadata; --check-audio measures audio signal
|
|
99
|
-
veo subs <url> List available manual and automatic subtitles
|
|
100
|
-
veo inspect run <id> Inspect saved files from a finished run by its id
|
|
101
|
-
veo stop [id] Stop one run, or every active run
|
|
102
|
-
veo alias list|add|remove Manage extra command names (wrappers calling veo)
|
|
103
|
-
veo uninstall [-p <name>] Remove one alias, or everything with --yes
|
|
104
|
-
veo version Show the installed version
|
|
105
|
-
veo config edit|path|profiles|guide|check|show|reset Manage defaults and named profiles
|
|
106
|
-
veo profile [list|reset|name] Show, list or change the default profile
|
|
107
|
-
veo config edit [--external|--terminal] Choose the configuration editor
|
|
108
|
-
|
|
109
|
-
Run veo without arguments in a terminal for interactive setup.
|
|
110
|
-
Agent workflow: see docs/AGENT_GUIDE.md in the repository or installed package.
|
|
111
|
-
|
|
112
|
-
Defaults can be stored in the veo config file; veo doctor prints its location.
|
|
113
|
-
|
|
114
|
-
Examples:
|
|
115
|
-
veo "https://youtube.com/watch?v=..."
|
|
116
|
-
veo <url> -q 1080p
|
|
117
|
-
veo <url> --audio
|
|
118
|
-
veo <url> -o ./downloads
|
|
119
|
-
veo <url> -r "My Video" --open
|
|
120
|
-
veo <url1> <url2> --subs --embed-metadata
|
|
121
|
-
veo update --check
|
|
122
|
-
|
|
123
|
-
Only download content you are authorized or legally permitted to download.
|
|
124
|
-
`;
|
|
125
|
-
|
|
126
|
-
const STRING_OPTIONS = {
|
|
127
|
-
quality: { short: 'q' },
|
|
128
|
-
output: { short: 'o' },
|
|
129
|
-
rename: { short: 'r' },
|
|
130
|
-
format: {},
|
|
131
|
-
'playlist-concurrency': {},
|
|
132
|
-
'concurrent-downloads': {},
|
|
133
|
-
'filename-template': {},
|
|
134
|
-
'folder-template': {},
|
|
135
|
-
cookies: {},
|
|
136
|
-
'cookies-from-browser': {},
|
|
137
|
-
'concurrent-fragments': { short: 'N' },
|
|
138
|
-
'sub-langs': {},
|
|
139
|
-
'sub-format': {},
|
|
140
|
-
'sponsorblock-remove': {},
|
|
141
|
-
section: {},
|
|
142
|
-
profile: {},
|
|
143
|
-
'batch-file': {},
|
|
144
|
-
'retry-failed': {},
|
|
145
|
-
'playlist-items': {},
|
|
146
|
-
source: {},
|
|
147
|
-
timeout: {},
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const BOOLEAN_OPTIONS = {
|
|
151
|
-
'experimental-fragments': {},
|
|
152
|
-
compatible: {},
|
|
153
|
-
recode: {},
|
|
154
|
-
'adaptive-concurrency': {},
|
|
155
|
-
'check-space': {},
|
|
156
|
-
timings: {},
|
|
157
|
-
color: {},
|
|
158
|
-
open: {},
|
|
159
|
-
audio: {},
|
|
160
|
-
resume: {},
|
|
161
|
-
incognito: {},
|
|
162
|
-
'neutral-filename': {},
|
|
163
|
-
playlist: {},
|
|
164
|
-
subs: {},
|
|
165
|
-
'auto-subs': {},
|
|
166
|
-
'list-subs': {},
|
|
167
|
-
verify: {},
|
|
168
|
-
'embed-subs': {},
|
|
169
|
-
'embed-metadata': {},
|
|
170
|
-
'embed-thumbnail': {},
|
|
171
|
-
'closest-quality': {},
|
|
172
|
-
'list-qualities': {},
|
|
173
|
-
'list-qualitys': {},
|
|
174
|
-
'list-formats': {},
|
|
175
|
-
'list-sources': {},
|
|
176
|
-
'auto-list-sources': {},
|
|
177
|
-
'deep-scan': {},
|
|
178
|
-
'dry-run': {},
|
|
179
|
-
json: {},
|
|
180
|
-
help: { short: 'h' },
|
|
181
|
-
version: { short: 'v' },
|
|
182
|
-
'skip-existing': {},
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
// Config defaults, so an explicit flag always wins but a stored preference does not.
|
|
186
|
-
export function optionDefaults(config = {}) {
|
|
187
|
-
return {
|
|
188
|
-
quality: config.quality ?? 'best',
|
|
189
|
-
output: config.output ?? process.cwd(),
|
|
190
|
-
rename: config.rename,
|
|
191
|
-
format: config.format,
|
|
192
|
-
compatible: config.compatible ?? false,
|
|
193
|
-
recode: config.recode ?? false,
|
|
194
|
-
'concurrent-downloads': String(config.concurrentDownloads ?? 2),
|
|
195
|
-
'filename-template': config.filenameTemplate,
|
|
196
|
-
'folder-template': config.folderTemplate,
|
|
197
|
-
'adaptive-concurrency': config.adaptiveConcurrency ?? true,
|
|
198
|
-
'check-space': config.checkSpace ?? true,
|
|
199
|
-
timings: config.timings ?? true,
|
|
200
|
-
color: config.color ?? true,
|
|
201
|
-
'playlist-concurrency': String(config.playlistConcurrency ?? 2),
|
|
202
|
-
cookies: config.cookies,
|
|
203
|
-
'cookies-from-browser': config.cookiesFromBrowser,
|
|
204
|
-
'concurrent-fragments': String(config.concurrentFragments ?? 8),
|
|
205
|
-
'experimental-fragments': config.experimentalFragments ?? false,
|
|
206
|
-
'sub-langs': config.subLangs,
|
|
207
|
-
'sponsorblock-remove': config.sponsorblockRemove,
|
|
208
|
-
section: config.section,
|
|
209
|
-
open: config.open ?? false,
|
|
210
|
-
audio: config.audio ?? false,
|
|
211
|
-
resume: config.resume ?? false,
|
|
212
|
-
incognito: config.incognito ?? false,
|
|
213
|
-
'neutral-filename': config.neutralFilename ?? false,
|
|
214
|
-
playlist: config.playlist ?? false,
|
|
215
|
-
subs: config.subs ?? false,
|
|
216
|
-
'auto-subs': config.autoSubs ?? false,
|
|
217
|
-
'sub-format': config.subFormat,
|
|
218
|
-
verify: config.verify ?? false,
|
|
219
|
-
'embed-subs': config.embedSubs ?? false,
|
|
220
|
-
'embed-metadata': config.embedMetadata ?? false,
|
|
221
|
-
'embed-thumbnail': config.embedThumbnail ?? false,
|
|
222
|
-
'closest-quality': config.closestQuality ?? false,
|
|
223
|
-
json: config.json ?? false,
|
|
224
|
-
'skip-existing': config.skipExisting ?? false,
|
|
225
|
-
'playlist-items': config.playlistItems,
|
|
226
|
-
source: config.source,
|
|
227
|
-
timeout: config.timeout,
|
|
228
|
-
'deep-scan': config.deepScan ?? false,
|
|
229
|
-
'list-sources': config.listSources ?? false,
|
|
230
|
-
'auto-list-sources': config.autoListSources ?? true,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function cliOptions(config = {}) {
|
|
235
|
-
const defaults = optionDefaults(config);
|
|
236
|
-
const options = {};
|
|
237
|
-
for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
|
|
238
|
-
options[name] = { type: 'string', ...extra };
|
|
239
|
-
if (defaults[name] !== undefined) options[name].default = defaults[name];
|
|
240
|
-
}
|
|
241
|
-
for (const [name, extra] of Object.entries(BOOLEAN_OPTIONS)) {
|
|
242
|
-
options[name] = { type: 'boolean', ...extra };
|
|
243
|
-
if (defaults[name] !== undefined) options[name].default = defaults[name];
|
|
244
|
-
}
|
|
245
|
-
return options;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const COMMAND_FLAGS = {
|
|
249
|
-
stats: ['--json'], history: ['--json', '--limit', '--failed'],
|
|
250
|
-
changes: ['--json', '--version', '--since', '--to', '--range', '--latest'],
|
|
251
|
-
runs: ['--json'], stop: [], flush: ['--stats'],
|
|
252
|
-
inspect: ['--json', '--check-audio', '--search'],
|
|
253
|
-
doctor: ['--offline', '--output', '-o'],
|
|
254
|
-
update: ['--check'], up: ['--check'], upgrade: ['--check'], check: ['--check'],
|
|
255
|
-
backend: ['--check', '--keep-files'],
|
|
256
|
-
alias: ['--json', '--force', '--bin-dir'],
|
|
257
|
-
uninstall: ['--prefix', '-p', '--yes', '--keep-cache', '--keep-config', '--keep-aliases', '--json', '--bin-dir'],
|
|
258
|
-
config: ['--profile', '--external', '--terminal'],
|
|
259
|
-
profile: [],
|
|
260
|
-
};
|
|
261
|
-
// `changelog` is an alias, so it shares the flags of the command it stands for.
|
|
262
|
-
const ALIASES = { changelog: 'changes' };
|
|
263
|
-
const COMMAND_NAMES = [...Object.keys(COMMAND_FLAGS), ...Object.keys(ALIASES), 'retry', 'subs', 'help', 'version'];
|
|
264
|
-
|
|
265
|
-
function mistypedCommandOption(args) {
|
|
266
|
-
const command = args[0];
|
|
267
|
-
if (command && !command.startsWith('-') && !COMMAND_NAMES.includes(command)) {
|
|
268
|
-
const suggestion = suggestOption(command, COMMAND_NAMES);
|
|
269
|
-
if (suggestion) return `Unknown command "${command}". Did you mean "${suggestion}"?`;
|
|
270
|
-
}
|
|
271
|
-
const flags = Object.hasOwn(COMMAND_FLAGS, command) ? COMMAND_FLAGS[command]
|
|
272
|
-
: ALIASES[command] ? COMMAND_FLAGS[ALIASES[command]] : null;
|
|
273
|
-
const options = flags
|
|
274
|
-
? [...flags, '--help', '-h', '--no-color', '--color', '--profile']
|
|
275
|
-
: command === 'retry' ? [...optionSpellings(cliOptions()), '--last'] : null;
|
|
276
|
-
if (!options) return null; // Download flags are checked by parseCli itself.
|
|
277
|
-
const start = ['config', 'backend', 'alias'].includes(command) && args[1] && !args[1].startsWith('-') ? 2 : 1;
|
|
278
|
-
for (const arg of args.slice(start)) {
|
|
279
|
-
if (arg === '--') break;
|
|
280
|
-
const name = arg.split('=')[0];
|
|
281
|
-
if (!name.startsWith('-') || options.includes(name)) continue;
|
|
282
|
-
const suggestion = suggestOption(name, options);
|
|
283
|
-
if (suggestion) return `Unknown option "${name}". Did you mean "${suggestion}"?`;
|
|
284
|
-
}
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function parseCli(args, { config = {} } = {}) {
|
|
289
|
-
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
290
|
-
if (args[0] === 'version') {
|
|
291
|
-
if (args.length !== 1) throw new Error('Usage: veo version');
|
|
292
|
-
return { version: true };
|
|
293
|
-
}
|
|
294
|
-
let preliminary;
|
|
295
|
-
try { preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() }); }
|
|
296
|
-
catch (error) { throw explainUnknownOption(error, cliOptions()); }
|
|
297
|
-
const profileName = selectedProfileName(config, preliminary.values.profile);
|
|
298
|
-
config = applyProfile(config, preliminary.values.profile);
|
|
299
|
-
let parsed;
|
|
300
|
-
try { parsed = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) }); }
|
|
301
|
-
catch (error) { throw explainUnknownOption(error, cliOptions(config)); }
|
|
302
|
-
const { values, positionals, tokens } = parsed;
|
|
303
|
-
if (values.help || values.version) return values;
|
|
304
|
-
// A stored default conflicting with a flag typed right now is a user error;
|
|
305
|
-
// a stored default merely ignored by another flag is not.
|
|
306
|
-
const typed = {
|
|
307
|
-
quality: tokens.some(token => token.name === 'quality'),
|
|
308
|
-
closest: tokens.some(token => token.name === 'closest-quality'),
|
|
309
|
-
audio: tokens.some(token => ['audio', 'no-audio'].includes(token.name)),
|
|
310
|
-
};
|
|
311
|
-
// An explicit numeric --quality states video intent, so it overrides an
|
|
312
|
-
// audio-only default instead of turning into a confusing conflict error.
|
|
313
|
-
if (typed.quality && values.quality !== 'best' && values.audio && !typed.audio) values.audio = false;
|
|
314
|
-
if (!positionals.length && !values['batch-file'] && !values['retry-failed']) throw new Error('Provide at least one video URL. Run veo --help for usage.');
|
|
315
|
-
if (!QUALITIES.includes(values.quality) && !/^[1-9]\d{1,4}p$/.test(values.quality)) throw new Error(`Invalid quality. Choose: ${QUALITIES.join(', ')} or a numeric resolution.`);
|
|
316
|
-
if (values['playlist-items']) {
|
|
317
|
-
if (values.playlist === false && tokens.some(token => token.name === 'playlist')) {
|
|
318
|
-
if (tokens.some(token => token.name === 'playlist-items')) throw new Error('--playlist-items cannot be combined with --no-playlist.');
|
|
319
|
-
values['playlist-items'] = undefined;
|
|
320
|
-
} else { validateItems(values['playlist-items']); values.playlist = true; }
|
|
321
|
-
}
|
|
322
|
-
if (values['retry-failed'] && (positionals.length || values['batch-file'])) throw new Error('--retry-failed cannot be combined with URLs or --batch-file.');
|
|
323
|
-
if (values.incognito && values['retry-failed']) throw new Error('--incognito cannot retry a saved job. Use --no-incognito for this request.');
|
|
324
|
-
if (values.incognito && values.resume) {
|
|
325
|
-
if (tokens.some(token => token.name === 'resume')) throw new Error('--incognito cannot be combined with --resume.');
|
|
326
|
-
values.resume = false;
|
|
327
|
-
}
|
|
328
|
-
if (values.incognito && values['skip-existing']) {
|
|
329
|
-
if (tokens.some(token => token.name === 'skip-existing')) throw new Error('--incognito cannot be combined with --skip-existing.');
|
|
330
|
-
values['skip-existing'] = false;
|
|
331
|
-
}
|
|
332
|
-
if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
|
|
333
|
-
if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
|
|
334
|
-
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.');
|
|
335
|
-
if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
|
|
336
|
-
if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
|
|
337
|
-
if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
|
|
338
|
-
values['list-qualities'] = Boolean(values['list-qualities'] || values['list-qualitys']);
|
|
339
|
-
delete values['list-qualitys'];
|
|
340
|
-
if (values['list-qualities']) {
|
|
341
|
-
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-qualities accepts exactly one URL.');
|
|
342
|
-
if (values['list-formats'] || values['list-sources'] || values['list-subs']) throw new Error('--list-qualities cannot be combined with other listing actions.');
|
|
343
|
-
if (values.audio || values.playlist) throw new Error('--list-qualities requires a single video without --audio or --playlist.');
|
|
344
|
-
}
|
|
345
|
-
if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
|
|
346
|
-
if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
347
|
-
if (values['list-subs']) {
|
|
348
|
-
if (positionals.length !== 1 || values['batch-file'] || values['retry-failed']) throw new Error('--list-subs accepts exactly one URL.');
|
|
349
|
-
if (values['list-formats'] || values['list-sources']) throw new Error('--list-subs cannot be combined with other listing actions.');
|
|
350
|
-
if (values.playlist) throw new Error('--list-subs requires a single video without --playlist.');
|
|
351
|
-
}
|
|
352
|
-
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.');
|
|
353
|
-
if (values.source && (!/^[1-9]\d*$/.test(values.source) || !Number.isSafeInteger(Number(values.source)))) throw new Error('--source requires a positive source number.');
|
|
354
|
-
if (values.source && values['list-sources'] && !tokens.some(token => token.name === 'list-sources')) values['list-sources'] = false;
|
|
355
|
-
if (values['list-sources'] && !tokens.some(token => token.name === 'list-sources')
|
|
356
|
-
&& (positionals.length !== 1 || values['batch-file'] || values['retry-failed'])) values['list-sources'] = false;
|
|
357
|
-
if (values['list-sources'] && (positionals.length !== 1 || values['batch-file'] || values['retry-failed'])) throw new Error('--list-sources accepts exactly one URL.');
|
|
358
|
-
if (values.source && (positionals.length > 1 || values['batch-file'])) throw new Error('--source accepts exactly one URL.');
|
|
359
|
-
if (values.source && values['list-sources']) throw new Error('--source and --list-sources are separate actions.');
|
|
360
|
-
if (values.timeout) parseSourceTimeout(values.timeout);
|
|
361
|
-
if ((values.source || values['list-sources']) && !tokens.some(token => ['playlist', 'playlist-items'].includes(token.name))) {
|
|
362
|
-
values.playlist = false;
|
|
363
|
-
values['playlist-items'] = undefined;
|
|
364
|
-
}
|
|
365
|
-
if ((values.source || values['list-sources']) && values.playlist) throw new Error('--source and --list-sources cannot be combined with --playlist.');
|
|
366
|
-
if (values['concurrent-fragments'] !== undefined) {
|
|
367
|
-
const fragments = Number(values['concurrent-fragments']);
|
|
368
|
-
validateFragments(fragments);
|
|
369
|
-
}
|
|
370
|
-
const concurrency = Number(values['playlist-concurrency']);
|
|
371
|
-
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
|
|
372
|
-
if (values.compatible && (values.audio || (values.format && values.format !== 'mp4') || values.recode || values['embed-subs'] || values['embed-thumbnail'])) throw new Error('--compatible requires video MP4 without --recode, --embed-subs or --embed-thumbnail.');
|
|
373
|
-
if (values.compatible) values.format = 'mp4';
|
|
374
|
-
if (values.recode && (!values.format || values.audio)) throw new Error('--recode requires --format and video mode.');
|
|
375
|
-
if (values.section && !/^[*\d]/.test(values.section.trim())) throw new Error('--section requires a range such as "*10:00-12:00" or "10:00-12:00".');
|
|
376
|
-
if (values['sponsorblock-remove'] && !/^[a-z_,-]+$/i.test(values['sponsorblock-remove'].trim())) throw new Error('--sponsorblock-remove takes comma-separated category names, e.g. "sponsor,selfpromo".');
|
|
377
|
-
const formats = values.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
378
|
-
if (values.format && !formats.includes(values.format)) throw new Error(`Invalid ${values.audio ? 'audio' : 'video'} format. Choose: ${formats.join(', ')}.${!values.audio && AUDIO_FORMATS.includes(values.format) ? ' Use --audio for audio formats.' : ''}`);
|
|
379
|
-
|
|
380
|
-
const options = {};
|
|
381
|
-
for (const [name, value] of Object.entries(values)) {
|
|
382
|
-
if (value === undefined) continue;
|
|
383
|
-
options[name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
|
|
384
|
-
}
|
|
385
|
-
options.urls = positionals.map(validateUrl);
|
|
386
|
-
options.url = options.urls[0];
|
|
387
|
-
if (values.cookies !== undefined) options.cookies = validateCookieFile(values.cookies);
|
|
388
|
-
if (values['cookies-from-browser'] !== undefined) options.cookiesFromBrowser = validateBrowserSpec(values['cookies-from-browser']);
|
|
389
|
-
if (options.section) options.section = options.section.trim();
|
|
390
|
-
if (options.sponsorblockRemove) options.sponsorblockRemove = options.sponsorblockRemove.trim();
|
|
391
|
-
if (options.concurrentFragments !== undefined) options.concurrentFragments = Number(options.concurrentFragments);
|
|
392
|
-
if (options.timeout) options.timeoutMs = parseSourceTimeout(options.timeout);
|
|
393
|
-
options.playlistConcurrency = concurrency;
|
|
394
|
-
options.concurrentDownloads = Number(values['concurrent-downloads']);
|
|
395
|
-
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.');
|
|
396
|
-
if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
|
|
397
|
-
if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
|
|
398
|
-
if (options.rename && options.filenameTemplate && !options.neutralFilename) throw new Error('--rename and --filename-template cannot be combined.');
|
|
399
|
-
const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
|
|
400
|
-
if (options.subLangs && !disableSubs && !options.autoSubs) options.subs = true;
|
|
401
|
-
if (disableSubs) {
|
|
402
|
-
options.embedSubs = false;
|
|
403
|
-
if (!options.autoSubs) options.subLangs = undefined;
|
|
404
|
-
}
|
|
405
|
-
options.profile = profileName;
|
|
406
|
-
return options;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export async function main(args = process.argv.slice(2), { config } = {}) {
|
|
410
|
-
if (args[0] === 'help') args = ['--help', ...args.slice(1)];
|
|
411
|
-
const mistyped = mistypedCommandOption(args);
|
|
412
|
-
if (mistyped) { process.stderr.write(`veo: ${mistyped}\n`); return 1; }
|
|
413
|
-
let color = !args.includes('--no-color') && !args.includes('--json');
|
|
414
|
-
let display;
|
|
415
|
-
let loadedConfig;
|
|
416
|
-
try {
|
|
417
|
-
display = outputOptions(args);
|
|
418
|
-
const loaded = config ?? await loadConfig();
|
|
419
|
-
loadedConfig = loaded.config || {};
|
|
420
|
-
color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
|
|
421
|
-
// Validate explicit profiles even when a color flag overrides their setting.
|
|
422
|
-
if (display.profile) applyProfile(loaded.config || {}, display.profile);
|
|
423
|
-
} catch (error) {
|
|
424
|
-
if (!display || display.profile) {
|
|
425
|
-
process.stderr.write(`veo: ${readableError(error)}\n`);
|
|
426
|
-
return 1;
|
|
427
|
-
}
|
|
428
|
-
// The command's own validation reports configuration errors.
|
|
429
|
-
}
|
|
430
|
-
if (['stats', 'history', 'changes', 'changelog', 'flush', 'runs', 'inspect', 'stop', 'update', 'up', 'upgrade', 'check', 'doctor', 'backend', 'alias', 'uninstall'].includes(args[0])) args = display.remaining;
|
|
431
|
-
const code = await withOutputSettings(color, () => runMain(args, { config }));
|
|
432
|
-
// One throttled (once per day) update hint after every successful command.
|
|
433
|
-
// Failures, cancellations, help/version output and the update commands
|
|
434
|
-
// themselves never trigger it; VEO_NO_UPDATE_CHECK=1 disables it for scripts.
|
|
435
|
-
let incognitoRequest = !args.length;
|
|
436
|
-
if (!incognitoRequest && code === 0 && shouldUpdateNotice(args)) {
|
|
437
|
-
try { incognitoRequest = Boolean(parseCli(args, { config: loadedConfig }).incognito); }
|
|
438
|
-
catch { /* Non-download commands have their own argument parser. */ }
|
|
439
|
-
}
|
|
440
|
-
if (code === 0 && shouldUpdateNotice(args) && !incognitoRequest) {
|
|
441
|
-
try {
|
|
442
|
-
const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
|
|
443
|
-
if (notice) process.stderr.write(`${notice}\n`);
|
|
444
|
-
} catch {
|
|
445
|
-
// Informational only: a broken notice must never fail the command.
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return code;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Help, version and the update commands report versions themselves;
|
|
452
|
-
// anything else earns the daily update hint on success.
|
|
453
|
-
export function shouldUpdateNotice(args = []) {
|
|
454
|
-
if (args.includes('-h') || args.includes('--help') || args.includes('-v') || args.includes('--version')) return false;
|
|
455
|
-
if (['update', 'up', 'upgrade', 'check', 'version', 'help', 'changes', 'changelog'].includes(args[0])) return false;
|
|
456
|
-
return true;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function runMain(args, { config }) {
|
|
460
|
-
const stdout = outputStream(process.stdout, { plain: args.includes('--json') });
|
|
461
|
-
const stderr = outputStream(process.stderr, { plain: args.includes('--json') });
|
|
462
|
-
if (args[0] === 'stats') {
|
|
463
|
-
try { return await (await import('./stats.js')).statsMain(args.slice(1)); }
|
|
464
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
465
|
-
}
|
|
466
|
-
if (args[0] === 'history') {
|
|
467
|
-
try { return await (await import('./history.js')).historyMain(args.slice(1)); }
|
|
468
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
469
|
-
}
|
|
470
|
-
if (args[0] === 'changes' || args[0] === 'changelog') {
|
|
471
|
-
try { return await (await import('./changes.js')).changesMain(args.slice(1), { stdout }); }
|
|
472
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
473
|
-
}
|
|
474
|
-
if (args[0] === 'flush') {
|
|
475
|
-
try { return await (await import('./flush.js')).flushMain(args.slice(1)); }
|
|
476
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
477
|
-
}
|
|
478
|
-
if (args[0] === 'runs') {
|
|
479
|
-
try { return await (await import('./runs.js')).runsMain(args.slice(1)); }
|
|
480
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
481
|
-
}
|
|
482
|
-
if (args[0] === 'inspect') {
|
|
483
|
-
try { return await (await import('./inspect-media.js')).inspectMain(args.slice(1)); }
|
|
484
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return error.name === 'AbortError' ? 130 : 1; }
|
|
485
|
-
}
|
|
486
|
-
if (args[0] === 'stop') {
|
|
487
|
-
try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
|
|
488
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
489
|
-
}
|
|
490
|
-
if (args[0] === 'retry') {
|
|
491
|
-
if (args[1] !== '--last') {
|
|
492
|
-
stderr.write('veo: Usage: veo retry --last [download options]\n');
|
|
493
|
-
return 1;
|
|
494
|
-
}
|
|
495
|
-
try {
|
|
496
|
-
const { latestFailedJob } = await import('./jobs.js');
|
|
497
|
-
args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
|
|
498
|
-
} catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
499
|
-
}
|
|
500
|
-
if (args[0] === 'subs') {
|
|
501
|
-
if (args[1] === '--help' || args[1] === '-h') {
|
|
502
|
-
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');
|
|
503
|
-
return 0;
|
|
504
|
-
}
|
|
505
|
-
if (!args[1] || args[1].startsWith('-')) {
|
|
506
|
-
stderr.write('veo: Usage: veo subs <url> [--json] [--source <n>]\n');
|
|
507
|
-
return 1;
|
|
508
|
-
}
|
|
509
|
-
args = [args[1], '--list-subs', ...args.slice(2)];
|
|
510
|
-
}
|
|
511
|
-
if (args[0] === 'profile') {
|
|
512
|
-
try { return await profileMain(args.slice(1)); }
|
|
513
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
514
|
-
}
|
|
515
|
-
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
516
|
-
await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
|
|
517
|
-
// update/upgrade/up/check subcommands are handled before URL validation.
|
|
518
|
-
if (['update', 'up', 'upgrade', 'check'].includes(args[0])) {
|
|
519
|
-
if (args[0] === 'up') args = ['update', ...args.slice(1)];
|
|
520
|
-
if (args[0] === 'check' && args[1] !== 'update') {
|
|
521
|
-
stdout.write(UPDATE_HELP);
|
|
522
|
-
return 0;
|
|
523
|
-
}
|
|
524
|
-
return updateMain(args, { registry: defaultRegistry() });
|
|
525
|
-
}
|
|
526
|
-
if (args[0] === 'config') {
|
|
527
|
-
try { return await configMain(args.slice(1)); }
|
|
528
|
-
catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
|
|
529
|
-
}
|
|
530
|
-
if (args[0] === 'doctor') {
|
|
531
|
-
try {
|
|
532
|
-
const { doctorMain } = await import('./doctor.js');
|
|
533
|
-
return await doctorMain(args.slice(1));
|
|
534
|
-
} catch (error) {
|
|
535
|
-
stderr.write(`veo: ${readableError(error)}\n`);
|
|
536
|
-
return 1;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
if (args[0] === 'backend') {
|
|
540
|
-
try {
|
|
541
|
-
const { backendUpdateMain } = await import('./backend-update.js');
|
|
542
|
-
return await backendUpdateMain(args.slice(1));
|
|
543
|
-
} catch (error) {
|
|
544
|
-
stderr.write(`veo: ${readableError(error)}\n`);
|
|
545
|
-
return 1;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (args[0] === 'alias') {
|
|
549
|
-
try {
|
|
550
|
-
const { aliasMain } = await import('./alias.js');
|
|
551
|
-
return await aliasMain(args.slice(1));
|
|
552
|
-
} catch (error) {
|
|
553
|
-
stderr.write(`veo: ${readableError(error)}\n`);
|
|
554
|
-
return 1;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
if (args[0] === 'uninstall') {
|
|
558
|
-
try {
|
|
559
|
-
const { uninstallMain } = await import('./uninstall.js');
|
|
560
|
-
return await uninstallMain(args.slice(1));
|
|
561
|
-
} catch (error) {
|
|
562
|
-
stderr.write(`veo: ${readableError(error)}\n`);
|
|
563
|
-
return 1;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
const reporter = createReporter();
|
|
567
|
-
const controller = new AbortController();
|
|
568
|
-
const cancel = () => controller.abort();
|
|
569
|
-
let run;
|
|
570
|
-
process.once('SIGINT', cancel);
|
|
571
|
-
process.once('SIGTERM', cancel);
|
|
572
|
-
try {
|
|
573
|
-
const loaded = config ?? await loadConfig();
|
|
574
|
-
for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
|
|
575
|
-
if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
|
|
576
|
-
const { interactiveArgs } = await import('./interactive.js');
|
|
577
|
-
args = await interactiveArgs(loaded.config || {}, { signal: controller.signal });
|
|
578
|
-
if (!args) return 0;
|
|
579
|
-
}
|
|
580
|
-
let options = parseCli(args, { config: loaded.config });
|
|
581
|
-
if (options.help) { stdout.write(HELP); return 0; }
|
|
582
|
-
if (options.version) {
|
|
583
|
-
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
584
|
-
stdout.write(`${pkg.version}\n`);
|
|
585
|
-
return 0;
|
|
586
|
-
}
|
|
587
|
-
if (!options.incognito) run = await (await import('./runs.js')).registerRun(cancel);
|
|
588
|
-
let retryItems;
|
|
589
|
-
if (options.retryFailed) {
|
|
590
|
-
retryItems = await retryOptions(options.retryFailed);
|
|
591
|
-
// Revalidate stored options and apply only flags explicitly supplied now.
|
|
592
|
-
const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
|
|
593
|
-
const overrideProfile = retryArgs.some(arg => arg === '--profile' || arg.startsWith('--profile='));
|
|
594
|
-
retryItems = retryItems.map(item => {
|
|
595
|
-
const resumed = parseCli([item.url, ...retryArgs], { config: overrideProfile
|
|
596
|
-
? { ...item, profiles: loaded.config?.profiles }
|
|
597
|
-
: item });
|
|
598
|
-
if (!overrideProfile) resumed.profile = item.profile;
|
|
599
|
-
return resumed;
|
|
600
|
-
});
|
|
601
|
-
options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
|
|
602
|
-
}
|
|
603
|
-
if (options.batchFile) {
|
|
604
|
-
const lines = (await readFile(options.batchFile, 'utf8')).replace(/^\uFEFF/, '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'));
|
|
605
|
-
options.urls.push(...lines.map(validateUrl));
|
|
606
|
-
options.url = options.urls[0];
|
|
607
|
-
if (!options.urls.length) throw new Error('The URL list is empty.');
|
|
608
|
-
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.');
|
|
609
|
-
if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
|
|
610
|
-
}
|
|
611
|
-
await confirmExperimentalFragments(retryItems || [options], { signal: controller.signal });
|
|
612
|
-
// The job file is created before the first download, so another terminal can
|
|
613
|
-
// follow this run's per-item progress with `veo runs <id>`.
|
|
614
|
-
const jobFile = options.incognito || options.listQualities || options.listFormats || options.listSources || options.listSubs || options.dryRun ? null : jobFilePath();
|
|
615
|
-
await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
|
|
616
|
-
audio: options.audio, quality: options.quality, format: options.format, profile: options.profile || null,
|
|
617
|
-
playlist: options.playlist, job: jobFile });
|
|
618
|
-
reporter.configure?.({ color: options.color && !options.json });
|
|
619
|
-
reporter.start(options.rename);
|
|
620
|
-
if (!options.dryRun && !options.listQualities && !options.listFormats && !options.listSources && !options.listSubs) reporter.profile(options.profile);
|
|
621
|
-
const preparedBackends = new Map();
|
|
622
|
-
const reuseBackend = request => preparedBackends.has(request.url) ? async () => preparedBackends.get(request.url) : undefined;
|
|
623
|
-
const cookieWarning = cookieFileWarning(options.cookies);
|
|
624
|
-
if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
|
|
625
|
-
|
|
626
|
-
// For a single page, fall back to media requests observed while its player loads.
|
|
627
|
-
// Explicit --source always rediscovers: signed media URLs can expire between runs.
|
|
628
|
-
if (!retryItems && options.urls.length === 1 && (!options.playlist || options.listSources)) {
|
|
629
|
-
const discovery = await resolvePageSource(options, { signal: controller.signal, stderr, reporter,
|
|
630
|
-
onBackend: backend => preparedBackends.set(options.url, backend) });
|
|
631
|
-
if (options.listSources) {
|
|
632
|
-
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`);
|
|
633
|
-
else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
|
|
634
|
-
if (discovery.sources.timedOut) stderr.write(`veo: Source search timed out after ${options.timeout || (options.deepScan ? '120s' : '45s')}; showing verified sources found so far.\n`);
|
|
635
|
-
if (!discovery.sources.length) stderr.write(discovery.sources.timedOut
|
|
636
|
-
? 'veo: No sources were verified before the timeout.\n'
|
|
637
|
-
: 'veo: No downloadable sources were found on this page.\n');
|
|
638
|
-
return discovery.sources.length && !discovery.sources.timedOut ? 0 : 1;
|
|
639
|
-
}
|
|
640
|
-
if (discovery.needsSelection) {
|
|
641
|
-
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`);
|
|
642
|
-
else for (const source of discovery.sources) stdout.write(`${discovery.formatSource(source)}\n`);
|
|
643
|
-
stderr.write('veo: Select a source with --source <number>.\n');
|
|
644
|
-
return 1;
|
|
645
|
-
}
|
|
646
|
-
if (discovery.mediaUrl) {
|
|
647
|
-
options.mediaUrl = discovery.mediaUrl;
|
|
648
|
-
options.source = String(discovery.source);
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
if (retryItems) for (const item of retryItems) {
|
|
652
|
-
if (!item.source) continue;
|
|
653
|
-
const discovery = await resolvePageSource(item, { signal: controller.signal, stderr, reporter,
|
|
654
|
-
onBackend: backend => preparedBackends.set(item.url, backend) });
|
|
655
|
-
item.mediaUrl = discovery.mediaUrl;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (options.listSubs) {
|
|
659
|
-
const { listSubtitles } = await import('./downloader.js');
|
|
660
|
-
const result = await listSubtitles(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
661
|
-
if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
|
|
662
|
-
else {
|
|
663
|
-
stdout.write(`Subtitles: ${cleanText(result.title || options.url)}\n`);
|
|
664
|
-
for (const [label, entries] of [['Manual', result.manual], ['Automatic', result.automatic]]) {
|
|
665
|
-
stdout.write(`${label}:\n`);
|
|
666
|
-
if (!entries.length) stdout.write(' none\n');
|
|
667
|
-
for (const entry of entries) stdout.write(` ${cleanText(entry.language)}${entry.name ? ` (${cleanText(entry.name)})` : ''}: ${entry.formats.join(', ') || 'unknown'}\n`);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
return 0;
|
|
671
|
-
}
|
|
672
|
-
if (options.listQualities) {
|
|
673
|
-
const { listQualities } = await import('./downloader.js');
|
|
674
|
-
const result = await listQualities(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) });
|
|
675
|
-
if (options.json) stdout.write(JSON.stringify(result) + '\n');
|
|
676
|
-
else {
|
|
677
|
-
stdout.write('Available qualities: ' + (result.qualities.join(', ') || 'resolution unknown') + '\n');
|
|
678
|
-
if (result.qualities.length) stdout.write('Choose with --quality ' + result.qualities[0] + '\n');
|
|
679
|
-
}
|
|
680
|
-
return 0;
|
|
681
|
-
}
|
|
682
|
-
if (options.listFormats) {
|
|
683
|
-
const { listFormats } = await import('./downloader.js');
|
|
684
|
-
stdout.write(await listFormats(options, { signal: controller.signal, reporter, backendResolver: reuseBackend(options) }));
|
|
685
|
-
return 0;
|
|
686
|
-
}
|
|
687
|
-
if (options.dryRun) {
|
|
688
|
-
const { planDownload } = await import('./downloader.js');
|
|
689
|
-
for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
|
|
690
|
-
const { url } = request;
|
|
691
|
-
const plan = await planDownload(request, { signal: controller.signal, reporter, backendResolver: reuseBackend(request) });
|
|
692
|
-
if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
|
|
693
|
-
else {
|
|
694
|
-
stdout.write(`URL: ${url}\n`);
|
|
695
|
-
stdout.write(`${describeEstimate(plan)}\n`);
|
|
696
|
-
if (plan.quality) stdout.write(`${plan.quality}\n`);
|
|
697
|
-
for (const entry of plan.entries) stdout.write(`Would save: ${cleanText(entry.path)}\n`);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
return 0;
|
|
701
|
-
}
|
|
702
|
-
const { download } = await import('./downloader.js');
|
|
703
|
-
const { createStatsRecorder } = await import('./stats.js');
|
|
704
|
-
const { createHistoryRecorder } = await import('./history.js');
|
|
705
|
-
if (!options.json && process.stdout.isTTY) reporter.enableInline();
|
|
706
|
-
const result = await runJob(options, { download: (request, dependencies) => download(request, { ...dependencies, backendResolver: reuseBackend(request) }),
|
|
707
|
-
reporter, stdout: reporter.output(stdout), stderr: reporter.output(stderr), signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id,
|
|
708
|
-
recordStats: options.incognito ? undefined : createStatsRecorder(), recordHistory: options.incognito ? undefined : createHistoryRecorder() });
|
|
709
|
-
return result;
|
|
710
|
-
} catch (error) {
|
|
711
|
-
reporter.fail(controller.signal.aborted);
|
|
712
|
-
reporter.dispose();
|
|
713
|
-
stderr.write(`veo: ${readableError(error)}\n`);
|
|
714
|
-
return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
|
|
715
|
-
} finally {
|
|
716
|
-
reporter.dispose();
|
|
717
|
-
await run?.unregister();
|
|
718
|
-
process.removeListener('SIGINT', cancel);
|
|
719
|
-
process.removeListener('SIGTERM', cancel);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
async function askSourceQuestion(prompt, signal) {
|
|
724
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
725
|
-
try { return await rl.question(prompt, { signal }); }
|
|
726
|
-
finally { rl.close(); }
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
export async function resolvePageSource(options, { signal, stderr, reporter, onBackend, inspect, discover, ask = askSourceQuestion,
|
|
730
|
-
backendResolver, interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY && !options.json) }) {
|
|
731
|
-
const { fetchMetadata, prepareBackend, runBackend } = await import('./downloader.js');
|
|
732
|
-
const { discoverSources, formatSource, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
|
|
733
|
-
const backend = inspect ? null : await (backendResolver || prepareBackend)(options, { signal, reporter });
|
|
734
|
-
if (backend) onBackend?.(backend);
|
|
735
|
-
const inspectMedia = (request, requestSignal = signal) => inspect ? inspect(request, requestSignal) : fetchMetadata(request, { signal: requestSignal, backend, runner: runBackend });
|
|
736
|
-
let originalError;
|
|
737
|
-
if (!options.source && !options.listSources) {
|
|
738
|
-
try {
|
|
739
|
-
const metadata = await inspectMedia(options);
|
|
740
|
-
if (!metadata.entries?.length && !metadata.formats?.length && !metadata.url) {
|
|
741
|
-
throw new Error('No downloadable video formats found.');
|
|
742
|
-
}
|
|
743
|
-
return {};
|
|
744
|
-
}
|
|
745
|
-
catch (error) { originalError = error; }
|
|
746
|
-
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
747
|
-
if (options.autoListSources === false) throw new Error(`${originalError.message} Use --list-sources to search this page, then --source <number> to select one.`);
|
|
748
|
-
stderr.write('veo: No direct video found; searching the page for a playable source…\n');
|
|
749
|
-
}
|
|
750
|
-
let sources;
|
|
751
|
-
try {
|
|
752
|
-
sources = await (discover || discoverSources)(options.url, {
|
|
753
|
-
signal, deepScan: options.deepScan, timeoutMs: options.timeoutMs,
|
|
754
|
-
inspect: (url, _page, { signal: scanSignal } = {}) => inspectMedia({ ...options, mediaUrl: url }, scanSignal),
|
|
755
|
-
});
|
|
756
|
-
} catch (error) {
|
|
757
|
-
if (signal?.aborted || error?.name === 'AbortError') throw error;
|
|
758
|
-
if (originalError) throw new Error(`${originalError.message} Source discovery also failed: ${error.message}`);
|
|
759
|
-
throw error;
|
|
760
|
-
}
|
|
761
|
-
if (options.listSources) return { sources, formatSource };
|
|
762
|
-
if (sources.timedOut) stderr.write(`veo: Source search timed out after ${options.timeout || (options.deepScan ? '120s' : '45s')}; showing verified sources found so far.\n`);
|
|
763
|
-
if (!sources.length) {
|
|
764
|
-
if (originalError) throw originalError;
|
|
765
|
-
throw new Error('No downloadable video sources were found on this page.');
|
|
766
|
-
}
|
|
767
|
-
let index = Number(options.source);
|
|
768
|
-
if (!index) {
|
|
769
|
-
if (sources.length === 1) index = 1;
|
|
770
|
-
else if (!interactive) return { sources, formatSource, needsSelection: true };
|
|
771
|
-
}
|
|
772
|
-
if (!index) {
|
|
773
|
-
for (const source of sources) stderr.write(`${formatSource(source)}\n`);
|
|
774
|
-
const answer = await ask('Source number (Enter to cancel): ', signal);
|
|
775
|
-
if (!/^[1-9]\d*$/.test(answer.trim())) throw new Error('No source selected.');
|
|
776
|
-
index = Number(answer.trim());
|
|
777
|
-
}
|
|
778
|
-
const selected = sources[index - 1];
|
|
779
|
-
if (!selected) throw new Error(`Source ${index} does not exist; choose 1-${sources.length}.`);
|
|
780
|
-
return { mediaUrl: selected.url, source: index };
|
|
781
|
-
}
|