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/AGENTS.md +11 -0
- package/CHANGELOG.md +264 -0
- package/LICENSE +21 -0
- package/README.md +725 -0
- package/bin/veo.js +4 -0
- package/docs/AGENT_GUIDE.md +66 -0
- package/package.json +59 -0
- package/src/backend-update.js +191 -0
- package/src/backend.js +520 -0
- package/src/cli.js +454 -0
- package/src/compatibility.js +39 -0
- package/src/config-clipboard.js +67 -0
- package/src/config-diagnostics.js +144 -0
- package/src/config-editor.js +281 -0
- package/src/config-errors.js +64 -0
- package/src/config-reset.js +39 -0
- package/src/config-template.js +171 -0
- package/src/config.js +213 -0
- package/src/disk-space.js +68 -0
- package/src/doctor.js +302 -0
- package/src/download-cache.js +31 -0
- package/src/downloader.js +600 -0
- package/src/execution.js +68 -0
- package/src/flush.js +66 -0
- package/src/history.js +173 -0
- package/src/inspect-media.js +168 -0
- package/src/interactive.js +82 -0
- package/src/jobs.js +172 -0
- package/src/legacy-config-comments.js +106 -0
- package/src/naming.js +50 -0
- package/src/open-file.js +15 -0
- package/src/output.js +61 -0
- package/src/paths.js +26 -0
- package/src/playlist.js +31 -0
- package/src/progress.js +152 -0
- package/src/run-archive.js +148 -0
- package/src/runs.js +333 -0
- package/src/state.js +27 -0
- package/src/stats.js +63 -0
- package/src/terminal-title.js +19 -0
- package/src/tool-setup.js +151 -0
- package/src/updater.js +227 -0
- package/src/utils.js +208 -0
- package/src/version.js +38 -0
package/src/jobs.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { runPool, serialQueue, reducedLimit } from './execution.js';
|
|
2
|
+
import { styleText } from './progress.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { cacheBase } from './paths.js';
|
|
7
|
+
import { publicOptions, readJson, writeJson } from './state.js';
|
|
8
|
+
import { cleanText, readableError, validateUrl } from './utils.js';
|
|
9
|
+
|
|
10
|
+
export async function retryOptions(file) {
|
|
11
|
+
const job = await readJson(path.resolve(file), null);
|
|
12
|
+
if (job?.version !== 1 || !Array.isArray(job.items)) throw new Error('Invalid retry job file.');
|
|
13
|
+
const pending = job.items.filter(item => !['saved', 'skipped'].includes(item.status));
|
|
14
|
+
if (!pending.length) throw new Error('This job has no failed or unfinished downloads.');
|
|
15
|
+
return pending.map(item => {
|
|
16
|
+
validateUrl(item.url);
|
|
17
|
+
const entries = item.entries || [];
|
|
18
|
+
// After interruption, retry the original selection; resume history skips completed entries.
|
|
19
|
+
const failures = entries.filter(entry => entry.status === 'failed');
|
|
20
|
+
return { ...job.options, ...item.options, url: item.url, resume: true,
|
|
21
|
+
...(item.status === 'failed' && failures.length && item.finished ? { playlistItems: failures.map(entry => entry.index).join(',') } : {}) };
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Location of one run's job file. It is created before the first download starts
|
|
27
|
+
* so `veo runs <id>` can report per-item progress from it.
|
|
28
|
+
*/
|
|
29
|
+
export function jobFilePath(root = cacheBase()) {
|
|
30
|
+
return path.join(root, 'jobs', `${Date.now()}-${randomUUID()}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Find the newest retryable job, skipping finished jobs and active runs. */
|
|
34
|
+
export async function latestFailedJob(root = cacheBase()) {
|
|
35
|
+
const directory = path.join(path.resolve(root), 'jobs');
|
|
36
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
37
|
+
if (!info) throw new Error('No retry jobs found. Run a download first.');
|
|
38
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Invalid retry job directory: ${directory}`);
|
|
39
|
+
const { listRuns } = await import('./runs.js');
|
|
40
|
+
const active = new Set((await listRuns(root)).filter(run => run.alive && run.job).map(run => path.resolve(run.job)));
|
|
41
|
+
const names = (await readdir(directory, { withFileTypes: true }))
|
|
42
|
+
.filter(entry => entry.isFile() && /^\d{13}-[a-f0-9-]{36}\.json$/.test(entry.name))
|
|
43
|
+
.map(entry => entry.name).sort().reverse();
|
|
44
|
+
for (const name of names) {
|
|
45
|
+
const file = path.join(directory, name);
|
|
46
|
+
if (active.has(file)) continue;
|
|
47
|
+
const job = await readJson(file, null).catch(error => { if (error instanceof SyntaxError) return null; throw error; });
|
|
48
|
+
if (job?.version === 1 && job.options && typeof job.options === 'object' && !Array.isArray(job.options)
|
|
49
|
+
&& Array.isArray(job.items) && job.items.every(item => item && typeof item.url === 'string' && typeof item.status === 'string')
|
|
50
|
+
&& job.items.some(item => !['saved', 'skipped'].includes(item.status))) return file;
|
|
51
|
+
}
|
|
52
|
+
throw new Error('No failed or unfinished retry job found.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runJob(options, { download, reporter, signal, openFile, stdout = process.stdout, stderr = process.stderr, jobFile, items, recordStats, recordHistory, runId, archiveRoot } = {}) {
|
|
56
|
+
const file = jobFile || jobFilePath();
|
|
57
|
+
const requests = items || options.urls.map(url => ({ ...options, url }));
|
|
58
|
+
const job = { version: 1, ...(runId ? { runId } : {}), startedAt: new Date().toISOString(), options: publicOptions({ ...options, output: path.resolve(options.output) }),
|
|
59
|
+
items: requests.map(item => ({ url: item.url, status: 'pending', entries: [] })) };
|
|
60
|
+
// Each retry item can have its own playlist selection.
|
|
61
|
+
job.items.forEach((item, index) => { item.options = publicOptions(requests[index]); });
|
|
62
|
+
const { cleanupDownloadCache } = await import('./download-cache.js');
|
|
63
|
+
await cleanupDownloadCache();
|
|
64
|
+
const queue = serialQueue();
|
|
65
|
+
const persist = () => queue(() => writeJson(file, job));
|
|
66
|
+
await persist();
|
|
67
|
+
const activeTargets = new Map();
|
|
68
|
+
const totals = { saved: 0, skipped: 0, failed: 0 };
|
|
69
|
+
const adaptiveState = { divisor: 1 };
|
|
70
|
+
const archiveTools = runId ? await import('./run-archive.js') : null;
|
|
71
|
+
const concurrency = options.concurrentDownloads ?? 2;
|
|
72
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 4) throw new Error('Concurrent downloads must be between 1 and 4.');
|
|
73
|
+
await runPool(requests, () => reducedLimit(concurrency, adaptiveState), async (request, index) => {
|
|
74
|
+
const targetKey = JSON.stringify([request.url, path.resolve(request.output)]);
|
|
75
|
+
const previous = activeTargets.get(targetKey);
|
|
76
|
+
let release;
|
|
77
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
78
|
+
activeTargets.set(targetKey, current);
|
|
79
|
+
try {
|
|
80
|
+
await previous;
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
let saved = 0, skipped = 0, failed = 0;
|
|
83
|
+
const itemReporter = concurrency > 1 && requests.length > 1 ? reporter.scoped?.(index + 1, requests.length, request.url) || reporter : reporter;
|
|
84
|
+
const item = job.items[index];
|
|
85
|
+
const captureFiles = async files => {
|
|
86
|
+
if (!archiveTools) return;
|
|
87
|
+
item.fingerprints ||= {};
|
|
88
|
+
for (const target of files || []) {
|
|
89
|
+
if (!archiveTools.isMediaFile(target) || item.fingerprints[target]) continue;
|
|
90
|
+
item.fingerprints[target] = await archiveTools.fileFingerprint(target).catch(() => null);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const started = performance.now();
|
|
94
|
+
const published = new Set();
|
|
95
|
+
let opened = false;
|
|
96
|
+
let title;
|
|
97
|
+
const publish = async files => {
|
|
98
|
+
if (!options.json) for (const target of files) {
|
|
99
|
+
if (!published.has(target)) stdout.write(styleText(stdout, `Saved: ${cleanText(target)}`, 'success', options.color !== false) + '\n');
|
|
100
|
+
published.add(target);
|
|
101
|
+
}
|
|
102
|
+
if (request.open && !opened && files.length) {
|
|
103
|
+
opened = true;
|
|
104
|
+
try { await openFile(files[0]); }
|
|
105
|
+
catch (error) { stderr.write(`veo: File saved, but could not launch the default app: ${readableError(error)}\n`); }
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
if (itemReporter === reporter) reporter.item?.(index + 1, requests.length, request.url);
|
|
109
|
+
try {
|
|
110
|
+
item.status = 'running';
|
|
111
|
+
await persist();
|
|
112
|
+
const result = await download(request, { reporter: itemReporter, signal, adaptiveState, skipCacheCleanup: true, onEntry: async entry => {
|
|
113
|
+
await captureFiles(entry.files);
|
|
114
|
+
item.entries.push(entry);
|
|
115
|
+
await persist();
|
|
116
|
+
if (entry.status === 'saved') saved++;
|
|
117
|
+
if (entry.status === 'skipped') skipped++;
|
|
118
|
+
if (entry.status === 'failed') failed++;
|
|
119
|
+
await publish(entry.files || []);
|
|
120
|
+
} });
|
|
121
|
+
await captureFiles(result.files);
|
|
122
|
+
Object.assign(item, { status: result.status || 'saved', files: result.files, timings: result.timings, entryTimings: result.entryTimings, finished: true });
|
|
123
|
+
title = result.title;
|
|
124
|
+
if (!item.entries.length) {
|
|
125
|
+
saved += result.saved ?? (item.status === 'saved' ? 1 : 0);
|
|
126
|
+
skipped += result.skipped ?? 0;
|
|
127
|
+
failed += result.failures?.length ?? (item.status === 'failed' ? 1 : 0);
|
|
128
|
+
}
|
|
129
|
+
if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, ...(runId ? { runId } : {}) })}\n`);
|
|
130
|
+
await publish(result.files);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
item.status = signal?.aborted ? 'cancelled' : 'failed';
|
|
133
|
+
item.error = readableError(error);
|
|
134
|
+
if (!signal?.aborted) failed++;
|
|
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`);
|
|
137
|
+
else stderr.write(styleText(stderr, `veo: ${cleanText(request.url)}: ${item.error}`, 'error', options.color !== false) + '\n');
|
|
138
|
+
}
|
|
139
|
+
if (recordStats) {
|
|
140
|
+
try {
|
|
141
|
+
await recordStats({ videos: request.audio ? 0 : saved,
|
|
142
|
+
audio: request.audio ? saved : 0, failed: failed,
|
|
143
|
+
skipped: skipped, cancelled: item.status === 'cancelled' ? 1 : 0,
|
|
144
|
+
elapsedMs: Math.round(performance.now() - started) });
|
|
145
|
+
} catch (error) { stderr.write(`veo: Could not save statistics: ${readableError(error)}\n`); }
|
|
146
|
+
}
|
|
147
|
+
// History is written per finished item, so `veo history` never reports an
|
|
148
|
+
// attempt that is still running.
|
|
149
|
+
if (recordHistory) {
|
|
150
|
+
try {
|
|
151
|
+
await recordHistory({ url: request.url, title, status: item.status, runId, job: ['failed', 'cancelled'].includes(item.status) ? file : null, audio: Boolean(request.audio),
|
|
152
|
+
quality: request.quality, format: request.format, files: item.files || [], error: item.error,
|
|
153
|
+
elapsedMs: Math.round(performance.now() - started) });
|
|
154
|
+
} catch (error) { stderr.write(`veo: Could not save download history: ${readableError(error)}\n`); }
|
|
155
|
+
}
|
|
156
|
+
await persist();
|
|
157
|
+
totals.saved += saved; totals.skipped += skipped; totals.failed += failed;
|
|
158
|
+
} finally { release(); if (activeTargets.get(targetKey) === current) activeTargets.delete(targetKey); }
|
|
159
|
+
}, { signal }).catch(error => { if (!signal?.aborted) throw error; });
|
|
160
|
+
const { saved, skipped, failed } = totals;
|
|
161
|
+
if (runId) {
|
|
162
|
+
try {
|
|
163
|
+
await archiveTools.archiveRun(job, { ...(archiveRoot ? { root: archiveRoot } : {}) });
|
|
164
|
+
} catch (error) { stderr.write(`veo: Could not archive run ${runId}: ${readableError(error)}\n`); }
|
|
165
|
+
stderr.write(`Run ID: ${runId}\n`);
|
|
166
|
+
}
|
|
167
|
+
const unfinished = job.items.some(item => !['saved', 'skipped'].includes(item.status));
|
|
168
|
+
stderr.write(styleText(stderr, `Summary: ${saved} saved, ${skipped} skipped, ${failed} failed${signal?.aborted ? ', cancelled' : ''}.`, unfinished ? 'error' : 'success', options.color !== false && !options.json) + '\n');
|
|
169
|
+
if (unfinished) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed "${file}"\n`);
|
|
170
|
+
if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
|
|
171
|
+
return signal?.aborted ? 130 : unfinished ? 1 : 0;
|
|
172
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { sanitizeTitle } from './utils.js';
|
|
4
|
+
|
|
5
|
+
const FIELDS = new Set(['title', 'id', 'channel', 'year', 'playlist', 'index']);
|
|
6
|
+
export function validateTemplate(template, { folders = false } = {}) {
|
|
7
|
+
if (typeof template !== 'string' || !template.trim()) throw new Error('A naming template cannot be empty.');
|
|
8
|
+
if (path.win32.isAbsolute(template) || path.posix.isAbsolute(template) || /[:\\\x00-\x1f]/.test(template)) throw new Error('Templates must be relative and use / for subfolders.');
|
|
9
|
+
if (!folders && template.includes('/')) throw new Error('Use --folder-template for subfolders.');
|
|
10
|
+
if (template.split('/').some(part => !part.trim() || ['.', '..'].includes(part.trim()))) throw new Error('Templates cannot contain empty, . or .. path components.');
|
|
11
|
+
const rest = template.replace(/\{([^{}]+)\}/g, (_, field) => {
|
|
12
|
+
if (!FIELDS.has(field)) throw new Error(`Unknown template field: ${field}. Use ${[...FIELDS].map(key => `{${key}}`).join(', ')}.`);
|
|
13
|
+
return '';
|
|
14
|
+
});
|
|
15
|
+
if (/[{}]/.test(rest)) throw new Error('Unbalanced braces in naming template.');
|
|
16
|
+
return template;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function mediaDestination(options, metadata, fallbackTitle) {
|
|
20
|
+
const fields = {
|
|
21
|
+
title: metadata.title || metadata.id || 'video', id: metadata.id || 'unknown',
|
|
22
|
+
channel: metadata.channel || metadata.uploader || 'Unknown channel',
|
|
23
|
+
year: /^\d{4}/.exec(metadata.upload_date || '')?.[0] || 'Unknown year',
|
|
24
|
+
playlist: options._playlistTitle || metadata.playlist_title || metadata.playlist || 'No playlist',
|
|
25
|
+
index: String(options._entryIndex || metadata.playlist_index || 1).padStart(3, '0'),
|
|
26
|
+
};
|
|
27
|
+
const expand = template => template.replace(/\{([^{}]+)\}/g, (_, field) => sanitizeTitle(String(fields[field])));
|
|
28
|
+
const title = options.filenameTemplate ? sanitizeTitle(expand(validateTemplate(options.filenameTemplate))) : fallbackTitle;
|
|
29
|
+
const folders = options.folderTemplate ? validateTemplate(options.folderTemplate, { folders: true }).split('/').map(part => sanitizeTitle(expand(part))) : [];
|
|
30
|
+
return { directory: path.resolve(options.output, ...folders), title };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Refuse symlink/junction subfolders: metadata must never redirect a save outside output.
|
|
34
|
+
export async function prepareDestination(root, directory, { create = false } = {}) {
|
|
35
|
+
root = path.resolve(root); directory = path.resolve(directory);
|
|
36
|
+
const relative = path.relative(root, directory);
|
|
37
|
+
if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative)) throw new Error('Destination escapes output directory.');
|
|
38
|
+
if (create) await mkdir(root, { recursive: true });
|
|
39
|
+
let current = root;
|
|
40
|
+
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
41
|
+
current = path.join(current, component);
|
|
42
|
+
const info = await lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
43
|
+
if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error(`Output subfolder is not a plain directory: ${current}`);
|
|
44
|
+
if (!info && create) {
|
|
45
|
+
await mkdir(current).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
46
|
+
const created = await lstat(current);
|
|
47
|
+
if (!created.isDirectory() || created.isSymbolicLink()) throw new Error(`Output subfolder is not a plain directory: ${current}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/open-file.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { styleText } from './progress.js';
|
|
3
|
+
|
|
4
|
+
const settings = new AsyncLocalStorage();
|
|
5
|
+
export const withOutputSettings = (enabled, work) => settings.run({ enabled }, work);
|
|
6
|
+
|
|
7
|
+
export function outputOptions(args) {
|
|
8
|
+
let profile, color;
|
|
9
|
+
const remaining = [];
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
const arg = args[i];
|
|
12
|
+
if (arg === '--') { remaining.push(...args.slice(i)); break; }
|
|
13
|
+
if (arg === '--profile' || arg.startsWith('--profile=')) {
|
|
14
|
+
profile = arg === '--profile' ? args[++i] : arg.slice(10);
|
|
15
|
+
if (!profile || profile.startsWith('--')) throw new Error('--profile requires a profile name.');
|
|
16
|
+
} else {
|
|
17
|
+
if (arg === '--color') color = true;
|
|
18
|
+
if (arg === '--no-color') color = false;
|
|
19
|
+
remaining.push(arg);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { profile, color, remaining };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatOutput(text, stream, enabled = settings.getStore()?.enabled !== false) {
|
|
26
|
+
return text.split(/(\r?\n)/).map(line => {
|
|
27
|
+
if (!line.trim() || /\x1b/.test(line)) return line;
|
|
28
|
+
const value = line.trim();
|
|
29
|
+
let role = 'muted';
|
|
30
|
+
if (/^(?:veo (?:stats|history|runs|stop|doctor|flush|update|backend)\b|veo \d[^ ]* doctor|ID\s+PID\s+STATE|Usage:|Options:|Commands:|Examples:|\d+\. )/.test(value)) role = 'title';
|
|
31
|
+
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';
|
|
32
|
+
if (/^(?:\[?FAIL\]?\s|Error:|veo: (?!note:))|Status:\s*(?:failed|cancelled)/i.test(value)) role = 'error';
|
|
33
|
+
if (/Status:\s*saved/.test(value)) role = 'success';
|
|
34
|
+
if (/^(?:Videos saved|Audio saved|Download time):/.test(value)) role = 'title';
|
|
35
|
+
if (/^Total failures:\s*[1-9]/.test(value)) role = 'error';
|
|
36
|
+
if (/^[1-9]\d* problems? found/.test(value)) role = 'error';
|
|
37
|
+
if (/^No problems found/.test(value)) role = 'success';
|
|
38
|
+
if (/^Config reset:/.test(value)) role = 'success';
|
|
39
|
+
if (/^Summary:/.test(value)) role = /\b[1-9]\d* failed\b|cancelled/.test(value) ? 'error' : 'success';
|
|
40
|
+
return styleText(stream, line, role, enabled);
|
|
41
|
+
}).join('');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Wrap only application-owned prose; no global stream monkey-patching.
|
|
45
|
+
export function outputStream(stream, { enabled = settings.getStore()?.enabled !== false, plain = false } = {}) {
|
|
46
|
+
if (plain || !enabled || !stream.isTTY) return stream;
|
|
47
|
+
return {
|
|
48
|
+
isTTY: stream.isTTY,
|
|
49
|
+
get columns() { return stream.columns; },
|
|
50
|
+
write(chunk, ...args) {
|
|
51
|
+
if (typeof chunk !== 'string') return stream.write(chunk, ...args);
|
|
52
|
+
return stream.write(formatOutput(chunk, stream, enabled), ...args);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function commandOutput(args, ...streams) {
|
|
58
|
+
const enabled = (outputOptions(args).color ?? settings.getStore()?.enabled) !== false;
|
|
59
|
+
const plain = args.includes('--json');
|
|
60
|
+
return [args.filter(arg => arg !== '--no-color' && arg !== '--color'), ...streams.map(stream => outputStream(stream, { enabled, plain }))];
|
|
61
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single source of truth for veo's per-user cache root. Used for the yt-dlp
|
|
6
|
+
* backend, the staged FFmpeg binaries, and small state files.
|
|
7
|
+
* An invalid relative OS cache setting must never write into the output directory.
|
|
8
|
+
*/
|
|
9
|
+
export function cacheBase({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
10
|
+
let base;
|
|
11
|
+
if (platform === 'win32') base = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
12
|
+
else if (platform === 'darwin') base = path.join(home, 'Library', 'Caches');
|
|
13
|
+
else base = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
14
|
+
if (!path.isAbsolute(base)) base = path.join(home, '.cache');
|
|
15
|
+
return path.join(base, 'veo');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Per-user configuration directory, following each platform's own convention. */
|
|
19
|
+
export function configBase({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
20
|
+
let base;
|
|
21
|
+
if (platform === 'win32') base = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
22
|
+
else if (platform === 'darwin') base = path.join(home, 'Library', 'Application Support');
|
|
23
|
+
else base = env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
24
|
+
if (!path.isAbsolute(base)) base = path.join(home, '.config');
|
|
25
|
+
return path.join(base, 'veo');
|
|
26
|
+
}
|
package/src/playlist.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function validateItems(value) {
|
|
2
|
+
if (typeof value !== 'string' || !/^\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*$/.test(value)) {
|
|
3
|
+
throw new Error('--playlist-items expects positive indices or ranges, e.g. 1,3-5.');
|
|
4
|
+
}
|
|
5
|
+
for (const part of value.split(',')) {
|
|
6
|
+
const [start, end = start] = part.split('-').map(Number);
|
|
7
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
|
|
8
|
+
throw new Error('--playlist-items requires ascending, positive ranges.');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function selectedEntries(metadata, selection) {
|
|
15
|
+
const ranges = selection ? validateItems(selection).split(',').map(part => part.split('-').map(Number)) : null;
|
|
16
|
+
const entries = (metadata.entries || []).map((entry, offset) => ({ entry, index: entry?.playlist_index || offset + 1 }));
|
|
17
|
+
const selected = entries.filter(({ index }) => !ranges || ranges.some(([start, end = start]) => index >= start && index <= end));
|
|
18
|
+
if (!selected.length) throw new Error('No playlist entries match the selection.');
|
|
19
|
+
return selected;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sizeEstimate(entries) {
|
|
23
|
+
const sizes = entries.map(({ entry }) => entry?.filesize || entry?.filesize_approx);
|
|
24
|
+
const known = sizes.filter(value => Number.isFinite(value) && value > 0);
|
|
25
|
+
return { estimatedBytes: known.reduce((sum, value) => sum + value, 0), knownSizes: known.length, totalEntries: entries.length };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeEstimate(estimate) {
|
|
29
|
+
if (!estimate.knownSizes) return `${estimate.totalEntries} entries; total size unknown`;
|
|
30
|
+
return `${estimate.totalEntries} entries; approximately ${(estimate.estimatedBytes / 1048576).toFixed(1)} MiB${estimate.knownSizes < estimate.totalEntries ? ` for ${estimate.knownSizes} entries; remaining sizes unknown` : ''}`;
|
|
31
|
+
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { cleanText } from './utils.js';
|
|
2
|
+
import { createTerminalTitle } from './terminal-title.js';
|
|
3
|
+
|
|
4
|
+
const PROCESSING_LABELS = { Merger: 'Merging audio/video', VideoRemuxer: 'Changing video container', VideoConvertor: 'Converting video', ExtractAudio: 'Converting audio', EmbedSubtitle: 'Embedding subtitles', Metadata: 'Writing metadata', EmbedThumbnail: 'Embedding thumbnail', MoveFiles: 'Preparing saved file' };
|
|
5
|
+
|
|
6
|
+
export function styleText(stream, text, role = 'muted', enabled = true, env = process.env) {
|
|
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 };
|
|
9
|
+
return '\x1b[' + (codes[role] || 90) + 'm' + text + '\x1b[0m';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function bytes(value) {
|
|
13
|
+
if (!Number.isFinite(value) || value < 0) return '?';
|
|
14
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; }
|
|
17
|
+
return `${value.toFixed(i ? 1 : 0)} ${units[i]}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Count terminal cells conservatively, including wide titles and emoji.
|
|
21
|
+
function cells(text) {
|
|
22
|
+
return [...text].reduce((width, char) => width + (/\p{Mark}|\u200d/u.test(char) ? 0 : /[\u1100-\u115f\u2329\u232a\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff01-\uff60\uffe0-\uffe6]|\p{Extended_Pictographic}/u.test(char) ? 2 : 1), 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fit(text, width) {
|
|
26
|
+
if (cells(text) <= width) return text;
|
|
27
|
+
let result = '';
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
if (cells(result + char) > width - 1) break;
|
|
30
|
+
result += char;
|
|
31
|
+
}
|
|
32
|
+
return width > 0 ? result + '…' : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatProgress(data, { columns = Infinity, prefix = '' } = {}) {
|
|
36
|
+
const done = Number.isFinite(data.downloaded_bytes) ? data.downloaded_bytes : 0;
|
|
37
|
+
const total = data.total_bytes || data.total_bytes_estimate;
|
|
38
|
+
const estimated = !data.total_bytes && Boolean(data.total_bytes_estimate);
|
|
39
|
+
const percent = total > 0 ? Math.max(0, Math.min(estimated && data.status !== 'finished' ? 99 : 100, done / total * 100)) : null;
|
|
40
|
+
const filled = percent === null ? 0 : Math.round(percent / 5);
|
|
41
|
+
const eta = Number.isFinite(data.eta) ? `${Math.floor(data.eta / 60)}:${String(Math.floor(data.eta % 60)).padStart(2, '0')}` : '?';
|
|
42
|
+
const pct = `${estimated && data.status !== 'finished' ? '~' : ''}${percent === null ? '?' : percent.toFixed(0)}%`;
|
|
43
|
+
const size = `${estimated ? '~' : ''}${bytes(total)}`;
|
|
44
|
+
const compact = value => bytes(value).replace(' ', '');
|
|
45
|
+
const label = prefix ? fit(cleanText(prefix), Math.max(0, Math.floor(columns / 3))) + ' ' : '';
|
|
46
|
+
const variants = data.status === 'finished'
|
|
47
|
+
? [`${bytes(done)} received; processing…`, `${compact(done)} received`, 'Received']
|
|
48
|
+
: [
|
|
49
|
+
`[${'='.repeat(filled)}${'-'.repeat(20 - filled)}] ${pct.padStart(4)} ${bytes(data.speed)}/s ${bytes(done)} / ${size} ETA ${eta}`,
|
|
50
|
+
`${pct} ${compact(data.speed)}/s ${compact(done)}/${estimated ? '~' : ''}${compact(total)} ETA ${eta}`,
|
|
51
|
+
`${pct} ${compact(data.speed)}/s ETA ${eta}`,
|
|
52
|
+
`${pct} ${compact(done)}`,
|
|
53
|
+
pct,
|
|
54
|
+
];
|
|
55
|
+
for (const variant of variants) if (cells(label + variant) <= columns) return label + variant;
|
|
56
|
+
return fit(variants.at(-1), columns);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream) } = {}) {
|
|
60
|
+
let color = true;
|
|
61
|
+
const muted = text => styleText(stream, text, 'muted', color);
|
|
62
|
+
const heading = text => styleText(stream, text, 'title', color);
|
|
63
|
+
let active = false;
|
|
64
|
+
let lastLog = 0;
|
|
65
|
+
let name = '';
|
|
66
|
+
let phase = 'Starting…';
|
|
67
|
+
let started = false;
|
|
68
|
+
let position = '';
|
|
69
|
+
let hasItem = false;
|
|
70
|
+
let streamName = '';
|
|
71
|
+
const line = (data, prefix) => formatProgress(data, { prefix, columns: stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity });
|
|
72
|
+
const draw = (data, prefix) => { stream.write(`\r\x1b[2K${line(data, prefix)}`); active = true; };
|
|
73
|
+
const updateTitle = () => setTitle(`veo | ${phase}${name ? ` | ${name}` : ''}`);
|
|
74
|
+
function clear() {
|
|
75
|
+
if (active && stream.isTTY) stream.write('\r\x1b[2K');
|
|
76
|
+
active = false;
|
|
77
|
+
}
|
|
78
|
+
const scoped = (index, total, title, parent = '') => {
|
|
79
|
+
let childName = cleanText(title), lastProgress = 0;
|
|
80
|
+
const prefix = parent + '[' + index + '/' + total + '] ';
|
|
81
|
+
const log = (message, quiet = true) => {
|
|
82
|
+
clear();
|
|
83
|
+
const line = prefix + childName + ': ' + cleanText(message);
|
|
84
|
+
stream.write((quiet ? muted(line) : line) + '\n');
|
|
85
|
+
phase = cleanText(message); name = childName;
|
|
86
|
+
if (started) updateTitle();
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
scoped: (index, total, title) => scoped(index, total, title, prefix),
|
|
90
|
+
name(value) { childName = cleanText(value); },
|
|
91
|
+
status: log,
|
|
92
|
+
progress(data) {
|
|
93
|
+
if (stream.isTTY) {
|
|
94
|
+
draw(data, prefix + childName + ': ' + (data.stream || 'Media'));
|
|
95
|
+
} else if (Date.now() - lastProgress >= 5000 || data.status === 'finished') {
|
|
96
|
+
log((data.stream || 'Media') + ' ' + formatProgress(data), false);
|
|
97
|
+
lastProgress = Date.now();
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
processing(data) { log((PROCESSING_LABELS[data.postprocessor] || 'Processing media') + (data.status === 'finished' ? ': done' : '…')); },
|
|
101
|
+
finish() {},
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
configure(options) { color = options.color !== false; },
|
|
106
|
+
scoped,
|
|
107
|
+
item(index, total, title) {
|
|
108
|
+
clear(); position = total > 1 ? `[${index}/${total}] ` : ''; hasItem = true; name = cleanText(title); streamName = ''; lastLog = 0;
|
|
109
|
+
stream.write(heading(`${position}${name}`) + '\n');
|
|
110
|
+
phase = 'Starting…'; if (started) updateTitle();
|
|
111
|
+
},
|
|
112
|
+
processing(data) {
|
|
113
|
+
const processor = cleanText(data.postprocessor || 'Processing');
|
|
114
|
+
const label = PROCESSING_LABELS[processor] || 'Processing media';
|
|
115
|
+
clear(); phase = `${label}${data.status === 'finished' ? ': done' : '…'}`;
|
|
116
|
+
if (started) updateTitle();
|
|
117
|
+
stream.write(muted(`${position}${phase}`) + '\n');
|
|
118
|
+
},
|
|
119
|
+
start(title = '') { started = true; name = cleanText(title); phase = 'Starting…'; updateTitle(); },
|
|
120
|
+
name(title) {
|
|
121
|
+
const next = cleanText(title);
|
|
122
|
+
if (hasItem && next !== name) { clear(); stream.write(heading(`${position}${next}`) + '\n'); }
|
|
123
|
+
name = next; if (started) updateTitle();
|
|
124
|
+
},
|
|
125
|
+
status(message) {
|
|
126
|
+
clear();
|
|
127
|
+
phase = cleanText(message);
|
|
128
|
+
if (started) updateTitle();
|
|
129
|
+
stream.write(muted(phase) + '\n');
|
|
130
|
+
},
|
|
131
|
+
complete() { clear(); phase = 'Done'; if (started) updateTitle(); },
|
|
132
|
+
fail(cancelled = false) { clear(); phase = cancelled ? 'Cancelled' : 'Failed'; if (started) updateTitle(); },
|
|
133
|
+
progress(data) {
|
|
134
|
+
if (data.stream && data.stream !== streamName) {
|
|
135
|
+
clear(); streamName = data.stream; lastLog = 0;
|
|
136
|
+
stream.write(muted(`${position}${streamName} download`) + '\n');
|
|
137
|
+
}
|
|
138
|
+
const total = data.total_bytes;
|
|
139
|
+
const percent = Number.isFinite(total) && total > 0 && Number.isFinite(data.downloaded_bytes)
|
|
140
|
+
? Math.max(0, Math.min(100, Math.round(data.downloaded_bytes / total * 100))) : null;
|
|
141
|
+
phase = data.status === 'finished' ? 'Processing…' : percent === null ? 'Downloading…' : `${percent}%`;
|
|
142
|
+
if (started) updateTitle();
|
|
143
|
+
if (stream.isTTY) {
|
|
144
|
+
draw(data, `${position}${streamName}`);
|
|
145
|
+
} else if (Date.now() - lastLog >= 5000 || data.status === 'finished') {
|
|
146
|
+
stream.write(`${position}${streamName ? `${streamName} ` : ''}${formatProgress(data)}\n`);
|
|
147
|
+
lastLog = Date.now();
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
finish: clear,
|
|
151
|
+
};
|
|
152
|
+
}
|