veodl 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,454 @@
1
+ import { outputOptions, outputStream, withOutputSettings } from './output.js';
2
+ import { validateTemplate } from './naming.js';
3
+ import { parseArgs } from 'node:util';
4
+ import path from 'node:path';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { createReporter } from './progress.js';
7
+ import { openFile } from './open-file.js';
8
+ import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
9
+ import { applyProfile, configMain, loadConfig } from './config.js';
10
+ import { validateItems, describeEstimate } from './playlist.js';
11
+ import { retryOptions, runJob, jobFilePath } from './jobs.js';
12
+ import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText, validateCookieFile, validateBrowserSpec, cookieFileWarning } from './utils.js';
13
+
14
+ export const HELP = `veo - simple video downloader
15
+
16
+ Usage:
17
+ veo <url> [<url>...] [options]
18
+
19
+ Options:
20
+ -q, --quality <quality> Video quality (best, 2160p, 1440p, 1080p, 720p, 480p, 360p)
21
+ Numeric qualities are an upper bound: -q 720p never
22
+ downloads 2160p. Use --closest-quality for the nearest
23
+ available resolution instead.
24
+ -o, --output <path> Output directory (default: current directory)
25
+ -r, --rename <name> Filename without extension; * inserts the original title
26
+ --closest-quality Pick the nearest available resolution, above or below
27
+ --open Open the saved file with your default app
28
+ --audio Download audio only (default: mp3)
29
+ --format <format> Video: mp4, mkv, webm, mov; audio: mp3, m4a, aac, opus, flac, wav
30
+ Video uses lossless remux; incompatible codecs fail.
31
+ --compatible Ensure MP4 H.264/AAC; converts only when needed (may lose quality)
32
+ --recode Allow video conversion (requires --format; may lose quality)
33
+ --concurrent-downloads <n> Parallel URLs/batch entries (1-4; default: 2)
34
+ --adaptive-concurrency Reduce connections and retry temporary failures (default: on)
35
+ --filename-template <s> Filename without extension, e.g. {index} - {title}
36
+ --folder-template <s> Relative folders, e.g. {channel}/{year}
37
+ --check-space Estimate cache/output space before downloading (default: on)
38
+ --timings Show phase timings (default: on; --no-timings disables)
39
+ --no-color Disable terminal colors (also respects NO_COLOR)
40
+ --playlist-concurrency <n> Simultaneous playlist downloads (1-4; default: 2)
41
+ --playlist Download every entry of a playlist or channel URL
42
+ -N, --concurrent-fragments <n>
43
+ Download this many fragments in parallel (1-16; default: 8)
44
+ --subs Download subtitles (default languages: en)
45
+ --sub-langs <langs> Subtitle languages, e.g. "de,en" (implies --subs)
46
+ --embed-subs Embed subtitles into the video file
47
+ --embed-metadata Embed title, date and other metadata
48
+ --embed-thumbnail Embed the thumbnail
49
+ --sponsorblock-remove <categories>
50
+ Remove sponsor segments, e.g. "sponsor,selfpromo"
51
+ --section <range> Download only a time range, e.g. "*10:00-12:00"
52
+ --cookies <file> Netscape cookie file, for content you may access
53
+ --cookies-from-browser <browser[:profile]>
54
+ Read cookies from an installed browser
55
+ --resume Keep partial data and continue an interrupted download
56
+ --list-formats Show the available formats and exit
57
+ --dry-run Show what would be downloaded and exit
58
+ --json Print one JSON object per URL instead of prose
59
+ --profile <name> Apply a named config profile
60
+ --batch-file <file> Read URLs from a file (one per line; # comments)
61
+ --retry-failed <file> Retry failed/unfinished items from a saved job
62
+ --playlist-items <list> Select playlist entries, e.g. 1,3-5 (implies --playlist)
63
+ --skip-existing Skip matching downloads still present on disk
64
+ --no-<boolean-option> Disable a stored boolean default, e.g. --no-open
65
+ -v, --version Show installed version (also: veo version)
66
+ -h, --help Show help (also: veo help)
67
+
68
+ Commands:
69
+ veo update [--check] Update veo itself with npm
70
+ veo backend update Install a newer yt-dlp release (see veo backend --help)
71
+ veo doctor Diagnose the local setup
72
+ veo flush Stop veo runs and clear temporary downloads and jobs
73
+ veo stats Show persistent download statistics
74
+ veo history Show the last 5 downloads (--json for scripting)
75
+ veo retry --last Retry the newest failed or unfinished job
76
+ veo history --failed --limit 20 Filter and extend download history
77
+ veo runs [id] List active runs; add --json for metadata and progress
78
+ veo inspect <file> Read media metadata; --check-audio measures audio signal
79
+ veo inspect run <id> Inspect saved files from a finished run by its id
80
+ veo stop [id] Stop one run, or every active run
81
+ veo version Show the installed version
82
+ veo config edit|path|profiles|check|show|reset Manage defaults and named profiles
83
+ veo config edit [--external|--terminal] Choose the configuration editor
84
+
85
+ Run veo without arguments in a terminal for interactive setup.
86
+ Agent workflow: see docs/AGENT_GUIDE.md in the repository or installed package.
87
+
88
+ Defaults can be stored in the veo config file; veo doctor prints its location.
89
+
90
+ Examples:
91
+ veo "https://youtube.com/watch?v=..."
92
+ veo <url> -q 1080p
93
+ veo <url> --audio
94
+ veo <url> -o ./downloads
95
+ veo <url> -r "My Video" --open
96
+ veo <url1> <url2> --subs --embed-metadata
97
+ veo update --check
98
+
99
+ Only download content you are authorized or legally permitted to download.
100
+ `;
101
+
102
+ const STRING_OPTIONS = {
103
+ quality: { short: 'q' },
104
+ output: { short: 'o' },
105
+ rename: { short: 'r' },
106
+ format: {},
107
+ 'playlist-concurrency': {},
108
+ 'concurrent-downloads': {},
109
+ 'filename-template': {},
110
+ 'folder-template': {},
111
+ cookies: {},
112
+ 'cookies-from-browser': {},
113
+ 'concurrent-fragments': { short: 'N' },
114
+ 'sub-langs': {},
115
+ 'sponsorblock-remove': {},
116
+ section: {},
117
+ profile: {},
118
+ 'batch-file': {},
119
+ 'retry-failed': {},
120
+ 'playlist-items': {},
121
+ };
122
+
123
+ const BOOLEAN_OPTIONS = {
124
+ compatible: {},
125
+ recode: {},
126
+ 'adaptive-concurrency': {},
127
+ 'check-space': {},
128
+ timings: {},
129
+ color: {},
130
+ open: {},
131
+ audio: {},
132
+ resume: {},
133
+ playlist: {},
134
+ subs: {},
135
+ 'embed-subs': {},
136
+ 'embed-metadata': {},
137
+ 'embed-thumbnail': {},
138
+ 'closest-quality': {},
139
+ 'list-formats': {},
140
+ 'dry-run': {},
141
+ json: {},
142
+ help: { short: 'h' },
143
+ version: { short: 'v' },
144
+ 'skip-existing': {},
145
+ };
146
+
147
+ // Config defaults, so an explicit flag always wins but a stored preference does not.
148
+ export function optionDefaults(config = {}) {
149
+ return {
150
+ quality: config.quality ?? 'best',
151
+ output: config.output ?? process.cwd(),
152
+ rename: config.rename,
153
+ format: config.format,
154
+ compatible: config.compatible ?? false,
155
+ recode: config.recode ?? false,
156
+ 'concurrent-downloads': String(config.concurrentDownloads ?? 2),
157
+ 'filename-template': config.filenameTemplate,
158
+ 'folder-template': config.folderTemplate,
159
+ 'adaptive-concurrency': config.adaptiveConcurrency ?? true,
160
+ 'check-space': config.checkSpace ?? true,
161
+ timings: config.timings ?? true,
162
+ color: config.color ?? true,
163
+ 'playlist-concurrency': String(config.playlistConcurrency ?? 2),
164
+ cookies: config.cookies,
165
+ 'cookies-from-browser': config.cookiesFromBrowser,
166
+ 'concurrent-fragments': String(config.concurrentFragments ?? 8),
167
+ 'sub-langs': config.subLangs,
168
+ 'sponsorblock-remove': config.sponsorblockRemove,
169
+ section: config.section,
170
+ open: config.open ?? false,
171
+ audio: config.audio ?? false,
172
+ resume: config.resume ?? false,
173
+ playlist: config.playlist ?? false,
174
+ subs: config.subs ?? false,
175
+ 'embed-subs': config.embedSubs ?? false,
176
+ 'embed-metadata': config.embedMetadata ?? false,
177
+ 'embed-thumbnail': config.embedThumbnail ?? false,
178
+ 'closest-quality': config.closestQuality ?? false,
179
+ json: config.json ?? false,
180
+ 'skip-existing': config.skipExisting ?? false,
181
+ 'playlist-items': config.playlistItems,
182
+ };
183
+ }
184
+
185
+ export function cliOptions(config = {}) {
186
+ const defaults = optionDefaults(config);
187
+ const options = {};
188
+ for (const [name, extra] of Object.entries(STRING_OPTIONS)) {
189
+ options[name] = { type: 'string', ...extra };
190
+ if (defaults[name] !== undefined) options[name].default = defaults[name];
191
+ }
192
+ for (const [name, extra] of Object.entries(BOOLEAN_OPTIONS)) {
193
+ options[name] = { type: 'boolean', ...extra };
194
+ if (defaults[name] !== undefined) options[name].default = defaults[name];
195
+ }
196
+ return options;
197
+ }
198
+
199
+ export function parseCli(args, { config = {} } = {}) {
200
+ if (args[0] === 'help') args = ['--help', ...args.slice(1)];
201
+ if (args[0] === 'version') {
202
+ if (args.length !== 1) throw new Error('Usage: veo version');
203
+ return { version: true };
204
+ }
205
+ const preliminary = parseArgs({ args, allowPositionals: true, strict: true, allowNegative: true, options: cliOptions() });
206
+ config = applyProfile(config, preliminary.values.profile);
207
+ const { values, positionals, tokens } = parseArgs({ args, tokens: true, allowNegative: true, allowPositionals: true, strict: true, options: cliOptions(config) });
208
+ if (values.help || values.version) return values;
209
+ // A stored default conflicting with a flag typed right now is a user error;
210
+ // a stored default merely ignored by another flag is not.
211
+ const typed = {
212
+ quality: tokens.some(token => token.name === 'quality'),
213
+ closest: tokens.some(token => token.name === 'closest-quality'),
214
+ audio: tokens.some(token => ['audio', 'no-audio'].includes(token.name)),
215
+ };
216
+ // An explicit numeric --quality states video intent, so it overrides an
217
+ // audio-only default instead of turning into a confusing conflict error.
218
+ if (typed.quality && values.quality !== 'best' && values.audio && !typed.audio) values.audio = false;
219
+ if (!positionals.length && !values['batch-file'] && !values['retry-failed']) throw new Error('Provide at least one video URL. Run veo --help for usage.');
220
+ 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.`);
221
+ if (values['playlist-items']) {
222
+ if (values.playlist === false && tokens.some(token => token.name === 'playlist')) {
223
+ if (tokens.some(token => token.name === 'playlist-items')) throw new Error('--playlist-items cannot be combined with --no-playlist.');
224
+ values['playlist-items'] = undefined;
225
+ } else { validateItems(values['playlist-items']); values.playlist = true; }
226
+ }
227
+ if (values['retry-failed'] && (positionals.length || values['batch-file'])) throw new Error('--retry-failed cannot be combined with URLs or --batch-file.');
228
+ if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
229
+ if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
230
+ 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.');
231
+ if (values.audio && values.quality !== 'best' && typed.quality) throw new Error('--quality is for video; omit it when using --audio.');
232
+ if (values.audio && values['closest-quality'] && typed.closest) throw new Error('--closest-quality is for video; omit it when using --audio.');
233
+ if (values['closest-quality'] && values.quality === 'best' && typed.closest) throw new Error('--closest-quality requires a numeric --quality such as 1080p.');
234
+ if (values['list-formats'] && (values.audio || values.format)) throw new Error('--list-formats cannot be combined with --audio or --format.');
235
+ if (values['list-formats'] && positionals.length > 1) throw new Error('--list-formats accepts exactly one URL.');
236
+ if (values['concurrent-fragments'] !== undefined) {
237
+ const fragments = Number(values['concurrent-fragments']);
238
+ if (!Number.isInteger(fragments) || fragments < 1 || fragments > 16) throw new Error('--concurrent-fragments must be a whole number between 1 and 16.');
239
+ }
240
+ const concurrency = Number(values['playlist-concurrency']);
241
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('--playlist-concurrency must be a whole number between 1 and 4.');
242
+ 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.');
243
+ if (values.compatible) values.format = 'mp4';
244
+ if (values.recode && (!values.format || values.audio)) throw new Error('--recode requires --format and video mode.');
245
+ 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".');
246
+ 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".');
247
+ const formats = values.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
248
+ 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.' : ''}`);
249
+
250
+ const options = {};
251
+ for (const [name, value] of Object.entries(values)) {
252
+ if (value === undefined) continue;
253
+ options[name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = value;
254
+ }
255
+ options.urls = positionals.map(validateUrl);
256
+ options.url = options.urls[0];
257
+ if (values.cookies !== undefined) options.cookies = validateCookieFile(values.cookies);
258
+ if (values['cookies-from-browser'] !== undefined) options.cookiesFromBrowser = validateBrowserSpec(values['cookies-from-browser']);
259
+ if (options.section) options.section = options.section.trim();
260
+ if (options.sponsorblockRemove) options.sponsorblockRemove = options.sponsorblockRemove.trim();
261
+ if (options.concurrentFragments !== undefined) options.concurrentFragments = Number(options.concurrentFragments);
262
+ options.playlistConcurrency = concurrency;
263
+ options.concurrentDownloads = Number(values['concurrent-downloads']);
264
+ 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.');
265
+ if (options.filenameTemplate !== undefined) validateTemplate(options.filenameTemplate);
266
+ if (options.folderTemplate !== undefined) validateTemplate(options.folderTemplate, { folders: true });
267
+ if (options.rename && options.filenameTemplate) throw new Error('--rename and --filename-template cannot be combined.');
268
+ const disableSubs = values.subs === false && tokens.some(token => token.name === 'subs');
269
+ if (options.subLangs && !disableSubs) options.subs = true;
270
+ if (disableSubs) {
271
+ options.subLangs = undefined;
272
+ options.embedSubs = false;
273
+ }
274
+ return options;
275
+ }
276
+
277
+ export async function main(args = process.argv.slice(2), { config } = {}) {
278
+ if (args[0] === 'help') args = ['--help', ...args.slice(1)];
279
+ let color = !args.includes('--no-color') && !args.includes('--json');
280
+ let display;
281
+ try {
282
+ display = outputOptions(args);
283
+ const loaded = config ?? await loadConfig();
284
+ color = !args.includes('--json') && (display.color ?? applyProfile(loaded.config || {}, display.profile).color ?? true);
285
+ // Validate explicit profiles even when a color flag overrides their setting.
286
+ if (display.profile) applyProfile(loaded.config || {}, display.profile);
287
+ } catch (error) {
288
+ if (!display || display.profile) {
289
+ process.stderr.write(`veo: ${readableError(error)}\n`);
290
+ return 1;
291
+ }
292
+ // The command's own validation reports configuration errors.
293
+ }
294
+ if (['stats', 'history', 'flush', 'runs', 'inspect', 'stop', 'update', 'upgrade', 'check', 'doctor', 'backend'].includes(args[0])) args = display.remaining;
295
+ return withOutputSettings(color, () => runMain(args, { config }));
296
+ }
297
+
298
+ async function runMain(args, { config }) {
299
+ const stdout = outputStream(process.stdout, { plain: args.includes('--json') });
300
+ const stderr = outputStream(process.stderr, { plain: args.includes('--json') });
301
+ if (args[0] === 'stats') {
302
+ try { return await (await import('./stats.js')).statsMain(args.slice(1)); }
303
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
304
+ }
305
+ if (args[0] === 'history') {
306
+ try { return await (await import('./history.js')).historyMain(args.slice(1)); }
307
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
308
+ }
309
+ if (args[0] === 'flush') {
310
+ try { return await (await import('./flush.js')).flushMain(args.slice(1)); }
311
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
312
+ }
313
+ if (args[0] === 'runs') {
314
+ try { return await (await import('./runs.js')).runsMain(args.slice(1)); }
315
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
316
+ }
317
+ if (args[0] === 'inspect') {
318
+ try { return await (await import('./inspect-media.js')).inspectMain(args.slice(1)); }
319
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return error.name === 'AbortError' ? 130 : 1; }
320
+ }
321
+ if (args[0] === 'stop') {
322
+ try { return await (await import('./runs.js')).stopMain(args.slice(1)); }
323
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
324
+ }
325
+ if (args[0] === 'retry') {
326
+ if (args[1] !== '--last') {
327
+ stderr.write('veo: Usage: veo retry --last [download options]\n');
328
+ return 1;
329
+ }
330
+ try {
331
+ const { latestFailedJob } = await import('./jobs.js');
332
+ args = ['--retry-failed', await latestFailedJob(), ...args.slice(2)];
333
+ } catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
334
+ }
335
+ const { cleanupDownloadCache } = await import('./download-cache.js');
336
+ await cleanupDownloadCache().catch(error => stderr.write(`veo: Could not clean expired local downloads: ${readableError(error)}\n`));
337
+ // update/upgrade/check subcommands are handled before URL validation.
338
+ if (['update', 'upgrade', 'check'].includes(args[0])) {
339
+ if (args[0] === 'check' && args[1] !== 'update') {
340
+ stdout.write(UPDATE_HELP);
341
+ return 0;
342
+ }
343
+ return updateMain(args, { registry: defaultRegistry() });
344
+ }
345
+ if (args[0] === 'config') {
346
+ try { return await configMain(args.slice(1)); }
347
+ catch (error) { stderr.write(`veo: ${readableError(error)}\n`); return 1; }
348
+ }
349
+ if (args[0] === 'doctor') {
350
+ try {
351
+ const { doctorMain } = await import('./doctor.js');
352
+ return await doctorMain(args.slice(1));
353
+ } catch (error) {
354
+ stderr.write(`veo: ${readableError(error)}\n`);
355
+ return 1;
356
+ }
357
+ }
358
+ if (args[0] === 'backend') {
359
+ try {
360
+ const { backendUpdateMain } = await import('./backend-update.js');
361
+ return await backendUpdateMain(args.slice(1));
362
+ } catch (error) {
363
+ stderr.write(`veo: ${readableError(error)}\n`);
364
+ return 1;
365
+ }
366
+ }
367
+ const reporter = createReporter();
368
+ const controller = new AbortController();
369
+ const cancel = () => controller.abort();
370
+ let run;
371
+ process.once('SIGINT', cancel);
372
+ process.once('SIGTERM', cancel);
373
+ try {
374
+ if (!args.includes('--help') && !args.includes('-h') && !args.includes('--version')) {
375
+ run = await (await import('./runs.js')).registerRun(cancel);
376
+ }
377
+ const loaded = config ?? await loadConfig();
378
+ for (const warning of loaded.warnings || []) stderr.write(`veo: ${warning}\n`);
379
+ if (!args.length && process.stdin.isTTY && process.stderr.isTTY) {
380
+ const { interactiveArgs } = await import('./interactive.js');
381
+ args = await interactiveArgs(loaded.config || {}, { signal: controller.signal });
382
+ if (!args) return 0;
383
+ }
384
+ let options = parseCli(args, { config: loaded.config });
385
+ if (options.help) { stdout.write(HELP); return 0; }
386
+ if (options.version) {
387
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
388
+ stdout.write(`${pkg.version}\n`);
389
+ return 0;
390
+ }
391
+ let retryItems;
392
+ if (options.retryFailed) {
393
+ retryItems = await retryOptions(options.retryFailed);
394
+ // Revalidate stored options and apply only flags explicitly supplied now.
395
+ const retryArgs = args.filter((arg, index) => arg !== '--retry-failed' && args[index - 1] !== '--retry-failed' && !arg.startsWith('--retry-failed='));
396
+ retryItems = retryItems.map(item => parseCli([item.url, ...retryArgs], { config: { ...item, profiles: loaded.config?.profiles } }));
397
+ options = { ...retryItems[0], urls: retryItems.map(item => item.url) };
398
+ }
399
+ if (options.batchFile) {
400
+ const lines = (await readFile(options.batchFile, 'utf8')).replace(/^\uFEFF/, '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'));
401
+ options.urls.push(...lines.map(validateUrl));
402
+ options.url = options.urls[0];
403
+ if (!options.urls.length) throw new Error('The URL list is empty.');
404
+ 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.');
405
+ if (options.listFormats && options.urls.length > 1) throw new Error('--list-formats accepts exactly one URL.');
406
+ }
407
+ // The job file is created before the first download, so another terminal can
408
+ // follow this run's per-item progress with `veo runs <id>`.
409
+ const jobFile = options.listFormats || options.dryRun ? null : jobFilePath();
410
+ await run?.describe({ urls: options.urls, output: options.output ? path.resolve(options.output) : null,
411
+ audio: options.audio, quality: options.quality, format: options.format, playlist: options.playlist, job: jobFile });
412
+ reporter.configure?.({ color: options.color && !options.json });
413
+ reporter.start(options.rename);
414
+ const cookieWarning = cookieFileWarning(options.cookies);
415
+ if (cookieWarning) stderr.write(`veo: ${cookieWarning}\n`);
416
+
417
+ if (options.listFormats) {
418
+ const { listFormats } = await import('./downloader.js');
419
+ stdout.write(await listFormats(options, { signal: controller.signal, reporter }));
420
+ return 0;
421
+ }
422
+ if (options.dryRun) {
423
+ const { planDownload } = await import('./downloader.js');
424
+ for (const request of retryItems || options.urls.map(url => ({ ...options, url }))) {
425
+ const { url } = request;
426
+ const plan = await planDownload(request, { signal: controller.signal, reporter });
427
+ if (options.json) stdout.write(`${JSON.stringify({ url, status: 'planned', ...plan })}\n`);
428
+ else {
429
+ stdout.write(`URL: ${url}\n`);
430
+ stdout.write(`${describeEstimate(plan)}\n`);
431
+ if (plan.quality) stdout.write(`${plan.quality}\n`);
432
+ for (const entry of plan.entries) stdout.write(`Would save: ${cleanText(entry.path)}\n`);
433
+ }
434
+ }
435
+ return 0;
436
+ }
437
+ const { download } = await import('./downloader.js');
438
+ const { createStatsRecorder } = await import('./stats.js');
439
+ const { createHistoryRecorder } = await import('./history.js');
440
+ const result = await runJob(options, { download, reporter, signal: controller.signal, openFile, items: retryItems, jobFile: jobFile || undefined, runId: run?.id, recordStats: createStatsRecorder(), recordHistory: createHistoryRecorder() });
441
+ if (result !== 0) return result;
442
+ const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
443
+ if (notice) stderr.write(`${notice}\n`);
444
+ return 0;
445
+ } catch (error) {
446
+ reporter.fail(controller.signal.aborted);
447
+ stderr.write(`veo: ${readableError(error)}\n`);
448
+ return controller.signal.aborted || error.name === 'AbortError' ? 130 : 1;
449
+ } finally {
450
+ await run?.unregister();
451
+ process.removeListener('SIGINT', cancel);
452
+ process.removeListener('SIGTERM', cancel);
453
+ }
454
+ }
@@ -0,0 +1,39 @@
1
+ import path from 'node:path';
2
+ import { rename, rm } from 'node:fs/promises';
3
+
4
+ export async function ensureCompatibility(file, { backend, runner, signal, convert = false, reporter }) {
5
+ const executable = name => path.join(backend.ffmpegLocation, name + (process.platform === 'win32' ? '.exe' : ''));
6
+ let streams;
7
+ try {
8
+ const raw = await runner(executable('ffprobe'), ['-v', 'error', '-show_streams', '-of', 'json', file], { signal });
9
+ streams = JSON.parse(raw).streams;
10
+ if (!Array.isArray(streams)) throw new Error('No stream metadata');
11
+ } catch (error) {
12
+ if (signal?.aborted || convert) throw error;
13
+ reporter?.status('Playback compatibility could not be checked; original media retained.');
14
+ return file;
15
+ }
16
+ const video = streams.find(stream => stream.codec_type === 'video' && !stream.disposition?.attached_pic);
17
+ const audio = streams.filter(stream => stream.codec_type === 'audio');
18
+ if (!video) return file;
19
+ const videoOK = video.codec_name === 'h264' && video.pix_fmt === 'yuv420p';
20
+ const audioOK = audio.every(stream => stream.codec_name === 'aac');
21
+ if (!convert) {
22
+ if (!videoOK || !audioOK) reporter?.status(`Playback note: ${video.codec_name}/${audio.map(stream => stream.codec_name).join(',') || 'no audio'} may require additional player codecs. Original quality retained; use --compatible for H.264/AAC conversion.`);
23
+ return file;
24
+ }
25
+ if (videoOK && audioOK && path.extname(file) === '.mp4') return file;
26
+ if (!videoOK && (video.color_transfer === 'smpte2084' || video.color_transfer === 'arib-std-b67')) throw new Error('HDR conversion needs tone mapping; original download retained. Use an HDR-capable player or disable --compatible.');
27
+ reporter?.status(videoOK && audioOK ? 'Preparing compatible MP4 without re-encoding…' : 'Converting to compatible H.264/AAC; this takes time and may lose quality…');
28
+ const temporary = path.join(path.dirname(file), 'compatible-output.mp4');
29
+ const target = path.join(path.dirname(file), 'media.mp4');
30
+ try {
31
+ await runner(executable('ffmpeg'), ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', file,
32
+ '-map', '0:v:0', '-map', '0:a?', '-map_metadata', '0',
33
+ '-c:v', videoOK ? 'copy' : 'libx264', ...(!videoOK ? ['-preset', 'fast', '-crf', '18', '-pix_fmt', 'yuv420p'] : []),
34
+ '-c:a', audioOK ? 'copy' : 'aac', ...(!audioOK ? ['-b:a', '192k'] : []), '-movflags', '+faststart', temporary], { signal });
35
+ await rename(temporary, target);
36
+ if (file !== target) await rm(file);
37
+ return target;
38
+ } finally { await rm(temporary, { force: true }); }
39
+ }
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const MAX_TEXT = 2 * 1024 * 1024;
4
+
5
+ // Prefer the clipboard for the active display server, then try the other common
6
+ // Linux tools. A terminal reached over SSH may have neither display available.
7
+ export function clipboardCommands(operation, { platform = process.platform, env = process.env } = {}) {
8
+ if (platform === 'win32') {
9
+ const setup = 'Add-Type -AssemblyName System.Windows.Forms; [Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ';
10
+ return [['powershell.exe', ['-NoProfile', '-NonInteractive', '-Sta', '-Command', setup + (operation === 'copy'
11
+ ? '[System.Windows.Forms.Clipboard]::SetText([Console]::In.ReadToEnd())'
12
+ : '[Console]::Out.Write([System.Windows.Forms.Clipboard]::GetText())')]]];
13
+ }
14
+ if (platform === 'darwin') return [[operation === 'copy' ? 'pbcopy' : 'pbpaste', []]];
15
+ if (platform === 'android') return [[operation === 'copy' ? 'termux-clipboard-set' : 'termux-clipboard-get', []]];
16
+ const wayland = [operation === 'copy' ? 'wl-copy' : 'wl-paste', operation === 'copy' ? [] : ['--no-newline']];
17
+ const xclip = ['xclip', ['-selection', 'clipboard', operation === 'copy' ? '-in' : '-out']];
18
+ const xsel = ['xsel', ['--clipboard', operation === 'copy' ? '--input' : '--output']];
19
+ const x11 = [xclip, xsel];
20
+ return env.WAYLAND_DISPLAY ? [wayland, ...x11] : env.DISPLAY ? [...x11, wayland] : [wayland, ...x11];
21
+ }
22
+
23
+ function runCommand([program, args], operation, value) {
24
+ return new Promise((resolve, reject) => {
25
+ // Clipboard owners such as xclip and wl-copy may continue in the background.
26
+ // Do not keep their inherited output pipes open after the command exits.
27
+ const child = spawn(program, args, {
28
+ shell: false, windowsHide: true,
29
+ stdio: operation === 'copy' ? ['pipe', 'ignore', 'ignore'] : ['ignore', 'pipe', 'ignore'],
30
+ });
31
+ const timer = setTimeout(() => child.kill(), 5000);
32
+ const chunks = [];
33
+ let size = 0, failed = false;
34
+ child.stdout?.on('data', chunk => {
35
+ size += chunk.length;
36
+ if (size > MAX_TEXT) child.kill();
37
+ else chunks.push(chunk);
38
+ });
39
+ child.stdin?.on('error', () => {});
40
+ child.on('error', () => { failed = true; });
41
+ child.on('close', code => {
42
+ clearTimeout(timer);
43
+ if (failed || code !== 0 || size > MAX_TEXT) reject(new Error('Clipboard command failed.'));
44
+ else resolve(operation === 'paste' ? Buffer.concat(chunks).toString('utf8') : undefined);
45
+ });
46
+ child.stdin?.end(value);
47
+ });
48
+ }
49
+
50
+ export async function clipboardText(operation, value = '', options = {}) {
51
+ if (Buffer.byteLength(value, 'utf8') > MAX_TEXT) throw new Error('Selection is too large for the editor clipboard.');
52
+ const candidates = clipboardCommands(operation, options);
53
+ for (const candidate of candidates) {
54
+ try { return await (options.run || runCommand)(candidate, operation, value); }
55
+ catch { /* Try the next available display-server clipboard. */ }
56
+ }
57
+ const platform = options.platform || process.platform;
58
+ const hint = platform === 'linux'
59
+ ? ' Install wl-clipboard, xclip or xsel and use a graphical session.'
60
+ : platform === 'android' ? ' Install the Termux:API app and pkg install termux-api.' : '';
61
+ throw new Error(`System clipboard is unavailable.${hint}`);
62
+ }
63
+
64
+ export const configClipboard = {
65
+ copy: value => clipboardText('copy', value),
66
+ paste: () => clipboardText('paste'),
67
+ };