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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { access, chmod, mkdtemp, realpath, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { cleanText } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// Only fixed package names and arguments are passed to these installers.
|
|
7
|
+
export function runSetup(command, args, {
|
|
8
|
+
signal, env = process.env, cwd, status = () => {}, timeoutMs = 600_000,
|
|
9
|
+
} = {}) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const child = spawn(command, args, {
|
|
13
|
+
shell: false, windowsHide: true, detached: process.platform !== 'win32',
|
|
14
|
+
stdio: ['ignore', 'pipe', 'pipe'], env, cwd,
|
|
15
|
+
});
|
|
16
|
+
let output = '';
|
|
17
|
+
let stopped;
|
|
18
|
+
const stop = error => {
|
|
19
|
+
if (stopped) return;
|
|
20
|
+
stopped = error;
|
|
21
|
+
if (process.platform === 'win32' && child.pid) {
|
|
22
|
+
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { shell: false, windowsHide: true, stdio: 'ignore' });
|
|
23
|
+
killer.on('error', () => child.kill());
|
|
24
|
+
} else {
|
|
25
|
+
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const abort = () => stop(signal.reason);
|
|
29
|
+
const timer = setTimeout(() => stop(new Error('Tool installation timed out. Retry with veo doctor fix.')), timeoutMs);
|
|
30
|
+
const receive = chunk => {
|
|
31
|
+
const text = cleanText(String(chunk));
|
|
32
|
+
output = (output + text).slice(-8192);
|
|
33
|
+
// Surface package-manager progress without taking over stdin or stdout.
|
|
34
|
+
for (const line of text.split(/[\r\n]+/)) if (line.trim()) status(line.slice(0, 500));
|
|
35
|
+
};
|
|
36
|
+
child.stdout.on('data', receive);
|
|
37
|
+
child.stderr.on('data', receive);
|
|
38
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
39
|
+
if (signal?.aborted) abort();
|
|
40
|
+
const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
|
|
41
|
+
child.once('error', error => { cleanup(); reject(stopped || error); });
|
|
42
|
+
child.once('close', code => {
|
|
43
|
+
cleanup();
|
|
44
|
+
if (stopped) reject(stopped);
|
|
45
|
+
else if (code !== 0) reject(new Error(`${path.basename(command)} exited with code ${code}: ${output.trim()}`));
|
|
46
|
+
else resolve(output);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function hasTermuxEjs({ find, run = runSetup, signal }) {
|
|
52
|
+
const python = await find(['python', 'python3']);
|
|
53
|
+
if (!python) return false;
|
|
54
|
+
try {
|
|
55
|
+
await run(python, ['-B', '-c', 'import yt_dlp_ejs'], { signal, timeoutMs: 15_000 });
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
signal?.throwIfAborted();
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let termuxInstall;
|
|
64
|
+
export async function installTermuxTools({ find, signal, status, run = runSetup, env = process.env }) {
|
|
65
|
+
if (termuxInstall) {
|
|
66
|
+
await termuxInstall;
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
termuxInstall = (async () => {
|
|
71
|
+
const pkg = await find(['pkg']);
|
|
72
|
+
if (!pkg) throw new Error('Automatic Android setup requires Termux with pkg on PATH. Install python-yt-dlp, yt-dlp-ejs and ffmpeg in Termux.');
|
|
73
|
+
signal?.throwIfAborted();
|
|
74
|
+
status('Installing yt-dlp, JavaScript support and FFmpeg with Termux pkg (first use)…');
|
|
75
|
+
// pkg refreshes mirrors/package lists itself. No full system upgrade or sudo.
|
|
76
|
+
await run(pkg, ['install', env.TERMUX_APP_PACKAGE_MANAGER === 'pacman' ? '--noconfirm' : '-y', 'python-yt-dlp', 'yt-dlp-ejs', 'ffmpeg'], {
|
|
77
|
+
signal, status, env: { ...env, DEBIAN_FRONTEND: 'noninteractive' },
|
|
78
|
+
});
|
|
79
|
+
})();
|
|
80
|
+
try { await termuxInstall; }
|
|
81
|
+
catch (cause) {
|
|
82
|
+
signal?.throwIfAborted();
|
|
83
|
+
throw new Error(`Automatic Termux setup failed: ${cause.message}. Retry with veo doctor fix; if the repository needs repair, run pkg update in Termux.`, { cause });
|
|
84
|
+
} finally { termuxInstall = undefined; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function mediaPackagePlan(platform = process.platform, arch = process.arch) {
|
|
88
|
+
// Windows 11 ARM runs the upstream x64 media tools via OS emulation.
|
|
89
|
+
const binaryArch = platform === 'win32' && arch === 'arm64' ? 'x64' : arch;
|
|
90
|
+
const versions = {
|
|
91
|
+
'win32-x64': '5.1.0', 'win32-ia32': '5.1.0',
|
|
92
|
+
'darwin-x64': '5.1.0', 'darwin-arm64': '5.0.1',
|
|
93
|
+
'linux-x64': '5.2.0', 'linux-ia32': '5.2.0', 'linux-arm64': '5.2.0', 'linux-arm': '5.2.0',
|
|
94
|
+
};
|
|
95
|
+
const target = `${platform}-${binaryArch}`;
|
|
96
|
+
if (!versions[target]) return undefined;
|
|
97
|
+
return { binaryArch, probePackage: `@ffprobe-installer/${target}`, probeVersion: versions[target] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function npmCli(find, env) {
|
|
101
|
+
const npm = await find(['npm']);
|
|
102
|
+
const candidates = [
|
|
103
|
+
env.npm_execpath,
|
|
104
|
+
npm && await realpath(npm).catch(() => undefined),
|
|
105
|
+
npm && path.join(path.dirname(npm), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
106
|
+
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
107
|
+
path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js'),
|
|
108
|
+
];
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
if (!candidate || path.basename(candidate) !== 'npm-cli.js') continue;
|
|
111
|
+
if (await stat(candidate).then(info => info.isFile(), () => false)) return candidate;
|
|
112
|
+
}
|
|
113
|
+
throw new Error('npm was not found. Install Node.js with npm, then run veo doctor fix.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function installMediaTools({
|
|
117
|
+
directory, find, stage, signal, status, platform = process.platform, arch = process.arch,
|
|
118
|
+
env = process.env, run = runSetup, locateNpm = npmCli,
|
|
119
|
+
}) {
|
|
120
|
+
const plan = mediaPackagePlan(platform, arch);
|
|
121
|
+
if (!plan) throw new Error(`Automatic FFmpeg setup is unavailable for ${platform}/${arch}. Set VEO_FFMPEG_PATH to a directory containing ffmpeg and ffprobe.`);
|
|
122
|
+
const cli = await locateNpm(find, env);
|
|
123
|
+
const temporary = await mkdtemp(path.join(directory, '.media-setup-'));
|
|
124
|
+
const suffix = platform === 'win32' ? '.exe' : '';
|
|
125
|
+
try {
|
|
126
|
+
status('Downloading FFmpeg and FFprobe into the veo cache (first use)…');
|
|
127
|
+
// This is a private throwaway project, never the global package or cwd.
|
|
128
|
+
// Ignore dependency scripts; invoke the pinned ffmpeg installer explicitly.
|
|
129
|
+
const args = [cli, 'install', '--prefix', temporary, '--no-save', '--package-lock=false', '--ignore-scripts', '--no-audit', '--no-fund', '--global=false', 'ffmpeg-static@5.3.0', `${plan.probePackage}@${plan.probeVersion}`];
|
|
130
|
+
if (platform === 'win32' && arch === 'arm64') args.push('--force');
|
|
131
|
+
await run(process.execPath, args, { signal, status, cwd: temporary, env });
|
|
132
|
+
const modules = path.join(temporary, 'node_modules');
|
|
133
|
+
await run(process.execPath, [path.join(modules, 'ffmpeg-static', 'install.js')], {
|
|
134
|
+
signal, status, cwd: temporary,
|
|
135
|
+
env: { ...env, npm_config_platform: platform, npm_config_arch: plan.binaryArch, FFMPEG_BIN: '', FFMPEG_BINARY_RELEASE: 'b6.1.1' },
|
|
136
|
+
});
|
|
137
|
+
const ffmpeg = path.join(modules, 'ffmpeg-static', `ffmpeg${suffix}`);
|
|
138
|
+
const ffprobe = path.join(modules, ...plan.probePackage.split('/'), `ffprobe${suffix}`);
|
|
139
|
+
for (const file of [ffmpeg, ffprobe]) {
|
|
140
|
+
await access(file);
|
|
141
|
+
if (process.platform !== 'win32') await chmod(file, 0o755);
|
|
142
|
+
await run(file, ['-version'], { signal, timeoutMs: 15_000 });
|
|
143
|
+
}
|
|
144
|
+
await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
|
|
145
|
+
await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
|
|
146
|
+
return directory;
|
|
147
|
+
} catch (cause) {
|
|
148
|
+
signal?.throwIfAborted();
|
|
149
|
+
throw new Error(`Automatic FFmpeg setup failed: ${cause.message}. Retry with veo doctor fix or set VEO_FFMPEG_PATH to installed tools.`, { cause });
|
|
150
|
+
} finally { await rm(temporary, { recursive: true, force: true }); }
|
|
151
|
+
}
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { commandOutput } from './output.js';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { RELEASE, readBackendOverride } from './backend.js';
|
|
6
|
+
import { cacheBase } from './paths.js';
|
|
7
|
+
import { compareVersions } from './version.js';
|
|
8
|
+
import { readableError } from './utils.js';
|
|
9
|
+
|
|
10
|
+
export { compareVersions };
|
|
11
|
+
|
|
12
|
+
const PACKAGE = 'veodl';
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
export const UPDATE_HELP = `veo update - keep veo current
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
veo update Install the latest version with npm
|
|
19
|
+
veo update --check Only check whether a newer version exists
|
|
20
|
+
veo upgrade Alias for veo update
|
|
21
|
+
veo check update Alias for veo update --check
|
|
22
|
+
|
|
23
|
+
The registry can be overridden with VEO_REGISTRY (or npm_config_registry).
|
|
24
|
+
Set VEO_NO_UPDATE_CHECK=1 to disable the automatic post-download check.
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export function defaultRegistry(env = process.env) {
|
|
28
|
+
const registry = env.VEO_REGISTRY || env.npm_config_registry;
|
|
29
|
+
if (typeof registry === 'string' && /^https?:\/\//.test(registry.trim())) return registry.trim().replace(/\/+$/, '');
|
|
30
|
+
return 'https://registry.npmjs.org';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchLatestVersion({ registry = defaultRegistry(), fetchImpl = fetch, timeoutMs = 8000, signal } = {}) {
|
|
34
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
35
|
+
const url = `${registry}/${encodeURIComponent(PACKAGE)}`;
|
|
36
|
+
const response = await fetchImpl(url, {
|
|
37
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
38
|
+
headers: { accept: 'application/json' },
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
await response.body?.cancel();
|
|
42
|
+
throw new Error(`Update check failed: HTTP ${response.status}.`);
|
|
43
|
+
}
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
// Full packument: pick the highest version instead of trusting the "latest" tag.
|
|
46
|
+
const versions = data?.versions ? Object.keys(data.versions) : [data?.version].filter(Boolean);
|
|
47
|
+
if (!versions.length) throw new Error('Update check returned no versions.');
|
|
48
|
+
const [latest] = versions.sort((a, b) => compareVersions(b, a));
|
|
49
|
+
const version = typeof latest === 'string' ? latest : '';
|
|
50
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error('Update check returned an invalid version.');
|
|
51
|
+
return version;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Same OS cache root as the yt-dlp backend cache (see src/paths.js).
|
|
55
|
+
export function veoCacheBase(options) {
|
|
56
|
+
return cacheBase(options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function readState(stateFile) {
|
|
60
|
+
try {
|
|
61
|
+
const state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
62
|
+
return Number.isFinite(state?.lastCheck) ? state : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeState(stateFile, state) {
|
|
69
|
+
await mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
70
|
+
await writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Throttled (once per day) post-download check. Purely informational; every
|
|
74
|
+
// failure is silent so downloads are never delayed or marked as failed.
|
|
75
|
+
export async function maybeUpdateNotice({
|
|
76
|
+
currentVersion,
|
|
77
|
+
env = process.env,
|
|
78
|
+
stateFile,
|
|
79
|
+
fetchImpl = fetch,
|
|
80
|
+
registry,
|
|
81
|
+
now = Date.now(),
|
|
82
|
+
ttlMs = DAY_MS,
|
|
83
|
+
timeoutMs = 4000,
|
|
84
|
+
} = {}) {
|
|
85
|
+
if (env.VEO_NO_UPDATE_CHECK) return null;
|
|
86
|
+
try {
|
|
87
|
+
if (!stateFile) stateFile = path.join(veoCacheBase({ env }), 'update-check.json');
|
|
88
|
+
const state = await readState(stateFile);
|
|
89
|
+
if (state && now - state.lastCheck < ttlMs) return null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null; // No usable cache location: never turn a notice into noise.
|
|
92
|
+
}
|
|
93
|
+
let latest = null;
|
|
94
|
+
let message = null;
|
|
95
|
+
try {
|
|
96
|
+
latest = await fetchLatestVersion({ fetchImpl, registry, timeoutMs });
|
|
97
|
+
if (compareVersions(latest, currentVersion) > 0) {
|
|
98
|
+
message = `Update available: veo ${latest} (you have ${currentVersion}). Run: veo update`;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
latest = null; // Unreachable registry still consumes the throttle interval.
|
|
102
|
+
}
|
|
103
|
+
await writeState(stateFile, { lastCheck: now, latest }).catch(() => {});
|
|
104
|
+
return message;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Removes yt-dlp caches from older pinned releases; only the releases still in
|
|
108
|
+
// use stay (the pinned one plus an explicitly installed newer backend).
|
|
109
|
+
export async function pruneBackendCaches({ keep, extra = [], root, platform = process.platform, env = process.env } = {}) {
|
|
110
|
+
const retained = new Set([keep, ...extra].filter(Boolean));
|
|
111
|
+
const base = root || path.join(veoCacheBase({ platform, env }), 'backends');
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (error.code === 'ENOENT') return 0;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
let removed = 0;
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (!entry.isDirectory() || retained.has(entry.name)) continue;
|
|
122
|
+
await rm(path.join(base, entry.name), { recursive: true, force: true });
|
|
123
|
+
removed++;
|
|
124
|
+
}
|
|
125
|
+
return removed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function npmSpawnCommand({ platform = process.platform } = {}) {
|
|
129
|
+
const args = ['install', '-g', '--no-fund', '--no-audit', `${PACKAGE}@latest`];
|
|
130
|
+
// npm.cmd needs cmd.exe on Windows. Pass a fixed command explicitly instead
|
|
131
|
+
// of Node's deprecated shell:true + args combination (DEP0190).
|
|
132
|
+
// Never interpolate user input into this command string.
|
|
133
|
+
if (platform === 'win32') return {
|
|
134
|
+
command: 'cmd.exe',
|
|
135
|
+
args: ['/d', '/s', '/c', `npm ${args.join(' ')}`],
|
|
136
|
+
shell: false,
|
|
137
|
+
windowsVerbatimArguments: true,
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
command: 'npm',
|
|
141
|
+
args,
|
|
142
|
+
shell: false,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function runNpmUpdate({ spawnImpl = spawn, platform = process.platform, signal, timeoutMs = 600_000 } = {}) {
|
|
147
|
+
const { command, args, ...spawnOptions } = npmSpawnCommand({ platform });
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const child = spawnImpl(command, args, { ...spawnOptions, stdio: 'inherit', windowsHide: true, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
|
|
150
|
+
child.once('error', reject);
|
|
151
|
+
child.once('close', resolve);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function packageVersion() {
|
|
156
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
157
|
+
return pkg.version;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Implements `veo update [--check]`, `veo upgrade`, and `veo check update`.
|
|
162
|
+
* Returns the process exit code. Deps are injectable for tests.
|
|
163
|
+
*/
|
|
164
|
+
export async function updateMain(args, {
|
|
165
|
+
registry = defaultRegistry(),
|
|
166
|
+
fetchImpl = fetch,
|
|
167
|
+
spawnImpl = spawn,
|
|
168
|
+
platform = process.platform,
|
|
169
|
+
stdout = process.stdout,
|
|
170
|
+
stderr = process.stderr,
|
|
171
|
+
current,
|
|
172
|
+
keep = RELEASE,
|
|
173
|
+
pruneRoot = undefined,
|
|
174
|
+
activeBackend,
|
|
175
|
+
} = {}) {
|
|
176
|
+
[args, stdout, stderr] = commandOutput(args, stdout, stderr);
|
|
177
|
+
current = current ?? await packageVersion();
|
|
178
|
+
const isCheckCommand = args[0] === 'check';
|
|
179
|
+
const rest = isCheckCommand ? args.slice(2) : args.slice(1);
|
|
180
|
+
if (rest.includes('-h') || rest.includes('--help')) {
|
|
181
|
+
stdout.write(UPDATE_HELP);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
const checkOnly = isCheckCommand || rest.includes('--check') || (rest.length === 1 && rest[0] === 'check');
|
|
185
|
+
const unknown = rest.filter(token => token !== '--check' && !(checkOnly && token === 'check'));
|
|
186
|
+
if (unknown.length) throw new Error(`Unknown option for veo update: ${unknown.join(' ')}. Use: veo update [--check]`);
|
|
187
|
+
const currentText = `veo ${current}`;
|
|
188
|
+
let latest;
|
|
189
|
+
try {
|
|
190
|
+
latest = await fetchLatestVersion({ registry, fetchImpl, timeoutMs: 10_000 });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
stderr.write(`veo: ${readableError(error)}\nUpdate manually with: npm install -g ${PACKAGE}@latest\n`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
const newer = compareVersions(latest, current) > 0;
|
|
196
|
+
if (checkOnly) {
|
|
197
|
+
stdout.write(newer ? `Update available: veo ${latest} (you have ${current}). Run: veo update\n` : `${currentText} is up to date.\n`);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
if (newer) {
|
|
201
|
+
stdout.write(`Updating ${currentText} → ${latest} with npm…\n`);
|
|
202
|
+
let code;
|
|
203
|
+
try {
|
|
204
|
+
code = await runNpmUpdate({ spawnImpl, platform, signal: AbortSignal.timeout(600_000) });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const reason = error.code === 'ENOENT' ? 'npm was not found. Install Node.js/npm, or update manually.' : `Could not run npm: ${readableError(error)}`;
|
|
207
|
+
stderr.write(`veo: ${reason}\nManual command: npm install -g ${PACKAGE}@latest\n`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
if (code !== 0) {
|
|
211
|
+
stderr.write(`veo: npm exited with code ${code}. Update manually with: npm install -g ${PACKAGE}@latest\n`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
stdout.write(`veo updated to ${latest}. The next veo call uses the new version.\n`);
|
|
215
|
+
} else {
|
|
216
|
+
stdout.write(`${currentText} is up to date.\n`);
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
// An explicitly installed backend release must survive the pruning.
|
|
220
|
+
const installed = activeBackend !== undefined ? activeBackend : (await readBackendOverride())?.release;
|
|
221
|
+
const removed = await pruneBackendCaches({ keep, extra: [installed], root: pruneRoot });
|
|
222
|
+
if (removed) stdout.write(`Removed ${removed} old backend cache${removed === 1 ? '' : 's'}.\n`);
|
|
223
|
+
} catch {
|
|
224
|
+
// Cosmetic housekeeping must never fail the update.
|
|
225
|
+
}
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { copyFile, link, lstat, unlink } from 'node:fs/promises';
|
|
3
|
+
import { accessSync, constants, statSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export const QUALITIES = ['best', '2160p', '1440p', '1080p', '720p', '480p', '360p'];
|
|
6
|
+
export const VIDEO_FORMATS = ['mp4', 'mkv', 'webm', 'mov'];
|
|
7
|
+
export const AUDIO_FORMATS = ['mp3', 'm4a', 'aac', 'opus', 'flac', 'wav'];
|
|
8
|
+
// Browsers yt-dlp can read cookies from. veo only forwards the choice; it never
|
|
9
|
+
// decrypts or copies cookie stores itself.
|
|
10
|
+
export const COOKIE_BROWSERS = ['brave', 'chrome', 'chromium', 'edge', 'firefox', 'opera', 'safari', 'vivaldi', 'whale'];
|
|
11
|
+
const COOKIE_KEYRINGS = ['gnomekeyring', 'kwallet', 'basic'];
|
|
12
|
+
|
|
13
|
+
export function validateUrl(input) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(input);
|
|
16
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error();
|
|
17
|
+
return url.href;
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error('Invalid URL. Use a complete http:// or https:// video URL without embedded credentials.');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Untrusted site titles must never become paths, terminal escapes, or device names.
|
|
24
|
+
export function sanitizeTitle(title) {
|
|
25
|
+
let name = String(title || 'video').normalize('NFC')
|
|
26
|
+
.replace(/[<>:"/\\|?*\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '_')
|
|
27
|
+
.trim().replace(/[. ]+$/g, '');
|
|
28
|
+
if (/^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name)) name = `_${name}`;
|
|
29
|
+
let shortened = '';
|
|
30
|
+
for (const char of name) {
|
|
31
|
+
if (Buffer.byteLength(shortened + char) > 180) break;
|
|
32
|
+
shortened += char;
|
|
33
|
+
}
|
|
34
|
+
return shortened.replace(/[. ]+$/g, '') || 'video';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function cleanText(value) {
|
|
38
|
+
return String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Locale-independent local timestamp for CLI output, e.g. "2026-02-03 14:22". */
|
|
42
|
+
export function localStamp(value) {
|
|
43
|
+
const date = new Date(value);
|
|
44
|
+
if (!Number.isFinite(date.getTime())) return 'unknown';
|
|
45
|
+
const pad = number => String(number).padStart(2, '0');
|
|
46
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a Netscape-format cookie file before any network work starts, so a
|
|
51
|
+
* typo fails immediately instead of after backend acquisition. The file is only
|
|
52
|
+
* read by the backend; veo never parses or stores its contents.
|
|
53
|
+
*/
|
|
54
|
+
export function validateCookieFile(input) {
|
|
55
|
+
const raw = String(input ?? '').trim();
|
|
56
|
+
if (!raw) throw new Error('--cookies requires the path to a Netscape-format cookie file.');
|
|
57
|
+
if (raw.includes('\0')) throw new Error('The cookie file path contains an invalid character.');
|
|
58
|
+
const file = path.resolve(raw);
|
|
59
|
+
let info;
|
|
60
|
+
try {
|
|
61
|
+
info = statSync(file);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`The cookie file does not exist: ${file}`);
|
|
64
|
+
}
|
|
65
|
+
if (!info.isFile()) throw new Error(`The cookie file is not a regular file: ${file}`);
|
|
66
|
+
try {
|
|
67
|
+
accessSync(file, constants.R_OK);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new Error(`The cookie file is not readable: ${file}`);
|
|
70
|
+
}
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// yt-dlp syntax: BROWSER[+KEYRING][:PROFILE][::CONTAINER]
|
|
75
|
+
export function validateBrowserSpec(input) {
|
|
76
|
+
const value = cleanText(input).trim();
|
|
77
|
+
if (!value) throw new Error('--cookies-from-browser requires a browser name.');
|
|
78
|
+
const [head, container, ...extraContainers] = value.split('::');
|
|
79
|
+
if (extraContainers.length) throw new Error('--cookies-from-browser accepts at most BROWSER[:PROFILE][::CONTAINER].');
|
|
80
|
+
const [browserPart, profile, ...extraProfiles] = head.split(':');
|
|
81
|
+
if (extraProfiles.length) throw new Error('--cookies-from-browser accepts at most BROWSER[:PROFILE][::CONTAINER].');
|
|
82
|
+
const [name, keyring, ...extraKeyrings] = browserPart.split('+');
|
|
83
|
+
if (!COOKIE_BROWSERS.includes(name.toLowerCase())) {
|
|
84
|
+
throw new Error(`Unsupported browser for --cookies-from-browser. Choose: ${COOKIE_BROWSERS.join(', ')}.`);
|
|
85
|
+
}
|
|
86
|
+
if (extraKeyrings.length) throw new Error('At most one keyring may follow "+" in --cookies-from-browser.');
|
|
87
|
+
if (keyring && !COOKIE_KEYRINGS.includes(keyring.toLowerCase())) {
|
|
88
|
+
throw new Error(`Unsupported keyring for --cookies-from-browser. Choose: ${COOKIE_KEYRINGS.join(', ')}.`);
|
|
89
|
+
}
|
|
90
|
+
for (const [label, part] of [['profile', profile], ['container', container]]) {
|
|
91
|
+
if (part === undefined) continue;
|
|
92
|
+
if (!part.trim() || cleanText(part) !== part.trim() || part.trim().startsWith('-')) {
|
|
93
|
+
throw new Error(`The --cookies-from-browser ${label} is empty or contains invalid characters.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// A cookie file readable by other accounts is a credential leak worth one line.
|
|
100
|
+
export function cookieFileWarning(file, { platform = process.platform, stat = statSync } = {}) {
|
|
101
|
+
if (!file || platform === 'win32') return null;
|
|
102
|
+
try {
|
|
103
|
+
if ((stat(file).mode & 0o077) !== 0) return `Warning: ${file} is readable by other users. Restrict it with: chmod 600 "${file}"`;
|
|
104
|
+
} catch { /* The file was validated earlier; a race here is not worth reporting. */ }
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Video heights a site actually offers, ignoring audio-only and DRM entries.
|
|
109
|
+
export function availableHeights(formats) {
|
|
110
|
+
return [...new Set((formats || [])
|
|
111
|
+
.filter(format => format && format.vcodec !== 'none' && !format.has_drm && Number.isFinite(format.height) && format.height > 0)
|
|
112
|
+
.map(format => format.height))];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function closestHeight(formats, quality) {
|
|
116
|
+
if (quality === 'best') return null;
|
|
117
|
+
const target = Number.parseInt(quality, 10);
|
|
118
|
+
// Equidistant alternatives prefer the smaller download.
|
|
119
|
+
return availableHeights(formats).sort((a, b) => Math.abs(a - target) - Math.abs(b - target) || a - b)[0] ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The highest offered resolution at or below the request, or undefined. */
|
|
123
|
+
export function cappedHeight(formats, quality) {
|
|
124
|
+
const target = Number.parseInt(quality, 10);
|
|
125
|
+
return availableHeights(formats).filter(height => height <= target).sort((a, b) => b - a)[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Filesystems without hard links (FAT/exFAT, some SMB and container mounts)
|
|
129
|
+
// report one of these; anything else is a real error and must surface.
|
|
130
|
+
const NO_HARDLINK = new Set(['EXDEV', 'EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EMLINK', 'EINVAL']);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Occupy `destination` without ever replacing an existing file. Returns false
|
|
134
|
+
* when the name is taken. Local staging can be on another drive, so a hard
|
|
135
|
+
* link is an O(1) metadata operation when supported; cross-drive saves and
|
|
136
|
+
* filesystems that cannot link fall back to copying. Both paths are race-safe
|
|
137
|
+
* against concurrent veo processes.
|
|
138
|
+
*/
|
|
139
|
+
export async function allocate(source, destination, { linkImpl = link, copyImpl = copyFile } = {}) {
|
|
140
|
+
try {
|
|
141
|
+
await linkImpl(source, destination);
|
|
142
|
+
return true;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code === 'EEXIST') return false;
|
|
145
|
+
if (error.code === 'EISDIR') {
|
|
146
|
+
// Some virtual drives report EISDIR for unsupported hard links even when
|
|
147
|
+
// the source is a regular file. Do not mistake a real directory for media.
|
|
148
|
+
if (!(await lstat(source)).isFile()) throw error;
|
|
149
|
+
const existing = await lstat(destination).catch(problem => {
|
|
150
|
+
if (problem.code === 'ENOENT') return null;
|
|
151
|
+
throw problem;
|
|
152
|
+
});
|
|
153
|
+
if (existing) return false;
|
|
154
|
+
} else if (!NO_HARDLINK.has(error.code)) throw error;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await copyImpl(source, destination, constants.COPYFILE_EXCL);
|
|
158
|
+
const [sourceInfo, destinationInfo] = await Promise.all([lstat(source), lstat(destination)]);
|
|
159
|
+
if (sourceInfo.size !== destinationInfo.size) {
|
|
160
|
+
await unlink(destination);
|
|
161
|
+
throw new Error('The saved copy has an unexpected size. The local original has been preserved.');
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code === 'EEXIST') return false;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function saveUnique(source, directory, title, { signal, keepSource = false } = {}) {
|
|
171
|
+
const extension = path.extname(source).toLowerCase();
|
|
172
|
+
if (!/^\.[a-z0-9]{1,8}$/.test(extension)) throw new Error('The backend returned an invalid output extension.');
|
|
173
|
+
const name = sanitizeTitle(title);
|
|
174
|
+
for (let number = 0; ; number++) {
|
|
175
|
+
signal?.throwIfAborted();
|
|
176
|
+
const destination = path.join(directory, `${name}${number ? ` (${number})` : ''}${extension}`);
|
|
177
|
+
if (!await allocate(source, destination)) continue;
|
|
178
|
+
// The destination keeps the content, so a failing unlink only leaves a
|
|
179
|
+
// second name that the caller's staging cleanup removes.
|
|
180
|
+
if (!keepSource) await unlink(source).catch(() => {});
|
|
181
|
+
return destination;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function readableError(error) {
|
|
186
|
+
if (error.name === 'AbortError') return 'Cancelled.';
|
|
187
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return 'Permission denied. Choose a writable output directory or check executable permissions.';
|
|
188
|
+
if (error.code === 'ENOSPC') return 'Not enough disk space to save the download.';
|
|
189
|
+
const text = cleanText(error.message || error);
|
|
190
|
+
if (/did not get any data blocks/i.test(text)) return 'The media stream returned no data (yt-dlp: Did not get any data blocks). The download is not confirmed complete. Retry the download; if it persists, update the backend with veo backend update.';
|
|
191
|
+
if (/unsupported url|no suitable extractor/i.test(text)) return 'This URL or website is not supported by the downloading backend.';
|
|
192
|
+
if (/private|login required|sign in|log in|authentication|members.only|not a bot|cookies/i.test(text)) return 'This content is private or requires authentication. veo does not bypass access controls. If you are authorized to view it, pass your own session with --cookies <file> or --cookies-from-browser <browser>.';
|
|
193
|
+
if (/deleted|removed|no longer available|404|not found|does not exist/i.test(text)) return 'The content was deleted, removed, or could not be found.';
|
|
194
|
+
if (/could not write header|only vp8 or vp9 or av1|not supported in container|could not find tag/i.test(text)) return 'The selected codecs do not fit this container. Try --format mkv for lossless output, or add --recode to explicitly convert (may lose quality).';
|
|
195
|
+
if (/requested format|no video formats|no suitable formats|conversion failed|error opening encoder|could not find tag/i.test(text)) return 'The requested format is unavailable or could not be converted. Try -q best or another --format.';
|
|
196
|
+
if (/drm.protected|digital rights/i.test(text)) return 'This content is DRM-protected. veo does not remove DRM.';
|
|
197
|
+
const http = text.match(/HTTP(?: Error)?\s*:?\s*(403|429|5\d\d)\b/i)?.[1];
|
|
198
|
+
if (http === '403') return 'HTTP 403 Forbidden: the server refused the media download. The video page may still be accessible. Try veo backend update and retry.';
|
|
199
|
+
if (http === '429') return 'HTTP 429 Too Many Requests: the server is rate-limiting downloads. Wait before retrying.';
|
|
200
|
+
if (http) return `HTTP ${http}: the server failed to handle the download. Try again later.`;
|
|
201
|
+
if (/network|timed? ?out|connection|resolve|ENOTFOUND|ECONN|fetch failed/i.test(text)) {
|
|
202
|
+
const detail = text.match(/\b(?:ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|ETIMEDOUT)\b/i)?.[0]
|
|
203
|
+
|| (/timed? ?out/i.test(text) ? 'request timed out' : /connection reset/i.test(text) ? 'connection reset' : 'connection failed');
|
|
204
|
+
return `Network request failed (${detail}). Check your connection and try again later.`;
|
|
205
|
+
}
|
|
206
|
+
if (/not available|unavailable|geo.?restrict|country/i.test(text)) return 'This content is unavailable or restricted in your region.';
|
|
207
|
+
return text.slice(-1200) || 'Download failed. Please try again.';
|
|
208
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shared semver-ish comparison for veo releases and yt-dlp release dates.
|
|
2
|
+
export function compareVersions(a, b) {
|
|
3
|
+
const parse = value => {
|
|
4
|
+
const [core, pre = ''] = String(value).trim().replace(/^v/, '').split('-');
|
|
5
|
+
if (!/^\d+(\.\d+)*$/.test(core)) throw new Error(`Invalid version: ${value}`);
|
|
6
|
+
// Semver prerelease identifiers compare per segment, numerically when numeric.
|
|
7
|
+
return { parts: core.split('.').map(Number), pre: pre === '' ? [] : pre.split('.') };
|
|
8
|
+
};
|
|
9
|
+
const left = parse(a);
|
|
10
|
+
const right = parse(b);
|
|
11
|
+
for (let index = 0; index < Math.max(left.parts.length, right.parts.length); index++) {
|
|
12
|
+
const difference = (left.parts[index] || 0) - (right.parts[index] || 0);
|
|
13
|
+
if (difference) return Math.sign(difference);
|
|
14
|
+
}
|
|
15
|
+
// A release outranks any prerelease of the same core version.
|
|
16
|
+
if (!left.pre.length && !right.pre.length) return 0;
|
|
17
|
+
if (!left.pre.length) return 1;
|
|
18
|
+
if (!right.pre.length) return -1;
|
|
19
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index++) {
|
|
20
|
+
const l = left.pre[index];
|
|
21
|
+
const r = right.pre[index];
|
|
22
|
+
if (l === undefined) return -1;
|
|
23
|
+
if (r === undefined) return 1;
|
|
24
|
+
const lNumeric = /^\d+$/.test(l);
|
|
25
|
+
const rNumeric = /^\d+$/.test(r);
|
|
26
|
+
if (lNumeric && rNumeric) {
|
|
27
|
+
const difference = Number(l) - Number(r);
|
|
28
|
+
if (difference) return Math.sign(difference);
|
|
29
|
+
} else if (lNumeric) return -1; // Numeric identifiers rank below alphanumerics.
|
|
30
|
+
else if (rNumeric) return 1;
|
|
31
|
+
else if (l !== r) return l < r ? -1 : 1;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isNewer(candidate, current) {
|
|
37
|
+
return compareVersions(candidate, current) > 0;
|
|
38
|
+
}
|