veodl 1.9.0 → 1.10.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 +28 -0
- package/README.md +57 -37
- package/docs/AGENT_GUIDE.md +8 -5
- package/package.json +1 -1
- package/src/backend.js +64 -36
- package/src/cli.js +120 -56
- package/src/config-diagnostics.js +19 -9
- package/src/config-editor.js +181 -66
- package/src/config-template.js +63 -73
- package/src/config.js +110 -27
- package/src/downloader.js +28 -21
- package/src/interactive.js +19 -19
- package/src/jobs.js +13 -4
- package/src/option-suggestions.js +43 -0
- package/src/progress.js +68 -12
- package/src/runs.js +8 -5
- package/src/source-discovery.js +82 -30
package/src/config.js
CHANGED
|
@@ -4,13 +4,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { configBase } from './paths.js';
|
|
6
6
|
import { configSyntaxError } from './config-errors.js';
|
|
7
|
-
import { CONFIG_TEMPLATE,
|
|
7
|
+
import { CONFIG_TEMPLATE, CONFIG_GUIDE, stripConfigComments } from './config-template.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Keys accepted in the config file, with the CLI flag they act as a default for.
|
|
11
11
|
* Everything here is a default only: an explicit command-line flag always wins.
|
|
12
12
|
*/
|
|
13
|
-
export const CONFIG_KEYS = Object.freeze({
|
|
13
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
14
14
|
concurrentDownloads: 'number',
|
|
15
15
|
adaptiveConcurrency: 'boolean',
|
|
16
16
|
filenameTemplate: 'string',
|
|
@@ -47,7 +47,9 @@ export const CONFIG_KEYS = Object.freeze({
|
|
|
47
47
|
timeout: 'string',
|
|
48
48
|
listSources: 'boolean',
|
|
49
49
|
autoListSources: 'boolean',
|
|
50
|
-
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const RESERVED_PROFILE_NAMES = Object.freeze(['list', 'reset']);
|
|
51
53
|
|
|
52
54
|
export function configFile({ env = process.env, platform = process.platform } = {}) {
|
|
53
55
|
const override = env.VEO_CONFIG;
|
|
@@ -94,13 +96,19 @@ export function parseConfigText(text, target = 'config') {
|
|
|
94
96
|
}
|
|
95
97
|
const warnings = [];
|
|
96
98
|
const config = {};
|
|
97
|
-
for (const [key, value] of Object.entries(data)) {
|
|
98
|
-
if (key === '$schema') continue;
|
|
99
|
+
for (const [key, value] of Object.entries(data)) {
|
|
100
|
+
if (key === '$schema') continue;
|
|
101
|
+
if (key === 'activeProfile') {
|
|
102
|
+
if (typeOf(value) !== 'string' || !value) throw new Error(`Config key "activeProfile" must be a non-empty string (${target}).`);
|
|
103
|
+
config.activeProfile = value;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
99
106
|
if (key === 'profiles') {
|
|
100
107
|
if (!value || typeOf(value) !== 'object') throw new Error('Config profiles must be an object.');
|
|
101
|
-
config.profiles = Object.create(null);
|
|
102
|
-
for (const [name, profile] of Object.entries(value)) {
|
|
103
|
-
if (
|
|
108
|
+
config.profiles = Object.create(null);
|
|
109
|
+
for (const [name, profile] of Object.entries(value)) {
|
|
110
|
+
if (RESERVED_PROFILE_NAMES.includes(name)) throw new Error(`Profile name "${name}" is reserved for veo profile ${name}. Rename it in ${target}.`);
|
|
111
|
+
if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
|
|
104
112
|
const checked = {};
|
|
105
113
|
for (const [setting, item] of Object.entries(profile)) {
|
|
106
114
|
if (!Object.hasOwn(CONFIG_KEYS, setting)) throw new Error(`Unknown setting "${setting}" in profile "${name}".`);
|
|
@@ -119,20 +127,95 @@ export function parseConfigText(text, target = 'config') {
|
|
|
119
127
|
if (typeOf(value) !== expected) {
|
|
120
128
|
throw new Error(`Config key "${key}" must be a ${expected}, not a ${typeOf(value)} (${target}).`);
|
|
121
129
|
}
|
|
122
|
-
config[key] = value;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
130
|
+
config[key] = value;
|
|
131
|
+
}
|
|
132
|
+
if (config.activeProfile && !Object.hasOwn(config.profiles || {}, config.activeProfile)) {
|
|
133
|
+
throw new Error(`Unknown active profile "${config.activeProfile}" in ${target}. Available: ${Object.keys(config.profiles || {}).join(', ') || 'none'}.`);
|
|
134
|
+
}
|
|
135
|
+
return { file: target, exists: true, config, warnings };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function selectedProfileName(config, name) {
|
|
139
|
+
return name || config.activeProfile || (Object.hasOwn(config.profiles || {}, 'default') ? 'default' : undefined);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function applyProfile(config, name) {
|
|
143
|
+
const { profiles, activeProfile, ...defaults } = config;
|
|
144
|
+
name = selectedProfileName(config, name);
|
|
145
|
+
if (!name) return defaults;
|
|
146
|
+
if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
|
|
147
|
+
return { ...defaults, ...profiles[name] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function profileMain(args, { file = configFile(), output = process.stdout } = {}) {
|
|
151
|
+
if (args.length > 1 || args[0]?.startsWith('-')) throw new Error('Usage: veo profile [list|reset|NAME]');
|
|
152
|
+
const loaded = await loadConfig({ file });
|
|
153
|
+
if (!args.length) {
|
|
154
|
+
output.write(`Active profile: ${selectedProfileName(loaded.config) || 'global (no profile)'}\n`);
|
|
155
|
+
output.write(`Available: ${Object.keys(loaded.config.profiles || {}).join(', ') || 'none'}\n`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
if (args[0] === 'list' && args.length === 1) {
|
|
159
|
+
const names = Object.keys(loaded.config.profiles || {});
|
|
160
|
+
if (!names.length) output.write('No profiles configured. Use veo config edit.\n');
|
|
161
|
+
else {
|
|
162
|
+
const active = selectedProfileName(loaded.config);
|
|
163
|
+
output.write(`Profiles:\n${active ? '' : '* global (no profile)\n'}${names.map(name => `${name === active ? '* ' : ' '}${name}${name === active ? ' (active)' : ''}`).join('\n')}\n`);
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
const reset = args[0] === 'reset' && args.length === 1;
|
|
168
|
+
const name = args[0];
|
|
169
|
+
if (!reset) {
|
|
170
|
+
applyProfile(loaded.config, name);
|
|
171
|
+
await effectiveConfig(loaded.config, name);
|
|
172
|
+
}
|
|
173
|
+
if (!loaded.exists) {
|
|
174
|
+
output.write('Active profile: global (no profile)\n');
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
const original = await readFile(file, 'utf8');
|
|
178
|
+
// Token locations come from the comment-aware parser, preserving hand-written notes.
|
|
179
|
+
const { configSource } = await import('./config-diagnostics.js');
|
|
180
|
+
const { nodes } = configSource(original);
|
|
181
|
+
const property = nodes.get(JSON.stringify(['activeProfile']));
|
|
182
|
+
let updated;
|
|
183
|
+
if (reset) {
|
|
184
|
+
if (!property) updated = original;
|
|
185
|
+
else {
|
|
186
|
+
const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
|
|
187
|
+
let start = property.keyStart, end = property.end;
|
|
188
|
+
while (/\s/.test(clean[end] || '')) end++;
|
|
189
|
+
if (clean[end] === ',') end++;
|
|
190
|
+
else {
|
|
191
|
+
let before = start - 1;
|
|
192
|
+
while (before >= 0 && /\s/.test(clean[before])) before--;
|
|
193
|
+
if (clean[before] === ',') start = before;
|
|
194
|
+
end = property.end;
|
|
195
|
+
}
|
|
196
|
+
updated = original.slice(0, start) + original.slice(end);
|
|
197
|
+
}
|
|
198
|
+
} else if (property) updated = original.slice(0, property.start) + JSON.stringify(name) + original.slice(property.end);
|
|
199
|
+
else {
|
|
200
|
+
const root = nodes.get('[]');
|
|
201
|
+
const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
|
|
202
|
+
const first = root.start + 1 + clean.slice(root.start + 1, root.end - 1).search(/\S/);
|
|
203
|
+
const empty = first === root.start;
|
|
204
|
+
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
205
|
+
const position = empty ? root.end - 1 : first;
|
|
206
|
+
const indent = empty ? ' ' : /^[ \t]*$/.test(original.slice(original.lastIndexOf('\n', position - 1) + 1, position))
|
|
207
|
+
? original.slice(original.lastIndexOf('\n', position - 1) + 1, position) : '';
|
|
208
|
+
updated = original.slice(0, position) + (empty ? `${newline}${indent}` : '') +
|
|
209
|
+
`"activeProfile": ${JSON.stringify(name)}${empty ? `${newline}` : `,${newline}${indent}`}` + original.slice(position);
|
|
210
|
+
}
|
|
211
|
+
parseConfigText(updated, file);
|
|
212
|
+
if (updated !== original) {
|
|
213
|
+
if (await readFile(file, 'utf8') !== original) throw new Error('Config changed while switching profiles. Try again.');
|
|
214
|
+
await writeFile(file, updated, 'utf8');
|
|
215
|
+
}
|
|
216
|
+
output.write(`Active profile: ${reset ? selectedProfileName(parseConfigText(updated, file).config) || 'global (no profile)' : name}\n`);
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
136
219
|
|
|
137
220
|
export async function prepareConfigEdit(file) {
|
|
138
221
|
await mkdir(path.dirname(file), { recursive: true });
|
|
@@ -140,8 +223,7 @@ export async function prepareConfigEdit(file) {
|
|
|
140
223
|
catch (error) {
|
|
141
224
|
if (error.code !== 'EEXIST') throw error;
|
|
142
225
|
const original = await readFile(file, 'utf8');
|
|
143
|
-
|
|
144
|
-
if (annotated !== original) await writeFile(file, annotated, 'utf8');
|
|
226
|
+
if (!original.replace(/^\uFEFF/, '').trim()) await writeFile(file, CONFIG_TEMPLATE, 'utf8');
|
|
145
227
|
}
|
|
146
228
|
}
|
|
147
229
|
|
|
@@ -149,7 +231,7 @@ export async function configMain(args) {
|
|
|
149
231
|
const editorColor = !args.includes('--no-color') && !Object.hasOwn(process.env, 'NO_COLOR');
|
|
150
232
|
let stdout;
|
|
151
233
|
[args, stdout] = commandOutput(args, process.stdout);
|
|
152
|
-
if (args[0] === 'show') stdout = process.stdout;
|
|
234
|
+
if (args[0] === 'show' || args[0] === 'guide') stdout = process.stdout;
|
|
153
235
|
const file = configFile();
|
|
154
236
|
if (args[0] === 'reset') {
|
|
155
237
|
if (args.length !== 1) throw new Error('Usage: veo config reset');
|
|
@@ -179,8 +261,9 @@ export async function configMain(args) {
|
|
|
179
261
|
if (args.length > 2 || (args[1] && !['--external', '--terminal'].includes(args[1]))) throw new Error('Usage: veo config edit [--external|--terminal]');
|
|
180
262
|
editorMode = args[1]?.slice(2) || process.env.VEO_CONFIG_EDITOR || 'auto';
|
|
181
263
|
if (!['auto', 'external', 'terminal'].includes(editorMode)) throw new Error('VEO_CONFIG_EDITOR must be auto, external or terminal.');
|
|
182
|
-
} else if (args.length !== 1 || !['path', 'profiles'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|check|show|reset');
|
|
183
|
-
if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
|
|
264
|
+
} else if (args.length !== 1 || !['path', 'profiles', 'guide'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|guide|check|show|reset');
|
|
265
|
+
if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
|
|
266
|
+
if (args[0] === 'guide') { stdout.write(CONFIG_GUIDE); return 0; }
|
|
184
267
|
if (args[0] === 'profiles') {
|
|
185
268
|
const loaded = await loadConfig();
|
|
186
269
|
stdout.write(`${Object.keys(loaded.config.profiles || {}).join('\n') || 'No profiles configured. Use veo config edit.'}\n`);
|
package/src/downloader.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ensureCompatibility } from './compatibility.js';
|
|
2
|
-
import { adaptiveRun, reducedLimit, phaseTimer
|
|
2
|
+
import { adaptiveRun, reducedLimit, phaseTimer } from './execution.js';
|
|
3
3
|
import { mediaDestination, prepareDestination } from './naming.js';
|
|
4
4
|
import { estimateMediaBytes, reserveSpace } from './disk-space.js';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
@@ -288,20 +288,26 @@ export async function fetchMetadata(options, { signal, backend, runner, reporter
|
|
|
288
288
|
const args = [...common, '--dump-single-json', '--skip-download'];
|
|
289
289
|
if (options.playlist && !options._entryIndex) args.push('--flat-playlist');
|
|
290
290
|
args.push('--', options.mediaUrl || options.url);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
291
|
+
try {
|
|
292
|
+
let metadata = JSON.parse(await runner(backend.ytDlp, args, { signal }));
|
|
293
|
+
if (options._entryIndex && metadata.entries) metadata = metadata.entries.find(Boolean);
|
|
294
|
+
if (!metadata) throw new Error('The selected playlist entry is unavailable.');
|
|
295
|
+
if (!options.playlist && (metadata._type === 'playlist' || metadata.entries)) {
|
|
296
|
+
throw new Error('This URL is a collection. Add --playlist to download every entry.');
|
|
297
|
+
}
|
|
298
|
+
if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
|
|
299
|
+
if (metadata.has_drm) throw new Error('This content is DRM-protected.');
|
|
300
|
+
reporter?.finishStatus?.('Reading video…', 'done');
|
|
301
|
+
return metadata;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (!signal?.aborted && error?.name !== 'AbortError') reporter?.finishStatus?.('Reading video…', 'failed');
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function prepareBackend(options, { signal, backendResolver = resolveBackend, reporter } = {}) {
|
|
309
|
+
return backendResolver({ signal, onStatus: message => reporter?.status(message), onStatusDone: (message, outcome) => reporter?.finishStatus?.(message, outcome) });
|
|
310
|
+
}
|
|
305
311
|
|
|
306
312
|
/** The extension the finished file will most likely have, for previews. */
|
|
307
313
|
export function predictedExtension(options) {
|
|
@@ -411,10 +417,10 @@ async function downloadCollection(options, metadata, dependencies) {
|
|
|
411
417
|
*/
|
|
412
418
|
export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend } = {}) {
|
|
413
419
|
const timer = phaseTimer(now);
|
|
414
|
-
const timingResult = () => {
|
|
415
|
-
if (options.timings === false) return {};
|
|
416
|
-
|
|
417
|
-
};
|
|
420
|
+
const timingResult = () => {
|
|
421
|
+
if (options.timings === false) return {};
|
|
422
|
+
return { timings: timer.result() };
|
|
423
|
+
};
|
|
418
424
|
let directory = path.resolve(options.output);
|
|
419
425
|
localRoot = path.resolve(localRoot);
|
|
420
426
|
if (!skipCacheCleanup) await cleanupDownloadCache(localRoot);
|
|
@@ -548,8 +554,9 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
548
554
|
}
|
|
549
555
|
await writeJson(historyFile, { version: 1, source: sourceKey(metadata, options.url), settings: downloadSettings(options), files });
|
|
550
556
|
return { url: options.url, title: metadata.title || metadata.id || 'video', files, status: 'saved', saved: 1, skipped: 0, ...timingResult() };
|
|
551
|
-
} catch (error) {
|
|
552
|
-
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (!signal?.aborted) reporter?.failStep?.();
|
|
559
|
+
// A kept staging directory is the whole point of --resume, and cancellation
|
|
553
560
|
// is the most common reason to want one.
|
|
554
561
|
keepPartial = Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
|
|
555
562
|
if (manifest && unconfirmedOutput) manifest.unconfirmedOutput = true;
|
package/src/interactive.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { outputStream, formatOutput } from './output.js';
|
|
2
2
|
import { createInterface } from 'node:readline/promises';
|
|
3
3
|
import { availableHeights, cleanText, validateUrl } from './utils.js';
|
|
4
|
-
import { applyProfile } from './config.js';
|
|
4
|
+
import { applyProfile, selectedProfileName } from './config.js';
|
|
5
5
|
import { fetchMetadata, prepareBackend, runBackend } from './downloader.js';
|
|
6
6
|
import { describeEstimate, selectedEntries, sizeEstimate, validateItems } from './playlist.js';
|
|
7
7
|
|
|
@@ -25,9 +25,9 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
|
|
|
25
25
|
const profiles = Object.keys(config.profiles || {});
|
|
26
26
|
let defaults = applyProfile(config);
|
|
27
27
|
if (profiles.length) {
|
|
28
|
-
const
|
|
29
|
-
const choices =
|
|
30
|
-
const fallback =
|
|
28
|
+
const selected = selectedProfileName(config);
|
|
29
|
+
const choices = selected ? profiles : [...profiles, 'none'];
|
|
30
|
+
const fallback = selected || 'none';
|
|
31
31
|
const profile = await choose(`Profile (${choices.join(', ')}) [${fallback}]: `, choices, fallback);
|
|
32
32
|
if (profile !== 'none') { args.push('--profile', profile); defaults = applyProfile(config, profile); }
|
|
33
33
|
}
|
|
@@ -53,24 +53,24 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
|
|
|
53
53
|
} catch (originalError) {
|
|
54
54
|
const { discoverSources, formatSource, parseSourceTimeout, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
|
|
55
55
|
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
56
|
-
if (
|
|
57
|
-
const answer = (await question('No downloadable video found. Search this page for other sources? (y/N): ')).trim().toLowerCase();
|
|
58
|
-
if (!['y', 'yes'].includes(answer)) throw originalError;
|
|
59
|
-
}
|
|
56
|
+
if (defaults.autoListSources === false) throw originalError;
|
|
60
57
|
const inspectSource = (mediaUrl, _page, { signal: scanSignal = signal } = {}) => inspect
|
|
61
58
|
? inspect({ ...inspectionOptions, playlist: false, mediaUrl }, scanSignal)
|
|
62
59
|
: fetchMetadata({ ...inspectionOptions, playlist: false, mediaUrl }, { signal: scanSignal, backend, runner: runBackend });
|
|
63
|
-
const sources = await (discover || discoverSources)(url, { signal, inspect: inspectSource,
|
|
64
|
-
deepScan: defaults.deepScan, timeoutMs: defaults.timeout ? parseSourceTimeout(defaults.timeout) : undefined }).catch(() => []);
|
|
65
|
-
if (!sources.length) throw originalError;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
60
|
+
const sources = await (discover || discoverSources)(url, { signal, inspect: inspectSource,
|
|
61
|
+
deepScan: defaults.deepScan, timeoutMs: defaults.timeout ? parseSourceTimeout(defaults.timeout) : undefined }).catch(() => []);
|
|
62
|
+
if (!sources.length) throw originalError;
|
|
63
|
+
let selection = sources[0];
|
|
64
|
+
if (sources.length > 1) {
|
|
65
|
+
for (const source of sources) output.write(`${formatSource(source)}\n`);
|
|
66
|
+
selection = null;
|
|
67
|
+
while (!selection) {
|
|
68
|
+
const answer = (await question('Source number: ')).trim();
|
|
69
|
+
const index = Number(answer);
|
|
70
|
+
if (/^[1-9]\d*$/.test(answer) && sources[index - 1]) selection = sources[index - 1];
|
|
71
|
+
else output.write(`Choose a number from 1 to ${sources.length}.\n`);
|
|
72
|
+
}
|
|
73
|
+
} else output.write(`Found video source: ${formatSource(selection)}\n`);
|
|
74
74
|
args.push('--source', String(selection.index));
|
|
75
75
|
metadata = await inspectSource(selection.url);
|
|
76
76
|
}
|
package/src/jobs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runPool, serialQueue, reducedLimit } from './execution.js';
|
|
1
|
+
import { runPool, serialQueue, reducedLimit, formatTimings } from './execution.js';
|
|
2
2
|
import { styleText } from './progress.js';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { lstat, readdir } from 'node:fs/promises';
|
|
@@ -126,14 +126,14 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
126
126
|
skipped += result.skipped ?? 0;
|
|
127
127
|
failed += result.failures?.length ?? (item.status === 'failed' ? 1 : 0);
|
|
128
128
|
}
|
|
129
|
-
if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, ...(runId ? { runId } : {}) })}\n`);
|
|
129
|
+
if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, profile: request.profile || null, ...(runId ? { runId } : {}) })}\n`);
|
|
130
130
|
await publish(result.files);
|
|
131
131
|
} catch (error) {
|
|
132
132
|
item.status = signal?.aborted ? 'cancelled' : 'failed';
|
|
133
133
|
item.error = readableError(error);
|
|
134
134
|
if (!signal?.aborted) failed++;
|
|
135
135
|
item.files = item.entries.flatMap(entry => entry.files || []);
|
|
136
|
-
if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
|
|
136
|
+
if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, profile: request.profile || null, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
|
|
137
137
|
else stderr.write(styleText(stderr, `veo: ${cleanText(request.url)}: ${item.error}`, 'error', options.color !== false) + '\n');
|
|
138
138
|
}
|
|
139
139
|
if (recordStats) {
|
|
@@ -165,7 +165,16 @@ export async function runJob(options, { download, reporter, signal, openFile, st
|
|
|
165
165
|
stderr.write(`Run ID: ${runId}\n`);
|
|
166
166
|
}
|
|
167
167
|
const unfinished = job.items.some(item => !['saved', 'skipped'].includes(item.status));
|
|
168
|
-
|
|
168
|
+
const downloadCount = job.items.reduce((count, item) => count + Math.max(1, item.entries?.length || 0), 0);
|
|
169
|
+
if (downloadCount > 1) stderr.write(styleText(stderr, `Summary: ${saved} saved, ${skipped} skipped, ${failed} failed${signal?.aborted ? ', cancelled' : ''}.`, unfinished ? 'error' : 'success', options.color !== false && !options.json) + '\n');
|
|
170
|
+
if (options.timings !== false) {
|
|
171
|
+
const totals = {};
|
|
172
|
+
for (const item of job.items) for (const timings of [item.timings, ...(item.entryTimings || []).map(entry => entry.timings)]) {
|
|
173
|
+
if (!timings) continue;
|
|
174
|
+
for (const [phase, ms] of Object.entries(timings)) totals[phase] = (totals[phase] || 0) + ms;
|
|
175
|
+
}
|
|
176
|
+
if (Object.keys(totals).length) stderr.write(styleText(stderr, formatTimings(totals), 'muted', options.color !== false && !options.json) + '\n');
|
|
177
|
+
}
|
|
169
178
|
if (unfinished) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed "${file}"\n`);
|
|
170
179
|
if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
|
|
171
180
|
return signal?.aborted ? 130 : unfinished ? 1 : 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Match misspelled flags without silently accepting them. Ignore punctuation
|
|
2
|
+
// differences first, then allow a small number of insertions or transpositions.
|
|
3
|
+
function editDistance(left, right) {
|
|
4
|
+
const rows = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
|
|
5
|
+
for (let i = 0; i <= left.length; i++) rows[i][0] = i;
|
|
6
|
+
for (let j = 0; j <= right.length; j++) rows[0][j] = j;
|
|
7
|
+
for (let i = 1; i <= left.length; i++) {
|
|
8
|
+
for (let j = 1; j <= right.length; j++) {
|
|
9
|
+
rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1,
|
|
10
|
+
rows[i - 1][j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
|
|
11
|
+
if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
|
|
12
|
+
rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return rows[left.length][right.length];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function suggestOption(unknown, options) {
|
|
20
|
+
const normalized = value => value.toLowerCase().replace(/-/g, '');
|
|
21
|
+
const target = normalized(unknown);
|
|
22
|
+
const prefix = unknown.startsWith('--') ? '--' : unknown.startsWith('-') ? '-' : null;
|
|
23
|
+
const candidates = [...new Set(options)].filter(option => prefix ? option.startsWith(prefix) : !option.startsWith('-'));
|
|
24
|
+
const ranked = candidates.map(option => ({ option, distance: editDistance(target, normalized(option)) }))
|
|
25
|
+
.sort((a, b) => a.distance - b.distance || a.option.length - b.option.length);
|
|
26
|
+
const best = ranked[0];
|
|
27
|
+
return best && best.distance <= Math.min(3, Math.max(1, Math.floor(target.length / 3))) ? best.option : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function optionSpellings(schema) {
|
|
31
|
+
return Object.entries(schema).flatMap(([name, details]) => [
|
|
32
|
+
`--${name}`, ...(details.short ? [`-${details.short}`] : []),
|
|
33
|
+
...(details.type === 'boolean' ? [`--no-${name}`] : []),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function explainUnknownOption(error, schema) {
|
|
38
|
+
if (error?.code !== 'ERR_PARSE_ARGS_UNKNOWN_OPTION') return error;
|
|
39
|
+
const unknown = /Unknown option '([^']+)'/.exec(error.message)?.[1];
|
|
40
|
+
if (!unknown) return error;
|
|
41
|
+
const suggestion = suggestOption(unknown, optionSpellings(schema));
|
|
42
|
+
return new Error(`Unknown option "${unknown}".${suggestion ? ` Did you mean "${suggestion}"?` : ' Run veo --help to see available options.'}`, { cause: error });
|
|
43
|
+
}
|
package/src/progress.js
CHANGED
|
@@ -5,7 +5,7 @@ const PROCESSING_LABELS = { Merger: 'Merging audio/video', VideoRemuxer: 'Changi
|
|
|
5
5
|
|
|
6
6
|
export function styleText(stream, text, role = 'muted', enabled = true, env = process.env) {
|
|
7
7
|
if (!enabled || !stream.isTTY || Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb') return text;
|
|
8
|
-
const codes = { muted: 90, title: 1, success: 32, error: 31 };
|
|
8
|
+
const codes = { muted: 90, title: 1, profile: 97, success: 32, error: 31 };
|
|
9
9
|
return '\x1b[' + (codes[role] || 90) + 'm' + text + '\x1b[0m';
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -56,10 +56,10 @@ export function formatProgress(data, { columns = Infinity, prefix = '' } = {}) {
|
|
|
56
56
|
return fit(variants.at(-1), columns);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream) } = {}) {
|
|
59
|
+
export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream), env = process.env } = {}) {
|
|
60
60
|
let color = true;
|
|
61
|
-
const muted = text => styleText(stream, text, 'muted', color);
|
|
62
|
-
const heading = text => styleText(stream, text, 'title', color);
|
|
61
|
+
const muted = text => styleText(stream, text, 'muted', color, env);
|
|
62
|
+
const heading = text => styleText(stream, text, 'title', color, env);
|
|
63
63
|
let active = false;
|
|
64
64
|
let lastLog = 0;
|
|
65
65
|
let name = '';
|
|
@@ -68,27 +68,64 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
|
|
|
68
68
|
let position = '';
|
|
69
69
|
let hasItem = false;
|
|
70
70
|
let streamName = '';
|
|
71
|
+
let animation;
|
|
72
|
+
let animationLabel = '';
|
|
73
|
+
let animationOwner;
|
|
74
|
+
let animationFrame = 0;
|
|
75
|
+
const stopAnimation = () => { if (animation) clearInterval(animation); animation = undefined; };
|
|
76
|
+
const display = value => fit(value, stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity);
|
|
77
|
+
const animate = (label, owner) => {
|
|
78
|
+
clear();
|
|
79
|
+
if (!stream.isTTY) { stream.write(muted(label + '…') + '\n'); return; }
|
|
80
|
+
animationLabel = label;
|
|
81
|
+
animationOwner = owner;
|
|
82
|
+
animationFrame = 0;
|
|
83
|
+
const tick = () => { stream.write(`\r\x1b[2K${muted(display(animationLabel + '.'.repeat(animationFrame % 3 + 1)))}`); active = true; animationFrame++; };
|
|
84
|
+
tick();
|
|
85
|
+
animation = setInterval(tick, 350);
|
|
86
|
+
animation.unref?.();
|
|
87
|
+
};
|
|
88
|
+
const processingResult = (label, status) => {
|
|
89
|
+
clear();
|
|
90
|
+
const failed = status === 'failed' || status === 'error';
|
|
91
|
+
const suffix = failed ? 'failed' : 'done';
|
|
92
|
+
const width = stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity;
|
|
93
|
+
const result = `${fit(label, Math.max(0, width - suffix.length - 2))}: `;
|
|
94
|
+
stream.write(muted(result) + styleText(stream, suffix, failed ? 'error' : 'success', color, env) + '\n');
|
|
95
|
+
};
|
|
96
|
+
const finishStatus = (message, owner, prefix = '', outcome) => {
|
|
97
|
+
const label = prefix + cleanText(message);
|
|
98
|
+
if (!animation || animationOwner !== owner || animationLabel !== label.slice(0, -1)) return;
|
|
99
|
+
if (outcome) { processingResult(label.slice(0, -1), outcome); return; }
|
|
100
|
+
clear();
|
|
101
|
+
stream.write(muted(display(label)) + '\n');
|
|
102
|
+
};
|
|
71
103
|
const line = (data, prefix) => formatProgress(data, { prefix, columns: stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity });
|
|
72
104
|
const draw = (data, prefix) => { stream.write(`\r\x1b[2K${line(data, prefix)}`); active = true; };
|
|
73
105
|
const updateTitle = () => setTitle(`veo | ${phase}${name ? ` | ${name}` : ''}`);
|
|
74
106
|
function clear() {
|
|
107
|
+
stopAnimation();
|
|
75
108
|
if (active && stream.isTTY) stream.write('\r\x1b[2K');
|
|
76
109
|
active = false;
|
|
110
|
+
animationLabel = '';
|
|
111
|
+
animationOwner = undefined;
|
|
77
112
|
}
|
|
78
113
|
const scoped = (index, total, title, parent = '') => {
|
|
79
114
|
let childName = cleanText(title), lastProgress = 0;
|
|
115
|
+
const owner = Symbol('scoped reporter');
|
|
80
116
|
const prefix = parent + '[' + index + '/' + total + '] ';
|
|
81
117
|
const log = (message, quiet = true) => {
|
|
82
|
-
clear();
|
|
83
|
-
const line = prefix + childName + ': ' + cleanText(message);
|
|
84
|
-
stream.write((quiet ? muted(line) : line) + '\n');
|
|
85
118
|
phase = cleanText(message); name = childName;
|
|
119
|
+
const line = prefix + childName + ': ' + phase;
|
|
120
|
+
if (phase.endsWith('…')) animate(line.slice(0, -1), owner);
|
|
121
|
+
else { clear(); stream.write((quiet ? muted(line) : line) + '\n'); }
|
|
86
122
|
if (started) updateTitle();
|
|
87
123
|
};
|
|
88
124
|
return {
|
|
89
125
|
scoped: (index, total, title) => scoped(index, total, title, prefix),
|
|
90
126
|
name(value) { childName = cleanText(value); },
|
|
91
127
|
status: log,
|
|
128
|
+
finishStatus(message, outcome) { finishStatus(message, owner, prefix + childName + ': ', outcome); },
|
|
92
129
|
progress(data) {
|
|
93
130
|
if (stream.isTTY) {
|
|
94
131
|
draw(data, prefix + childName + ': ' + (data.stream || 'Media'));
|
|
@@ -97,8 +134,15 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
|
|
|
97
134
|
lastProgress = Date.now();
|
|
98
135
|
}
|
|
99
136
|
},
|
|
100
|
-
processing(data) {
|
|
137
|
+
processing(data) {
|
|
138
|
+
const label = prefix + childName + ': ' + (PROCESSING_LABELS[data.postprocessor] || 'Processing media');
|
|
139
|
+
if (['finished', 'failed', 'error'].includes(data.status)) processingResult(label, data.status);
|
|
140
|
+
else animate(label, owner);
|
|
141
|
+
phase = `${PROCESSING_LABELS[data.postprocessor] || 'Processing media'}${data.status === 'finished' ? ': done' : '…'}`;
|
|
142
|
+
name = childName; if (started) updateTitle();
|
|
143
|
+
},
|
|
101
144
|
finish() {},
|
|
145
|
+
failStep() { if (animation && animationOwner === owner) processingResult(animationLabel, 'failed'); },
|
|
102
146
|
};
|
|
103
147
|
};
|
|
104
148
|
return {
|
|
@@ -112,9 +156,10 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
|
|
|
112
156
|
processing(data) {
|
|
113
157
|
const processor = cleanText(data.postprocessor || 'Processing');
|
|
114
158
|
const label = PROCESSING_LABELS[processor] || 'Processing media';
|
|
115
|
-
|
|
159
|
+
phase = `${label}${data.status === 'finished' ? ': done' : '…'}`;
|
|
116
160
|
if (started) updateTitle();
|
|
117
|
-
|
|
161
|
+
if (['finished', 'failed', 'error'].includes(data.status)) processingResult(`${position}${label}`, data.status);
|
|
162
|
+
else animate(`${position}${label}`);
|
|
118
163
|
},
|
|
119
164
|
start(title = '') { started = true; name = cleanText(title); phase = 'Starting…'; updateTitle(); },
|
|
120
165
|
name(title) {
|
|
@@ -123,11 +168,22 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
|
|
|
123
168
|
name = next; if (started) updateTitle();
|
|
124
169
|
},
|
|
125
170
|
status(message) {
|
|
126
|
-
clear();
|
|
127
171
|
phase = cleanText(message);
|
|
128
172
|
if (started) updateTitle();
|
|
129
|
-
|
|
173
|
+
if (phase.endsWith('…')) animate(phase.slice(0, -1));
|
|
174
|
+
else { clear(); stream.write(muted(phase) + '\n'); }
|
|
175
|
+
},
|
|
176
|
+
profile(value) {
|
|
177
|
+
const selected = value ? cleanText(value) : 'global (no profile)';
|
|
178
|
+
phase = `Profile: ${selected}`;
|
|
179
|
+
if (started) updateTitle();
|
|
180
|
+
clear();
|
|
181
|
+
stream.write(value && value !== 'default'
|
|
182
|
+
? muted('Profile: ') + styleText(stream, selected, 'profile', color, env) + '\n'
|
|
183
|
+
: muted(phase) + '\n');
|
|
130
184
|
},
|
|
185
|
+
finishStatus(message, outcome) { finishStatus(message, undefined, '', outcome); },
|
|
186
|
+
failStep() { if (animation && animationOwner === undefined) processingResult(animationLabel, 'failed'); },
|
|
131
187
|
complete() { clear(); phase = 'Done'; if (started) updateTitle(); },
|
|
132
188
|
fail(cancelled = false) { clear(); phase = cancelled ? 'Cancelled' : 'Failed'; if (started) updateTitle(); },
|
|
133
189
|
progress(data) {
|
package/src/runs.js
CHANGED
|
@@ -119,7 +119,7 @@ export async function registerRun(cancel, root = cacheBase()) {
|
|
|
119
119
|
const file = path.join(directory, `${randomUUID()}.json`);
|
|
120
120
|
const request = `${file}.cancel`;
|
|
121
121
|
const record = { version: 1, id, pid: process.pid, startedAt: new Date().toISOString(),
|
|
122
|
-
urls: [], output: null, media: null, quality: null, format: null, playlist: false, job: null };
|
|
122
|
+
urls: [], output: null, media: null, quality: null, format: null, profile: null, playlist: false, job: null };
|
|
123
123
|
await writeJson(file, record);
|
|
124
124
|
// A flush that started while this run registered must still win.
|
|
125
125
|
if (await exists(gate)) {
|
|
@@ -146,8 +146,9 @@ export async function registerRun(cancel, root = cacheBase()) {
|
|
|
146
146
|
urls: (Array.isArray(details.urls) ? details.urls : []).filter(url => typeof url === 'string').slice(0, 200).map(cleanText),
|
|
147
147
|
output: text(details.output) || null,
|
|
148
148
|
media: details.audio ? 'audio' : 'video',
|
|
149
|
-
quality: details.audio ? null : text(details.quality) || null,
|
|
150
|
-
format: text(details.format) || null,
|
|
149
|
+
quality: details.audio ? null : text(details.quality) || null,
|
|
150
|
+
format: text(details.format) || null,
|
|
151
|
+
profile: text(details.profile) || null,
|
|
151
152
|
playlist: Boolean(details.playlist),
|
|
152
153
|
job: text(details.job) || null,
|
|
153
154
|
});
|
|
@@ -232,7 +233,8 @@ export function formatRunDetails(run) {
|
|
|
232
233
|
const running = uptime(run);
|
|
233
234
|
lines.push(`State: ${run.alive ? `running (PID ${run.pid})` : `stale (PID ${run.pid} is gone)`}`);
|
|
234
235
|
lines.push(`Started: ${localStamp(run.startedAt)}${run.alive && running ? ` (${running} ago)` : ''}`);
|
|
235
|
-
lines.push(`Media: ${describeMedia(run)}`);
|
|
236
|
+
lines.push(`Media: ${describeMedia(run)}`);
|
|
237
|
+
lines.push(`Profile: ${text(run.profile) || 'global (no profile)'}`);
|
|
236
238
|
lines.push(`Output: ${text(run.output) || 'not resolved yet'}`);
|
|
237
239
|
lines.push(`Job: ${text(run.job) || 'not created yet'}`);
|
|
238
240
|
const urls = (Array.isArray(run.urls) ? run.urls : []).map(text).filter(Boolean);
|
|
@@ -286,7 +288,8 @@ export async function runsMain(args = [], { stdout = process.stdout, root = cach
|
|
|
286
288
|
function publicRun(run) {
|
|
287
289
|
return { id: run.id, pid: run.pid, state: run.alive ? 'running' : 'stale', startedAt: run.startedAt || null,
|
|
288
290
|
urls: Array.isArray(run.urls) ? run.urls : [], output: run.output || null, media: run.media || null,
|
|
289
|
-
quality: run.quality || null, format: run.format || null,
|
|
291
|
+
quality: run.quality || null, format: run.format || null, profile: run.profile || null,
|
|
292
|
+
playlist: Boolean(run.playlist), job: run.job || null,
|
|
290
293
|
progress: run.progress || null };
|
|
291
294
|
}
|
|
292
295
|
|