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/interactive.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { outputStream, formatOutput } from './output.js';
|
|
2
|
-
import { createInterface } from 'node:readline/promises';
|
|
3
|
-
import { availableHeights, cleanText, validateUrl } from './utils.js';
|
|
4
|
-
import { applyProfile, selectedProfileName } from './config.js';
|
|
5
|
-
import { fetchMetadata, prepareBackend, runBackend } from './downloader.js';
|
|
6
|
-
import { describeEstimate, selectedEntries, sizeEstimate, validateItems } from './playlist.js';
|
|
7
|
-
|
|
8
|
-
export async function interactiveArgs(config, { signal, input = process.stdin, output = process.stderr, ask, inspect, discover } = {}) {
|
|
9
|
-
const terminalOutput = output;
|
|
10
|
-
output = outputStream(output);
|
|
11
|
-
const rl = ask ? null : createInterface({ input, output: terminalOutput });
|
|
12
|
-
const controller = new AbortController();
|
|
13
|
-
signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
14
|
-
rl?.on('SIGINT', () => controller.abort());
|
|
15
|
-
const question = ask || (prompt => rl.question(formatOutput(prompt, terminalOutput), { signal }));
|
|
16
|
-
const choose = async (prompt, allowed, fallback) => {
|
|
17
|
-
while (true) {
|
|
18
|
-
const answer = (await question(prompt)).trim() || fallback;
|
|
19
|
-
if (allowed.includes(answer)) return answer;
|
|
20
|
-
output.write(`Choose: ${allowed.join(', ')}\n`);
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
try {
|
|
24
|
-
const args = [];
|
|
25
|
-
const profiles = Object.keys(config.profiles || {});
|
|
26
|
-
let defaults = applyProfile(config);
|
|
27
|
-
if (profiles.length) {
|
|
28
|
-
const selected = selectedProfileName(config);
|
|
29
|
-
const choices = selected ? profiles : [...profiles, 'none'];
|
|
30
|
-
const fallback = selected || 'none';
|
|
31
|
-
const profile = await choose(`Profile (${choices.join(', ')}) [${fallback}]: `, choices, fallback);
|
|
32
|
-
if (profile !== 'none') { args.push('--profile', profile); defaults = applyProfile(config, profile); }
|
|
33
|
-
}
|
|
34
|
-
const url = validateUrl((await question('Video or playlist URL: ')).trim());
|
|
35
|
-
args.push(url);
|
|
36
|
-
if (defaults.listSources) {
|
|
37
|
-
output.write('Listing page sources…\n');
|
|
38
|
-
args.push('--list-sources');
|
|
39
|
-
return args;
|
|
40
|
-
}
|
|
41
|
-
const type = await choose(`Download (video/audio) [${defaults.audio ? 'audio' : 'video'}]: `, ['video', 'audio'], defaults.audio ? 'audio' : 'video');
|
|
42
|
-
args.push(type === 'audio' ? '--audio' : '--no-audio');
|
|
43
|
-
output.write('Reading available media…\n');
|
|
44
|
-
// Inspect the collection first so ordinary video links do not need a playlist prompt.
|
|
45
|
-
const inspectionOptions = { ...defaults, url, playlist: true };
|
|
46
|
-
const backend = inspect ? null : await prepareBackend(inspectionOptions, { signal });
|
|
47
|
-
let metadata;
|
|
48
|
-
try {
|
|
49
|
-
metadata = inspect ? await inspect(inspectionOptions) : await fetchMetadata(inspectionOptions, { signal, backend, runner: runBackend });
|
|
50
|
-
if (Array.isArray(metadata.formats) && !metadata.formats.length && !metadata.entries?.length && !metadata.url) {
|
|
51
|
-
throw new Error('No downloadable video formats found.');
|
|
52
|
-
}
|
|
53
|
-
} catch (originalError) {
|
|
54
|
-
const { discoverSources, formatSource, parseSourceTimeout, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
|
|
55
|
-
if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
|
|
56
|
-
if (defaults.autoListSources === false) throw originalError;
|
|
57
|
-
const inspectSource = (mediaUrl, _page, { signal: scanSignal = signal } = {}) => inspect
|
|
58
|
-
? inspect({ ...inspectionOptions, playlist: false, mediaUrl }, scanSignal)
|
|
59
|
-
: fetchMetadata({ ...inspectionOptions, playlist: false, mediaUrl }, { signal: scanSignal, backend, runner: runBackend });
|
|
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
|
-
args.push('--source', String(selection.index));
|
|
75
|
-
metadata = await inspectSource(selection.url);
|
|
76
|
-
}
|
|
77
|
-
const isCollection = metadata._type === 'playlist' || Boolean(metadata.entries);
|
|
78
|
-
const collection = isCollection
|
|
79
|
-
? await choose(`Download the playlist? (y/n) [${defaults.playlist === false ? 'n' : 'y'}]: `,
|
|
80
|
-
['y', 'n'], defaults.playlist === false ? 'n' : 'y')
|
|
81
|
-
: 'n';
|
|
82
|
-
args.push(collection === 'y' ? '--playlist' : '--no-playlist');
|
|
83
|
-
output.write(`${cleanText(metadata.title || metadata.id || 'Media')}\n`);
|
|
84
|
-
if (collection === 'y' && metadata.entries) {
|
|
85
|
-
for (const { entry, index } of selectedEntries(metadata)) output.write(`${index}. ${cleanText(entry?.title || 'Unavailable entry')}\n`);
|
|
86
|
-
let selection;
|
|
87
|
-
while (true) {
|
|
88
|
-
const preferred = defaults.playlistItems || '';
|
|
89
|
-
selection = (await question(`Entries (e.g. 1,3-5; Enter = ${preferred || 'all'}): `)).trim() || preferred;
|
|
90
|
-
try { if (selection) validateItems(selection); selectedEntries(metadata, selection); break; }
|
|
91
|
-
catch (error) { output.write(`${error.message}\n`); }
|
|
92
|
-
}
|
|
93
|
-
if (selection) args.push('--playlist-items', selection);
|
|
94
|
-
output.write(`${describeEstimate(sizeEstimate(selectedEntries(metadata, selection)))}\n`);
|
|
95
|
-
}
|
|
96
|
-
if (type === 'video') {
|
|
97
|
-
const heights = availableHeights(metadata.formats).sort((a, b) => b - a).map(height => `${height}p`);
|
|
98
|
-
const choices = ['best', ...heights];
|
|
99
|
-
if (!heights.length) output.write('Resolution metadata unavailable; using best. Set a quality cap with -q in command mode.\n');
|
|
100
|
-
const fallback = choices.includes(defaults.quality) ? defaults.quality : 'best';
|
|
101
|
-
args.push('-q', await choose(`Quality (${choices.join(', ')}) [${fallback}]: `, choices, fallback));
|
|
102
|
-
}
|
|
103
|
-
// Switching the media type must not leave an incompatible format from a profile.
|
|
104
|
-
if (type === 'video' && ['mp3', 'm4a', 'aac', 'opus', 'flac', 'wav'].includes(defaults.format)) args.push('--format', 'mp4');
|
|
105
|
-
if (type === 'audio' && ['mp4', 'mkv', 'webm', 'mov'].includes(defaults.format)) args.push('--format', 'mp3');
|
|
106
|
-
const directory = (await question(`Output directory [${defaults.output || process.cwd()}]: `)).trim() || defaults.output || process.cwd();
|
|
107
|
-
args.push('-o', directory);
|
|
108
|
-
if (defaults.incognito) {
|
|
109
|
-
args.push('--no-resume', '--no-skip-existing');
|
|
110
|
-
} else {
|
|
111
|
-
args.push('--resume');
|
|
112
|
-
const skipDefault = defaults.skipExisting === false ? 'n' : 'y';
|
|
113
|
-
const skip = await choose(`Skip previously downloaded videos? (y/n) [${skipDefault}]: `, ['y', 'n'], skipDefault);
|
|
114
|
-
args.push(skip === 'y' ? '--skip-existing' : '--no-skip-existing');
|
|
115
|
-
}
|
|
116
|
-
output.write(`Ready: ${type}, ${directory}\n`);
|
|
117
|
-
if (await choose('Start download? (y/n) [y]: ', ['y', 'n'], 'y') === 'n') return null;
|
|
118
|
-
return args;
|
|
119
|
-
} finally { rl?.close(); }
|
|
120
|
-
}
|
package/src/jobs.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
import { runPool, serialQueue, reducedLimit, formatTimings } from './execution.js';
|
|
2
|
-
import { styleText, terminalText } from './progress.js';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { lstat, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import { randomUUID } from 'node:crypto';
|
|
7
|
-
import { cacheBase } from './paths.js';
|
|
8
|
-
import { publicOptions, readJson, writeJson } from './state.js';
|
|
9
|
-
import { cleanText, readableError, validateUrl } from './utils.js';
|
|
10
|
-
import { RUN_ARCHIVE_ID } from './run-archive.js';
|
|
11
|
-
import { folderLink } from './path-links.js';
|
|
12
|
-
|
|
13
|
-
export async function retryOptions(file, root = cacheBase()) {
|
|
14
|
-
if (RUN_ARCHIVE_ID.test(file)) file = await retryJobForRun(file, root);
|
|
15
|
-
const job = await readJson(path.resolve(file), null);
|
|
16
|
-
if (job?.version !== 1 || !Array.isArray(job.items)) throw new Error('Invalid retry job file.');
|
|
17
|
-
const pending = job.items.filter(item => !['saved', 'skipped'].includes(item.status));
|
|
18
|
-
if (!pending.length) throw new Error('This job has no failed or unfinished downloads.');
|
|
19
|
-
return pending.map(item => {
|
|
20
|
-
validateUrl(item.url);
|
|
21
|
-
const entries = item.entries || [];
|
|
22
|
-
// After interruption, retry the original selection; resume history skips completed entries.
|
|
23
|
-
const failures = entries.filter(entry => entry.status === 'failed');
|
|
24
|
-
return { ...job.options, ...item.options, url: item.url, resume: true,
|
|
25
|
-
...(item.status === 'failed' && failures.length && item.finished ? { playlistItems: failures.map(entry => entry.index).join(',') } : {}) };
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Resolve a persistent run ID using its original retry job, including older runs. */
|
|
30
|
-
async function retryJobForRun(id, root) {
|
|
31
|
-
const { listRuns } = await import('./runs.js');
|
|
32
|
-
const runs = await listRuns(root);
|
|
33
|
-
if (runs.some(run => run.id === id && run.alive)) throw new Error(`Run ${id} is still active. Stop it before retrying.`);
|
|
34
|
-
const directory = path.join(path.resolve(root), 'jobs');
|
|
35
|
-
const entries = await readdir(directory, { withFileTypes: true }).catch(error => {
|
|
36
|
-
if (error.code === 'ENOENT') return [];
|
|
37
|
-
throw error;
|
|
38
|
-
});
|
|
39
|
-
for (const entry of entries.filter(entry => entry.isFile() && /^\d{13}-[a-f0-9-]{36}\.json$/.test(entry.name)).sort((a, b) => b.name.localeCompare(a.name))) {
|
|
40
|
-
const file = path.join(directory, entry.name);
|
|
41
|
-
const job = await readJson(file, null).catch(error => { if (error instanceof SyntaxError) return null; throw error; });
|
|
42
|
-
if (job?.runId === id) return file;
|
|
43
|
-
}
|
|
44
|
-
throw new Error(`No retry job found for run ${id}. Retry jobs may have been removed by veo flush.`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Location of one run's job file. It is created before the first download starts
|
|
49
|
-
* so `veo runs <id>` can report per-item progress from it.
|
|
50
|
-
*/
|
|
51
|
-
export function jobFilePath(root = cacheBase()) {
|
|
52
|
-
return path.join(root, 'jobs', `${Date.now()}-${randomUUID()}.json`);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Find the newest retryable job, skipping finished jobs and active runs. */
|
|
56
|
-
export async function latestFailedJob(root = cacheBase()) {
|
|
57
|
-
const directory = path.join(path.resolve(root), 'jobs');
|
|
58
|
-
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
59
|
-
if (!info) throw new Error('No retry jobs found. Run a download first.');
|
|
60
|
-
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid retry job directory: ${directory}`);
|
|
61
|
-
const { listRuns } = await import('./runs.js');
|
|
62
|
-
const active = new Set((await listRuns(root)).filter(run => run.alive && run.job).map(run => path.resolve(run.job)));
|
|
63
|
-
const names = (await readdir(directory, { withFileTypes: true }))
|
|
64
|
-
.filter(entry => entry.isFile() && /^\d{13}-[a-f0-9-]{36}\.json$/.test(entry.name))
|
|
65
|
-
.map(entry => entry.name).sort().reverse();
|
|
66
|
-
for (const name of names) {
|
|
67
|
-
const file = path.join(directory, name);
|
|
68
|
-
if (active.has(file)) continue;
|
|
69
|
-
const job = await readJson(file, null).catch(error => { if (error instanceof SyntaxError) return null; throw error; });
|
|
70
|
-
if (job?.version === 1 && job.options && typeof job.options === 'object' && !Array.isArray(job.options)
|
|
71
|
-
&& Array.isArray(job.items) && job.items.every(item => item && typeof item.url === 'string' && typeof item.status === 'string')
|
|
72
|
-
&& job.items.some(item => !['saved', 'skipped'].includes(item.status))) return file;
|
|
73
|
-
}
|
|
74
|
-
throw new Error('No failed or unfinished retry job found.');
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function runJob(options, { download, reporter, signal, openFile, stdout = process.stdout, stderr = process.stderr, jobFile, items, recordStats, recordHistory, runId, archiveRoot } = {}) {
|
|
78
|
-
if (options.incognito) {
|
|
79
|
-
runId = undefined;
|
|
80
|
-
recordStats = undefined;
|
|
81
|
-
recordHistory = undefined;
|
|
82
|
-
}
|
|
83
|
-
const concurrency = options.concurrentDownloads ?? 2;
|
|
84
|
-
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('Concurrent downloads must be between 1 and 4.');
|
|
85
|
-
const file = jobFile || jobFilePath();
|
|
86
|
-
const requests = items || options.urls.map(url => ({ ...options, url }));
|
|
87
|
-
const job = { version: 1, ...(runId ? { runId } : {}), startedAt: new Date().toISOString(), options: publicOptions({ ...options, output: path.resolve(options.output) }),
|
|
88
|
-
items: requests.map(item => ({ url: item.url, status: 'pending', entries: [] })) };
|
|
89
|
-
// Each retry item can have its own playlist selection.
|
|
90
|
-
job.items.forEach((item, index) => { item.options = publicOptions(requests[index]); });
|
|
91
|
-
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
92
|
-
if (!options.incognito) await cleanupDownloadCache();
|
|
93
|
-
const privateRoot = options.incognito ? await mkdtemp(path.join(os.tmpdir(), 'veo-incognito-')) : null;
|
|
94
|
-
const queue = serialQueue();
|
|
95
|
-
const persist = () => options.incognito ? Promise.resolve() : queue(() => writeJson(file, job));
|
|
96
|
-
await persist();
|
|
97
|
-
const activeTargets = new Map();
|
|
98
|
-
const totals = { saved: 0, skipped: 0, failed: 0 };
|
|
99
|
-
const adaptiveState = { divisor: 1 };
|
|
100
|
-
const archiveTools = runId ? await import('./run-archive.js') : null;
|
|
101
|
-
try {
|
|
102
|
-
await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
|
|
103
|
-
const targetKey = JSON.stringify([request.url, path.resolve(request.output)]);
|
|
104
|
-
const previous = activeTargets.get(targetKey);
|
|
105
|
-
let release;
|
|
106
|
-
const current = new Promise(resolve => { release = resolve; });
|
|
107
|
-
activeTargets.set(targetKey, current);
|
|
108
|
-
try {
|
|
109
|
-
await previous;
|
|
110
|
-
signal?.throwIfAborted();
|
|
111
|
-
let saved = 0, skipped = 0, failed = 0;
|
|
112
|
-
const itemReporter = concurrency > 1 && requests.length > 1 ? reporter.scoped?.(index + 1, requests.length, request.url) || reporter : reporter;
|
|
113
|
-
const item = job.items[index];
|
|
114
|
-
const captureFiles = async files => {
|
|
115
|
-
if (!archiveTools) return;
|
|
116
|
-
item.fingerprints ||= {};
|
|
117
|
-
for (const target of files || []) {
|
|
118
|
-
if (!archiveTools.isMediaFile(target) || item.fingerprints[target]) continue;
|
|
119
|
-
item.fingerprints[target] = await archiveTools.fileFingerprint(target).catch(() => null);
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
const started = performance.now();
|
|
123
|
-
const published = new Set();
|
|
124
|
-
let opened = false;
|
|
125
|
-
let title;
|
|
126
|
-
const publish = async files => {
|
|
127
|
-
if (!options.json) for (const target of files) {
|
|
128
|
-
if (!published.has(target)) {
|
|
129
|
-
const display = terminalText(stdout, `Saved: ${cleanText(target)}`);
|
|
130
|
-
stdout.write(styleText(stdout, display.startsWith('Saved: ') ? `Saved: ${folderLink(stdout, target, display.slice(7))}` : display, 'success', options.color !== false) + '\n');
|
|
131
|
-
}
|
|
132
|
-
published.add(target);
|
|
133
|
-
}
|
|
134
|
-
if (request.open && !opened && files.length) {
|
|
135
|
-
opened = true;
|
|
136
|
-
try { await openFile(files[0]); }
|
|
137
|
-
catch (error) { stderr.write(`veo: File saved, but could not launch the default app: ${readableError(error)}\n`); }
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
if (itemReporter === reporter) reporter.item?.(index + 1, requests.length, request.url);
|
|
141
|
-
try {
|
|
142
|
-
item.status = 'running';
|
|
143
|
-
await persist();
|
|
144
|
-
const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true,
|
|
145
|
-
...(privateRoot ? { localRoot: privateRoot } : {}), onEntry: async entry => {
|
|
146
|
-
await captureFiles(entry.files);
|
|
147
|
-
item.entries.push(entry);
|
|
148
|
-
await persist();
|
|
149
|
-
if (entry.status === 'saved') saved++;
|
|
150
|
-
if (entry.status === 'skipped') skipped++;
|
|
151
|
-
if (entry.status === 'failed') failed++;
|
|
152
|
-
await publish(entry.files || []);
|
|
153
|
-
} });
|
|
154
|
-
await captureFiles(result.files);
|
|
155
|
-
Object.assign(item, { status: result.status || 'saved', files: result.files, timings: result.timings, entryTimings: result.entryTimings, finished: true });
|
|
156
|
-
title = result.title;
|
|
157
|
-
if (!item.entries.length) {
|
|
158
|
-
saved += result.saved ?? (item.status === 'saved' ? 1 : 0);
|
|
159
|
-
skipped += result.skipped ?? 0;
|
|
160
|
-
failed += result.failures?.length ?? (item.status === 'failed' ? 1 : 0);
|
|
161
|
-
}
|
|
162
|
-
if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, profile: request.profile || null, ...(runId ? { runId } : {}) })}\n`);
|
|
163
|
-
await publish(result.files);
|
|
164
|
-
} catch (error) {
|
|
165
|
-
item.status = signal?.aborted ? 'cancelled' : 'failed';
|
|
166
|
-
item.error = readableError(error);
|
|
167
|
-
if (!signal?.aborted) failed++;
|
|
168
|
-
item.files = [...new Set([...item.entries.flatMap(entry => entry.files || []), ...(error.files || [])])];
|
|
169
|
-
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`);
|
|
170
|
-
else stderr.write(styleText(stderr, terminalText(stderr, `veo: ${item.error} (${request.url})`), 'error', options.color !== false) + '\n');
|
|
171
|
-
if (error.verificationFailed && !options.json) for (const file of item.files) stderr.write(`Saved file retained: ${cleanText(file)}\n`);
|
|
172
|
-
}
|
|
173
|
-
if (recordStats) {
|
|
174
|
-
try {
|
|
175
|
-
await recordStats({ videos: request.audio ? 0 : saved,
|
|
176
|
-
audio: request.audio ? saved : 0, failed: failed,
|
|
177
|
-
skipped: skipped, cancelled: item.status === 'cancelled' ? 1 : 0,
|
|
178
|
-
elapsedMs: Math.round(performance.now() - started) });
|
|
179
|
-
} catch (error) { stderr.write(`veo: Could not save statistics: ${readableError(error)}\n`); }
|
|
180
|
-
}
|
|
181
|
-
// History is written per finished item, so `veo history` never reports an
|
|
182
|
-
// attempt that is still running.
|
|
183
|
-
if (recordHistory) {
|
|
184
|
-
try {
|
|
185
|
-
await recordHistory({ url: request.url, title, status: item.status, runId, job: ['failed', 'cancelled'].includes(item.status) ? file : null, audio: Boolean(request.audio),
|
|
186
|
-
quality: request.quality, format: request.format, files: item.files || [], error: item.error,
|
|
187
|
-
elapsedMs: Math.round(performance.now() - started) });
|
|
188
|
-
} catch (error) { stderr.write(`veo: Could not save download history: ${readableError(error)}\n`); }
|
|
189
|
-
}
|
|
190
|
-
await persist();
|
|
191
|
-
totals.saved += saved; totals.skipped += skipped; totals.failed += failed;
|
|
192
|
-
} finally { release(); if (activeTargets.get(targetKey) === current) activeTargets.delete(targetKey); }
|
|
193
|
-
}, { signal }).catch(error => { if (!signal?.aborted) throw error; });
|
|
194
|
-
} finally {
|
|
195
|
-
if (privateRoot) await rm(privateRoot, { recursive: true, force: true });
|
|
196
|
-
}
|
|
197
|
-
const { saved, skipped, failed } = totals;
|
|
198
|
-
if (runId) {
|
|
199
|
-
try {
|
|
200
|
-
await archiveTools.archiveRun(job, { ...(archiveRoot ? { root: archiveRoot } : {}) });
|
|
201
|
-
} catch (error) { stderr.write(`veo: Could not archive run ${runId}: ${readableError(error)}\n`); }
|
|
202
|
-
stderr.write(`Run ID: ${runId}\n`);
|
|
203
|
-
}
|
|
204
|
-
const unfinished = job.items.some(item => !['saved', 'skipped'].includes(item.status));
|
|
205
|
-
const downloadCount = job.items.reduce((count, item) => count + Math.max(1, item.entries?.length || 0), 0);
|
|
206
|
-
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');
|
|
207
|
-
if (options.timings !== false) {
|
|
208
|
-
const totals = {};
|
|
209
|
-
for (const item of job.items) for (const timings of [item.timings, ...(item.entryTimings || []).map(entry => entry.timings)]) {
|
|
210
|
-
if (!timings) continue;
|
|
211
|
-
for (const [phase, ms] of Object.entries(timings)) totals[phase] = (totals[phase] || 0) + ms;
|
|
212
|
-
}
|
|
213
|
-
if (Object.keys(totals).length) stderr.write(styleText(stderr, formatTimings(totals), 'muted', options.color !== false && !options.json) + '\n');
|
|
214
|
-
}
|
|
215
|
-
if (unfinished && !options.incognito) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed ${runId || `"${file}"`}\n`);
|
|
216
|
-
if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
|
|
217
|
-
return signal?.aborted ? 130 : unfinished ? 1 : 0;
|
|
218
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
// Compatibility data for comments generated by the former German template.
|
|
2
|
-
// These strings are recognized during migration, never emitted as new UI text.
|
|
3
|
-
const translations = new Map([
|
|
4
|
-
[
|
|
5
|
-
"// veo: kommentierte Konfigurationsvorlage",
|
|
6
|
-
"// veo: commented configuration template"
|
|
7
|
-
],
|
|
8
|
-
[
|
|
9
|
-
"// Speichern und den Editor schliessen. Beim naechsten Aufruf gelten die Einstellungen.",
|
|
10
|
-
"// Save and close the editor. Settings apply the next time you run veo."
|
|
11
|
-
],
|
|
12
|
-
[
|
|
13
|
-
"// Kommentare mit // oder /* ... */ sind erlaubt. CLI-Optionen haben Vorrang.",
|
|
14
|
-
"// Comments using // or /* ... */ are supported. Command-line options take priority."
|
|
15
|
-
],
|
|
16
|
-
[
|
|
17
|
-
"// Option aktivieren: die beiden // davor entfernen und den Wert anpassen.",
|
|
18
|
-
"// To enable an option, remove its leading // and adjust the value."
|
|
19
|
-
],
|
|
20
|
-
[
|
|
21
|
-
"// Zwischen aktiven Eintraegen steht ein Komma, nach dem letzten keines.",
|
|
22
|
-
"// Separate active entries with commas. Do not add a comma after the last entry."
|
|
23
|
-
],
|
|
24
|
-
[
|
|
25
|
-
"// Zielordner: / oder doppelte Backslashes verwenden, z.B. \"D:\\\\Videos\".",
|
|
26
|
-
"// Output directory: use / or double backslashes, for example \"D:\\\\Videos\"."
|
|
27
|
-
],
|
|
28
|
-
[
|
|
29
|
-
"// Ohne output wird im aktuellen Arbeitsordner gespeichert.",
|
|
30
|
-
"// Without output, downloads are saved in the current working directory."
|
|
31
|
-
],
|
|
32
|
-
[
|
|
33
|
-
"// Maximale Videoaufloesung: best, 2160p, 1440p, 1080p, 720p, ...",
|
|
34
|
-
"// Maximum video resolution: best, 2160p, 1440p, 1080p, 720p, ..."
|
|
35
|
-
],
|
|
36
|
-
[
|
|
37
|
-
"// Videoformat: mp4, mkv, webm, mov. Konvertierung kann Zeit brauchen.",
|
|
38
|
-
"// Video format: mp4, mkv, webm, mov. Conversion can take time."
|
|
39
|
-
],
|
|
40
|
-
[
|
|
41
|
-
"// \"open\": false, // Fertige Datei automatisch oeffnen",
|
|
42
|
-
"// \"open\": false, // Open the completed file automatically"
|
|
43
|
-
],
|
|
44
|
-
[
|
|
45
|
-
"// \"resume\": true, // Abgebrochene Downloads fortsetzen",
|
|
46
|
-
"// \"resume\": true, // Resume interrupted downloads"
|
|
47
|
-
],
|
|
48
|
-
[
|
|
49
|
-
"// \"skipExisting\": true, // Bereits gespeicherte Downloads ueberspringen",
|
|
50
|
-
"// \"skipExisting\": true, // Skip previously saved downloads"
|
|
51
|
-
],
|
|
52
|
-
[
|
|
53
|
-
"// \"concurrentFragments\": 4, // Gleichzeitige Fragmente: 1 bis 16",
|
|
54
|
-
"// \"concurrentFragments\": 4, // Concurrent fragments: 1 to 16"
|
|
55
|
-
],
|
|
56
|
-
[
|
|
57
|
-
"// Untertitel und Zusatzinformationen:",
|
|
58
|
-
"// Subtitles and additional information:"
|
|
59
|
-
],
|
|
60
|
-
[
|
|
61
|
-
"// \"subLangs\": \"de,en\", // Aktiviert Untertitel fuer diese Sprachen",
|
|
62
|
-
"// \"subLangs\": \"de,en\", // Enable subtitles for these languages"
|
|
63
|
-
],
|
|
64
|
-
[
|
|
65
|
-
"// \"embedSubs\": true, // Untertitel ins Video einbetten",
|
|
66
|
-
"// \"embedSubs\": true, // Embed subtitles in the video"
|
|
67
|
-
],
|
|
68
|
-
[
|
|
69
|
-
"// \"embedMetadata\": true, // Titel, Datum usw. einbetten",
|
|
70
|
-
"// \"embedMetadata\": true, // Embed the title, date and other metadata"
|
|
71
|
-
],
|
|
72
|
-
[
|
|
73
|
-
"// \"embedThumbnail\": true, // Vorschaubild einbetten",
|
|
74
|
-
"// \"embedThumbnail\": true, // Embed the thumbnail"
|
|
75
|
-
],
|
|
76
|
-
[
|
|
77
|
-
"// Profile werden nur mit --profile NAME oder im interaktiven Dialog aktiviert.",
|
|
78
|
-
"// Profiles are activated only with --profile NAME or in the interactive wizard."
|
|
79
|
-
],
|
|
80
|
-
[
|
|
81
|
-
"// Beispiel: veo \"https://example.com/video.mp4\" --profile musik",
|
|
82
|
-
"// Example: veo \"https://example.com/video.mp4\" --profile music"
|
|
83
|
-
],
|
|
84
|
-
[
|
|
85
|
-
"// Nur Audio. Formate: mp3, m4a, aac, opus, flac, wav.",
|
|
86
|
-
"// Audio only. Formats: mp3, m4a, aac, opus, flac, wav."
|
|
87
|
-
],
|
|
88
|
-
[
|
|
89
|
-
"// Referenz: Beispiele bei Bedarf in die bestehende Konfiguration unten uebernehmen.",
|
|
90
|
-
"// Reference: copy any examples you need into your existing configuration below."
|
|
91
|
-
],
|
|
92
|
-
[
|
|
93
|
-
"// Deine bestehenden Einstellungen:",
|
|
94
|
-
"// Your existing settings:"
|
|
95
|
-
]
|
|
96
|
-
]);
|
|
97
|
-
|
|
98
|
-
export function translateLegacyConfigComments(text) {
|
|
99
|
-
if (!text.replace(/^\uFEFF/, '').startsWith('// veo: kommentierte Konfigurationsvorlage')) return text;
|
|
100
|
-
return text.replace(/^([ \t]*(?:\/\/[ \t]*)?)(\/\/[^\r\n]*)/gm, (line, prefix, comment) => {
|
|
101
|
-
const translated = translations.get(comment);
|
|
102
|
-
if (translated) return prefix + translated.replace('--profile music', '--profile NAME');
|
|
103
|
-
// Profile names in the commented reference are examples, not active settings.
|
|
104
|
-
return prefix + comment.replace(/^(\/\/[ \t]*")musik("\s*:)/, '$1music$2').replace(/^(\/\/[ \t]*")archiv("\s*:)/, '$1archive$2');
|
|
105
|
-
});
|
|
106
|
-
}
|
package/src/naming.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { lstat, mkdir } from 'node:fs/promises';
|
|
3
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { sanitizeTitle } from './utils.js';
|
|
5
|
-
|
|
6
|
-
const FIELDS = new Set(['title', 'id', 'channel', 'year', 'playlist', 'index']);
|
|
7
|
-
export function validateTemplate(template, { folders = false } = {}) {
|
|
8
|
-
if (typeof template !== 'string' || !template.trim()) throw new Error('A naming template cannot be empty.');
|
|
9
|
-
if (path.win32.isAbsolute(template) || path.posix.isAbsolute(template) || /[:\\\x00-\x1f]/.test(template)) throw new Error('Templates must be relative and use / for subfolders.');
|
|
10
|
-
if (!folders && template.includes('/')) throw new Error('Use --folder-template for subfolders.');
|
|
11
|
-
if (template.split('/').some(part => !part.trim() || ['.', '..'].includes(part.trim()))) throw new Error('Templates cannot contain empty, . or .. path components.');
|
|
12
|
-
const rest = template.replace(/\{([^{}]+)\}/g, (_, field) => {
|
|
13
|
-
if (!FIELDS.has(field)) throw new Error(`Unknown template field: ${field}. Use ${[...FIELDS].map(key => `{${key}}`).join(', ')}.`);
|
|
14
|
-
return '';
|
|
15
|
-
});
|
|
16
|
-
if (/[{}]/.test(rest)) throw new Error('Unbalanced braces in naming template.');
|
|
17
|
-
return template;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function mediaDestination(options, metadata, fallbackTitle) {
|
|
21
|
-
if (options.neutralFilename) {
|
|
22
|
-
return { directory: path.resolve(options.output), title: `video-${randomBytes(6).toString('hex')}` };
|
|
23
|
-
}
|
|
24
|
-
const fields = {
|
|
25
|
-
title: metadata.title || metadata.id || 'video', id: metadata.id || 'unknown',
|
|
26
|
-
channel: metadata.channel || metadata.uploader || 'Unknown channel',
|
|
27
|
-
year: /^\d{4}/.exec(metadata.upload_date || '')?.[0] || 'Unknown year',
|
|
28
|
-
playlist: options._playlistTitle || metadata.playlist_title || metadata.playlist || 'No playlist',
|
|
29
|
-
index: String(options._entryIndex || metadata.playlist_index || 1).padStart(3, '0'),
|
|
30
|
-
};
|
|
31
|
-
const expand = template => template.replace(/\{([^{}]+)\}/g, (_, field) => sanitizeTitle(String(fields[field])));
|
|
32
|
-
const title = options.filenameTemplate ? sanitizeTitle(expand(validateTemplate(options.filenameTemplate))) : fallbackTitle;
|
|
33
|
-
const folders = options.folderTemplate ? validateTemplate(options.folderTemplate, { folders: true }).split('/').map(part => sanitizeTitle(expand(part))) : [];
|
|
34
|
-
return { directory: path.resolve(options.output, ...folders), title };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Refuse symlink/junction subfolders: metadata must never redirect a save outside output.
|
|
38
|
-
export async function prepareDestination(root, directory, { create = false } = {}) {
|
|
39
|
-
root = path.resolve(root); directory = path.resolve(directory);
|
|
40
|
-
const relative = path.relative(root, directory);
|
|
41
|
-
if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) throw new Error('Destination escapes output directory.');
|
|
42
|
-
if (create) await mkdir(root, { recursive: true });
|
|
43
|
-
let current = root;
|
|
44
|
-
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
45
|
-
current = path.join(current, component);
|
|
46
|
-
const info = await lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
47
|
-
if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error(`Output subfolder is not a plain directory: ${current}`);
|
|
48
|
-
if (!info && create) {
|
|
49
|
-
await mkdir(current).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
50
|
-
const created = await lstat(current);
|
|
51
|
-
if (!created.isDirectory() || created.isSymbolicLink()) throw new Error(`Output subfolder is not a plain directory: ${current}`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
package/src/open-file.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
// Pass the absolute filename as one argument, never through a shell. Detach so
|
|
5
|
-
// a media player that stays running does not keep the CLI alive.
|
|
6
|
-
export function openFile(filename, { platform = process.platform, spawnProcess = spawn } = {}) {
|
|
7
|
-
const command = platform === 'win32' ? 'explorer.exe' : platform === 'darwin' ? 'open'
|
|
8
|
-
: platform === 'android' ? 'termux-open' : 'xdg-open';
|
|
9
|
-
const absolute = path.resolve(filename);
|
|
10
|
-
return new Promise((resolve, reject) => {
|
|
11
|
-
const child = spawnProcess(command, [absolute], { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
|
|
12
|
-
child.once('error', reject);
|
|
13
|
-
child.once('spawn', () => { child.unref(); resolve(); });
|
|
14
|
-
});
|
|
15
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
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/output.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import { styleText, terminalText, terminalTitle } from './progress.js';
|
|
3
|
-
import { linkOutputPaths } from './path-links.js';
|
|
4
|
-
import { terminalColumns } from './terminal-size.js';
|
|
5
|
-
|
|
6
|
-
const settings = new AsyncLocalStorage();
|
|
7
|
-
export const withOutputSettings = (enabled, work) => settings.run({ enabled }, work);
|
|
8
|
-
|
|
9
|
-
export function outputOptions(args) {
|
|
10
|
-
let profile, color;
|
|
11
|
-
const remaining = [];
|
|
12
|
-
for (let i = 0; i < args.length; i++) {
|
|
13
|
-
const arg = args[i];
|
|
14
|
-
if (arg === '--') { remaining.push(...args.slice(i)); break; }
|
|
15
|
-
if (arg === '--profile' || arg.startsWith('--profile=')) {
|
|
16
|
-
profile = arg === '--profile' ? args[++i] : arg.slice(10);
|
|
17
|
-
if (!profile || profile.startsWith('--')) throw new Error('--profile requires a profile name.');
|
|
18
|
-
} else {
|
|
19
|
-
if (arg === '--color') color = true;
|
|
20
|
-
if (arg === '--no-color') color = false;
|
|
21
|
-
remaining.push(arg);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
return { profile, color, remaining };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function formatOutput(text, stream, enabled = settings.getStore()?.enabled !== false) {
|
|
28
|
-
return text.split(/(\r?\n)/).map(line => {
|
|
29
|
-
if (!line.trim() || /\x1b/.test(line)) return line;
|
|
30
|
-
const value = line.trim();
|
|
31
|
-
const original = line;
|
|
32
|
-
// Limit display values, keeping help, JSON/config text and runnable commands intact.
|
|
33
|
-
if (stream.isTTY) {
|
|
34
|
-
const indent = line.match(/^\s*/)[0];
|
|
35
|
-
const available = { isTTY: true, columns: Math.max(2, terminalColumns(stream) - indent.length) };
|
|
36
|
-
if (/^\d+\. /.test(value)) line = terminalTitle(available, value).split('\n').map(part => indent + part).join('\n');
|
|
37
|
-
else if (/^(?:URL[s]?:|Saved:|Would save:|File:|Missing:|Output:|Job:|Error:|Profile:|Media:|Format:|Streams:|Source:|Title:|Command directory:|Config OK:|veo:|\[?(?:OK|FAIL)\]?\s|[0-9a-z]{6}\s+\d+\s|https?:\/\/)/.test(value)
|
|
38
|
-
|| /^\s{6,}\S/.test(line)) line = indent + terminalText(available, value);
|
|
39
|
-
}
|
|
40
|
-
let role = 'muted';
|
|
41
|
-
if (/^(?:veo (?:stats|history|changes|changelog|runs|stop|doctor|flush|update|backend|alias|uninstall)\b|veo \d[^ ]* doctor|ID\s+PID\s+STATE|Usage:|Options:|Commands:|Examples:|\d+\. )/.test(value)) role = 'title';
|
|
42
|
-
// A bare version line introduces one release in `veo changes` range output; the
|
|
43
|
-
// list rows carry a summary and stay muted.
|
|
44
|
-
if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?(?:\s+\(installed\))?$/.test(value)) role = 'title';
|
|
45
|
-
if (/^(?:\[?OK\]?\s|Saved:|Flushed:|Stopped\b|Removed\b|Config OK:|Statistics reset|Managed tools are ready)|\bis up to date\b|\bupdated to\b|\binstalled and will be used\b/i.test(value)) role = 'success';
|
|
46
|
-
if (/^(?:\[?FAIL\]?\s|Error:|veo: (?!note:))|Status:\s*(?:failed|cancelled)/i.test(value)) role = 'error';
|
|
47
|
-
if (/Status:\s*saved/.test(value)) role = 'success';
|
|
48
|
-
if (/^(?:Videos saved|Audio saved|Download time):/.test(value)) role = 'title';
|
|
49
|
-
if (/^Total failures:\s*[1-9]/.test(value)) role = 'error';
|
|
50
|
-
if (/^[1-9]\d* problems? found/.test(value)) role = 'error';
|
|
51
|
-
if (/^No problems found/.test(value)) role = 'success';
|
|
52
|
-
if (/^Config reset:/.test(value)) role = 'success';
|
|
53
|
-
if (/^Summary:/.test(value)) role = /\b[1-9]\d* failed\b|cancelled/.test(value) ? 'error' : 'success';
|
|
54
|
-
return styleText(stream, linkOutputPaths(stream, original, line), role, enabled);
|
|
55
|
-
}).join('');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Wrap only application-owned prose; no global stream monkey-patching.
|
|
59
|
-
export function outputStream(stream, { enabled = settings.getStore()?.enabled !== false, plain = false } = {}) {
|
|
60
|
-
if (plain || !stream.isTTY) return stream;
|
|
61
|
-
return {
|
|
62
|
-
isTTY: stream.isTTY,
|
|
63
|
-
get columns() { return terminalColumns(stream); },
|
|
64
|
-
on(event, listener) { stream.on?.(event, listener); },
|
|
65
|
-
removeListener(event, listener) { stream.removeListener?.(event, listener); },
|
|
66
|
-
write(chunk, ...args) {
|
|
67
|
-
if (typeof chunk !== 'string') return stream.write(chunk, ...args);
|
|
68
|
-
// Live redraw frames already carry erase/cursor sequences; prose fitting
|
|
69
|
-
// and role styling must not touch them, otherwise the invisible styling
|
|
70
|
-
// bytes push raw lines past the terminal width.
|
|
71
|
-
if (chunk.includes('\x1b')) return stream.write(chunk, ...args);
|
|
72
|
-
return stream.write(formatOutput(chunk, stream, enabled), ...args);
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function commandOutput(args, ...streams) {
|
|
78
|
-
const enabled = (outputOptions(args).color ?? settings.getStore()?.enabled) !== false;
|
|
79
|
-
const plain = args.includes('--json');
|
|
80
|
-
return [args.filter(arg => arg !== '--no-color' && arg !== '--color'), ...streams.map(stream => outputStream(stream, { enabled, plain }))];
|
|
81
|
-
}
|