@mailo037/veo 1.0.0 → 1.0.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/README.md +21 -1
- package/package.json +2 -1
- package/src/cli.js +28 -1
- package/src/downloader.js +3 -1
- package/src/open-file.js +14 -0
- package/src/progress.js +21 -2
- package/src/terminal-title.js +19 -0
- package/src/updater.js +247 -0
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ veo <url> [options]
|
|
|
40
40
|
|
|
41
41
|
-q, --quality <quality> best, 2160p, 1440p, 1080p, 720p, 480p, 360p
|
|
42
42
|
-o, --output <path> Output directory (default: current working directory)
|
|
43
|
+
-r, --rename <name> Custom filename without extension (also used in tab title)
|
|
44
|
+
--open Open the saved file with your default app
|
|
43
45
|
--audio Audio only (MP3 by default)
|
|
44
46
|
--format <format> Video: mp4, mkv, webm, mov
|
|
45
47
|
Audio: mp3, m4a, aac, opus, flac, wav
|
|
@@ -53,6 +55,8 @@ veo "https://x.com/USER/status/STATUS_ID" -q best
|
|
|
53
55
|
veo "https://example.com/video.mp4" --audio
|
|
54
56
|
veo "https://example.com/video.mp4" --audio --format flac
|
|
55
57
|
veo "https://example.com/video.mp4" --format webm -o ./videos
|
|
58
|
+
veo "https://example.com/video.mp4" -r "Mein Video" --format mp4 --open
|
|
59
|
+
veo "https://example.com/video.mp4" --audio -r "Meine Musik"
|
|
56
60
|
npx @mailo037/veo "https://example.com/video.mp4" --output ./downloads
|
|
57
61
|
```
|
|
58
62
|
|
|
@@ -60,11 +64,26 @@ npx @mailo037/veo "https://example.com/video.mp4" --output ./downloads
|
|
|
60
64
|
- Numeric quality selects the nearest **available video height**, not just a maximum. Ties choose the lower height. Unknown heights fall back to best. `--quality` does not apply to audio.
|
|
61
65
|
- MP4-compatible codecs are preferred at the selected resolution; merged video prefers MP4 with MKV fallback. A single-file source may retain its original container. Use `--format mp4` to explicitly require MP4 (conversion may be slow or lossy).
|
|
62
66
|
- `--format` on video enables conversion when necessary. Audio formats require `--audio`.
|
|
63
|
-
- Original titles are preserved
|
|
67
|
+
- Original titles are preserved by default. Use `-r "My Video"` or `--rename "My Video"` to choose a filename for video or audio. Supply the name **without an extension**; the actual media extension is appended automatically. Use `-o` for the directory. Invalid filename characters are sanitized and overly long names shortened. Existing files are never intentionally overwritten: duplicates get ` (1)`, ` (2)`, etc.
|
|
68
|
+
- During a download, the terminal/tab title shows `veo | 50% | My Video` (original title or your `-r` name), plus setup/processing status. It ends with `Done`, `Failed`, or `Cancelled`; the shell may replace it at the next prompt. This uses the native console title on Windows (including PowerShell/Windows Terminal) and OSC title sequences on compatible Linux/macOS terminals. Titles are not changed when stderr is redirected or `TERM=dumb`. Terminal settings that enforce a fixed tab title can override this feature.
|
|
64
69
|
- Downloads and conversion use a private temporary directory inside the output directory. The completed file is copied to a collision-safe final name, so allow extra disk space. Temporary files are removed on ordinary failure or Ctrl+C; force-killing the process may leave `.veo-*` directories to remove manually. Partial downloads are not resumed between invocations.
|
|
65
70
|
- Progress shows percentage, speed, downloaded/total size and ETA on stderr; unknown values appear as `?`. Separate audio/video streams each have their own progress. Non-interactive output is throttled. The final path is printed on stdout as `Saved: ...`.
|
|
71
|
+
- `--open` launches the completed file in your default app, including renamed files and audio. Uses `explorer.exe` on Windows, `open` on macOS, and `xdg-open` on Linux (requires a graphical desktop, xdg-utils and a file association). The CLI does not wait for the player to close. If the opener cannot be launched, a warning is printed and the successful download still exits with `0`; later errors inside the detached opener/player are not monitored.
|
|
72
|
+
- After each successful download, veo checks the npm registry at most once per day for a newer version and prints a one-line notice on stderr (never on failure). Disable it with `VEO_NO_UPDATE_CHECK=1`; `VEO_REGISTRY`/`npm_config_registry` are respected.
|
|
66
73
|
- Exit status is `0` on success, `1` on errors, and `130` on cancellation.
|
|
67
74
|
|
|
75
|
+
## Updating veo
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
veo update # install the latest version with npm (global)
|
|
79
|
+
veo update --check # only check for a newer version
|
|
80
|
+
veo upgrade # alias for veo update
|
|
81
|
+
veo check update # alias for veo update --check
|
|
82
|
+
veo update --help # update usage
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`veo update` runs `npm install -g @mailo037/veo@latest` and then removes yt-dlp backend caches from older pinned releases. It never uses a shell on Linux/macOS, passes fixed arguments only, and prints the manual npm command on any failure. The registry can be overridden with `VEO_REGISTRY` (or npm's `npm_config_registry`) for mirrors and proxies.
|
|
86
|
+
|
|
68
87
|
## Supported sites and backend
|
|
69
88
|
|
|
70
89
|
YouTube, X/Twitter, TikTok, Vimeo, Reddit, Instagram, and [many other yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) are supported **when publicly accessible and technically available**. Support changes with websites, regions, rate limits, and backend versions; it is not a guarantee that every URL will work. Authentication/cookie flags are not exposed in this initial version.
|
|
@@ -85,6 +104,7 @@ Automatic yt-dlp acquisition covers mainstream Windows, macOS and Linux architec
|
|
|
85
104
|
```bash
|
|
86
105
|
npm install
|
|
87
106
|
npm test
|
|
107
|
+
npm run test:open
|
|
88
108
|
npm run test:smoke
|
|
89
109
|
npm pack --dry-run
|
|
90
110
|
npm link
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailo037/veo",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "A clean, simple video downloader powered by yt-dlp.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "node --test",
|
|
20
20
|
"test:smoke": "node scripts/smoke.js",
|
|
21
|
+
"test:open": "node scripts/smoke-open.js",
|
|
21
22
|
"prepublishOnly": "npm test"
|
|
22
23
|
},
|
|
23
24
|
"keywords": [
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { createReporter } from './progress.js';
|
|
4
|
+
import { openFile } from './open-file.js';
|
|
5
|
+
import { maybeUpdateNotice, updateMain, UPDATE_HELP, defaultRegistry, packageVersion } from './updater.js';
|
|
4
6
|
import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText } from './utils.js';
|
|
5
7
|
|
|
6
8
|
export const HELP = `veo - simple video downloader
|
|
@@ -11,6 +13,8 @@ Usage:
|
|
|
11
13
|
Options:
|
|
12
14
|
-q, --quality <quality> Video quality (best, 2160p, 1440p, 1080p, 720p, 480p, 360p)
|
|
13
15
|
-o, --output <path> Output directory (default: current directory)
|
|
16
|
+
-r, --rename <name> Custom filename without extension (also used in tab title)
|
|
17
|
+
--open Open the saved file with your default app
|
|
14
18
|
--audio Download audio only (default: mp3)
|
|
15
19
|
--format <format> Video: mp4, mkv, webm, mov; audio: mp3, m4a, aac, opus, flac, wav
|
|
16
20
|
--version Show version
|
|
@@ -21,6 +25,8 @@ Examples:
|
|
|
21
25
|
veo <url> -q 1080p
|
|
22
26
|
veo <url> --audio
|
|
23
27
|
veo <url> -o ./downloads
|
|
28
|
+
veo <url> -r "My Video" --open
|
|
29
|
+
veo update --check
|
|
24
30
|
|
|
25
31
|
Only download content you are authorized or legally permitted to download.
|
|
26
32
|
`;
|
|
@@ -29,6 +35,8 @@ export function parseCli(args) {
|
|
|
29
35
|
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
30
36
|
quality: { type: 'string', short: 'q', default: 'best' },
|
|
31
37
|
output: { type: 'string', short: 'o', default: process.cwd() },
|
|
38
|
+
rename: { type: 'string', short: 'r' },
|
|
39
|
+
open: { type: 'boolean' },
|
|
32
40
|
audio: { type: 'boolean', default: false },
|
|
33
41
|
format: { type: 'string' },
|
|
34
42
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -38,6 +46,7 @@ export function parseCli(args) {
|
|
|
38
46
|
if (positionals.length !== 1) throw new Error('Provide exactly one video URL. Run veo --help for usage.');
|
|
39
47
|
if (!QUALITIES.includes(values.quality)) throw new Error(`Invalid quality. Choose: ${QUALITIES.join(', ')}.`);
|
|
40
48
|
if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
|
|
49
|
+
if (values.rename !== undefined && !cleanText(values.rename)) throw new Error('The custom filename cannot be empty.');
|
|
41
50
|
if (values.audio && values.quality !== 'best') throw new Error('--quality is for video; omit it when using --audio.');
|
|
42
51
|
const formats = values.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
43
52
|
if (values.format && !formats.includes(values.format)) throw new Error(`Invalid ${values.audio ? 'audio' : 'video'} format. Choose: ${formats.join(', ')}.${!values.audio && AUDIO_FORMATS.includes(values.format) ? ' Use --audio for audio formats.' : ''}`);
|
|
@@ -45,6 +54,14 @@ export function parseCli(args) {
|
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
export async function main(args = process.argv.slice(2)) {
|
|
57
|
+
// update/upgrade/check subcommands are handled before URL validation.
|
|
58
|
+
if (['update', 'upgrade', 'check'].includes(args[0])) {
|
|
59
|
+
if (args[0] === 'check' && args[1] !== 'update') {
|
|
60
|
+
process.stdout.write(UPDATE_HELP);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
return updateMain(args, { registry: defaultRegistry() });
|
|
64
|
+
}
|
|
48
65
|
const reporter = createReporter();
|
|
49
66
|
const controller = new AbortController();
|
|
50
67
|
const cancel = () => controller.abort();
|
|
@@ -58,12 +75,22 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
58
75
|
process.stdout.write(`${pkg.version}\n`);
|
|
59
76
|
return 0;
|
|
60
77
|
}
|
|
78
|
+
reporter.start(options.rename);
|
|
61
79
|
const { download } = await import('./downloader.js');
|
|
62
80
|
const saved = await download(options, { signal: controller.signal, reporter });
|
|
81
|
+
reporter.complete();
|
|
63
82
|
process.stdout.write(`Saved: ${cleanText(saved)}\n`);
|
|
83
|
+
if (options.open) {
|
|
84
|
+
try { await openFile(saved); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
process.stderr.write(`veo: File saved, but could not launch the default app: ${readableError(error)}\n`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const notice = await maybeUpdateNotice({ currentVersion: await packageVersion() });
|
|
90
|
+
if (notice) process.stderr.write(`${notice}\n`);
|
|
64
91
|
return 0;
|
|
65
92
|
} catch (error) {
|
|
66
|
-
reporter.
|
|
93
|
+
reporter.fail(controller.signal.aborted);
|
|
67
94
|
process.stderr.write(`veo: ${readableError(error)}\n`);
|
|
68
95
|
return controller.signal.aborted ? 130 : 1;
|
|
69
96
|
} finally {
|
package/src/downloader.js
CHANGED
|
@@ -49,6 +49,8 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
49
49
|
if (metadata._type === 'playlist' || metadata.entries) throw new Error('This URL is a collection. Please provide a single video URL.');
|
|
50
50
|
if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
|
|
51
51
|
if (metadata.has_drm) throw new Error('This content is DRM-protected.');
|
|
52
|
+
const title = options.rename ?? (metadata.title || metadata.id || 'video');
|
|
53
|
+
reporter?.name?.(title);
|
|
52
54
|
const height = options.audio ? null : closestHeight(metadata.formats || [metadata], options.quality);
|
|
53
55
|
if (!options.audio && options.quality !== 'best') {
|
|
54
56
|
reporter?.status(height ? `Quality: ${height}p${height !== parseInt(options.quality, 10) ? ` (closest to ${options.quality})` : ''}` : 'Resolution unknown; using the best available stream.');
|
|
@@ -79,7 +81,7 @@ export async function download(options, { signal, reporter, backendResolver = re
|
|
|
79
81
|
saved = path.resolve(saved);
|
|
80
82
|
if (path.dirname(saved) !== temporary || !(await stat(saved)).isFile()) throw new Error('The backend returned an invalid saved file path.');
|
|
81
83
|
signal?.throwIfAborted();
|
|
82
|
-
return await saveUnique(saved, directory,
|
|
84
|
+
return await saveUnique(saved, directory, title);
|
|
83
85
|
} finally {
|
|
84
86
|
reporter?.finish();
|
|
85
87
|
await rm(temporary, { recursive: true, force: true });
|
package/src/open-file.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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' : 'xdg-open';
|
|
8
|
+
const absolute = path.resolve(filename);
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawnProcess(command, [absolute], { shell: false, detached: true, stdio: 'ignore', windowsHide: true });
|
|
11
|
+
child.once('error', reject);
|
|
12
|
+
child.once('spawn', () => { child.unref(); resolve(); });
|
|
13
|
+
});
|
|
14
|
+
}
|
package/src/progress.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cleanText } from './utils.js';
|
|
2
|
+
import { createTerminalTitle } from './terminal-title.js';
|
|
2
3
|
|
|
3
4
|
function bytes(value) {
|
|
4
5
|
if (!Number.isFinite(value) || value < 0) return '?';
|
|
@@ -17,16 +18,34 @@ export function formatProgress(data) {
|
|
|
17
18
|
return `[${'='.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent === null ? ' ?' : percent.toFixed(0).padStart(3)}% ${bytes(data.speed)}/s ${bytes(done)} / ${bytes(total)} ETA ${eta}`;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export function createReporter(stream = process.stderr) {
|
|
21
|
+
export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream) } = {}) {
|
|
21
22
|
let active = false;
|
|
22
23
|
let lastLog = 0;
|
|
24
|
+
let name = '';
|
|
25
|
+
let phase = 'Starting…';
|
|
26
|
+
let started = false;
|
|
27
|
+
const updateTitle = () => setTitle(`veo | ${phase}${name ? ` | ${name}` : ''}`);
|
|
23
28
|
function clear() {
|
|
24
29
|
if (active && stream.isTTY) stream.write('\r\x1b[2K');
|
|
25
30
|
active = false;
|
|
26
31
|
}
|
|
27
32
|
return {
|
|
28
|
-
|
|
33
|
+
start(title = '') { started = true; name = cleanText(title); phase = 'Starting…'; updateTitle(); },
|
|
34
|
+
name(title) { name = cleanText(title); if (started) updateTitle(); },
|
|
35
|
+
status(message) {
|
|
36
|
+
clear();
|
|
37
|
+
phase = cleanText(message);
|
|
38
|
+
if (started) updateTitle();
|
|
39
|
+
stream.write(`${phase}\n`);
|
|
40
|
+
},
|
|
41
|
+
complete() { clear(); phase = 'Done'; if (started) updateTitle(); },
|
|
42
|
+
fail(cancelled = false) { clear(); phase = cancelled ? 'Cancelled' : 'Failed'; if (started) updateTitle(); },
|
|
29
43
|
progress(data) {
|
|
44
|
+
const total = data.total_bytes || data.total_bytes_estimate;
|
|
45
|
+
const percent = Number.isFinite(total) && total > 0 && Number.isFinite(data.downloaded_bytes)
|
|
46
|
+
? Math.max(0, Math.min(100, Math.round(data.downloaded_bytes / total * 100))) : null;
|
|
47
|
+
phase = data.status === 'finished' ? 'Processing…' : percent === null ? 'Downloading…' : `${percent}%`;
|
|
48
|
+
if (started) updateTitle();
|
|
30
49
|
if (stream.isTTY) {
|
|
31
50
|
stream.write(`\r\x1b[2K${formatProgress(data)}`);
|
|
32
51
|
active = true;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cleanText } from './utils.js';
|
|
2
|
+
|
|
3
|
+
// PowerShell's console title API on Windows; OSC 0 on xterm-compatible Unix
|
|
4
|
+
// terminals. Never put escape sequences into redirected output or dumb terminals.
|
|
5
|
+
export function createTerminalTitle(stream, { platform = process.platform, env = process.env, processInfo = process } = {}) {
|
|
6
|
+
let previous;
|
|
7
|
+
return title => {
|
|
8
|
+
if (!stream.isTTY || env.TERM === 'dumb') return;
|
|
9
|
+
const safe = cleanText(title).slice(0, 240);
|
|
10
|
+
if (safe === previous) return;
|
|
11
|
+
try {
|
|
12
|
+
if (platform === 'win32') processInfo.title = safe;
|
|
13
|
+
else stream.write(`\x1b]0;${safe}\x07`);
|
|
14
|
+
previous = safe;
|
|
15
|
+
} catch {
|
|
16
|
+
// Cosmetic only: a terminal that rejects titles must not break downloads.
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
package/src/updater.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { RELEASE } from './backend.js';
|
|
6
|
+
import { readableError } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const PACKAGE = '@mailo037/veo';
|
|
9
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export const UPDATE_HELP = `veo update - keep veo current
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
veo update Install the latest version with npm
|
|
15
|
+
veo update --check Only check whether a newer version exists
|
|
16
|
+
veo upgrade Alias for veo update
|
|
17
|
+
veo check update Alias for veo update --check
|
|
18
|
+
|
|
19
|
+
The registry can be overridden with VEO_REGISTRY (or npm_config_registry).
|
|
20
|
+
Set VEO_NO_UPDATE_CHECK=1 to disable the automatic post-download check.
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
export function defaultRegistry(env = process.env) {
|
|
24
|
+
const registry = env.VEO_REGISTRY || env.npm_config_registry;
|
|
25
|
+
if (typeof registry === 'string' && /^https?:\/\//.test(registry.trim())) return registry.trim().replace(/\/+$/, '');
|
|
26
|
+
return 'https://registry.npmjs.org';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function compareVersions(a, b) {
|
|
30
|
+
const parse = value => {
|
|
31
|
+
const [core, pre = ''] = String(value).trim().replace(/^v/, '').split('-');
|
|
32
|
+
if (!/^\d+(\.\d+)*$/.test(core)) throw new Error(`Invalid version: ${value}`);
|
|
33
|
+
// Semver prerelease identifiers compare per segment, numerically when numeric.
|
|
34
|
+
return { parts: core.split('.').map(Number), pre: pre === '' ? [] : pre.split('.') };
|
|
35
|
+
};
|
|
36
|
+
const left = parse(a);
|
|
37
|
+
const right = parse(b);
|
|
38
|
+
for (let index = 0; index < Math.max(left.parts.length, right.parts.length); index++) {
|
|
39
|
+
const difference = (left.parts[index] || 0) - (right.parts[index] || 0);
|
|
40
|
+
if (difference) return Math.sign(difference);
|
|
41
|
+
}
|
|
42
|
+
// A release outranks any prerelease of the same core version.
|
|
43
|
+
if (!left.pre.length && !right.pre.length) return 0;
|
|
44
|
+
if (!left.pre.length) return 1;
|
|
45
|
+
if (!right.pre.length) return -1;
|
|
46
|
+
for (let index = 0; index < Math.max(left.pre.length, right.pre.length); index++) {
|
|
47
|
+
const l = left.pre[index];
|
|
48
|
+
const r = right.pre[index];
|
|
49
|
+
if (l === undefined) return -1;
|
|
50
|
+
if (r === undefined) return 1;
|
|
51
|
+
const lNumeric = /^\d+$/.test(l);
|
|
52
|
+
const rNumeric = /^\d+$/.test(r);
|
|
53
|
+
if (lNumeric && rNumeric) {
|
|
54
|
+
const difference = Number(l) - Number(r);
|
|
55
|
+
if (difference) return Math.sign(difference);
|
|
56
|
+
} else if (lNumeric) return -1; // Numeric identifiers rank below alphanumerics.
|
|
57
|
+
else if (rNumeric) return 1;
|
|
58
|
+
else if (l !== r) return l < r ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function fetchLatestVersion({ registry = defaultRegistry(), fetchImpl = fetch, timeoutMs = 8000, signal } = {}) {
|
|
64
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
65
|
+
const url = `${registry}/${encodeURIComponent(PACKAGE)}`;
|
|
66
|
+
const response = await fetchImpl(url, {
|
|
67
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
68
|
+
headers: { accept: 'application/json' },
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
await response.body?.cancel();
|
|
72
|
+
throw new Error(`Update check failed: HTTP ${response.status}.`);
|
|
73
|
+
}
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
// Full packument: pick the highest version instead of trusting the "latest" tag.
|
|
76
|
+
const versions = data?.versions ? Object.keys(data.versions) : [data?.version].filter(Boolean);
|
|
77
|
+
if (!versions.length) throw new Error('Update check returned no versions.');
|
|
78
|
+
const [latest] = versions.sort((a, b) => compareVersions(b, a));
|
|
79
|
+
const version = typeof latest === 'string' ? latest : '';
|
|
80
|
+
if (!/^\d+\.\d+\.\d+/.test(version)) throw new Error('Update check returned an invalid version.');
|
|
81
|
+
return version;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Same OS cache base as the yt-dlp backend cache (see src/backend.js).
|
|
85
|
+
export function veoCacheBase({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
86
|
+
let base;
|
|
87
|
+
if (platform === 'win32') base = env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
88
|
+
else if (platform === 'darwin') base = path.join(home, 'Library', 'Caches');
|
|
89
|
+
else base = env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
90
|
+
if (!path.isAbsolute(base)) base = path.join(home, '.cache');
|
|
91
|
+
return path.join(base, 'veo');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function readState(stateFile) {
|
|
95
|
+
try {
|
|
96
|
+
const state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
97
|
+
return Number.isFinite(state?.lastCheck) ? state : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeState(stateFile, state) {
|
|
104
|
+
await mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
105
|
+
await writeFile(stateFile, JSON.stringify(state), { mode: 0o600 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Throttled (once per day) post-download check. Purely informational; every
|
|
109
|
+
// failure is silent so downloads are never delayed or marked as failed.
|
|
110
|
+
export async function maybeUpdateNotice({
|
|
111
|
+
currentVersion,
|
|
112
|
+
env = process.env,
|
|
113
|
+
stateFile,
|
|
114
|
+
fetchImpl = fetch,
|
|
115
|
+
registry,
|
|
116
|
+
now = Date.now(),
|
|
117
|
+
ttlMs = DAY_MS,
|
|
118
|
+
timeoutMs = 4000,
|
|
119
|
+
} = {}) {
|
|
120
|
+
if (env.VEO_NO_UPDATE_CHECK) return null;
|
|
121
|
+
try {
|
|
122
|
+
if (!stateFile) stateFile = path.join(veoCacheBase({ env }), 'update-check.json');
|
|
123
|
+
const state = await readState(stateFile);
|
|
124
|
+
if (state && now - state.lastCheck < ttlMs) return null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null; // No usable cache location: never turn a notice into noise.
|
|
127
|
+
}
|
|
128
|
+
let latest = null;
|
|
129
|
+
let message = null;
|
|
130
|
+
try {
|
|
131
|
+
latest = await fetchLatestVersion({ fetchImpl, registry, timeoutMs });
|
|
132
|
+
if (compareVersions(latest, currentVersion) > 0) {
|
|
133
|
+
message = `Update available: veo ${latest} (you have ${currentVersion}). Run: veo update`;
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
latest = null; // Unreachable registry still consumes the throttle interval.
|
|
137
|
+
}
|
|
138
|
+
await writeState(stateFile, { lastCheck: now, latest }).catch(() => {});
|
|
139
|
+
return message;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Removes yt-dlp caches from older pinned releases; only the current one stays.
|
|
143
|
+
export async function pruneBackendCaches({ keep, root, platform = process.platform, env = process.env } = {}) {
|
|
144
|
+
const base = root || path.join(veoCacheBase({ platform, env }), 'backends');
|
|
145
|
+
let entries;
|
|
146
|
+
try {
|
|
147
|
+
entries = await readdir(base, { withFileTypes: true });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === 'ENOENT') return 0;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
let removed = 0;
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (!entry.isDirectory() || entry.name === keep) continue;
|
|
155
|
+
await rm(path.join(base, entry.name), { recursive: true, force: true });
|
|
156
|
+
removed++;
|
|
157
|
+
}
|
|
158
|
+
return removed;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function npmSpawnCommand({ platform = process.platform } = {}) {
|
|
162
|
+
// Fixed arguments only (no user input), so cmd.exe on Windows is safe here.
|
|
163
|
+
return {
|
|
164
|
+
command: 'npm',
|
|
165
|
+
args: ['install', '-g', '--no-fund', '--no-audit', `${PACKAGE}@latest`],
|
|
166
|
+
shell: platform === 'win32',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function runNpmUpdate({ spawnImpl = spawn, platform = process.platform, signal, timeoutMs = 600_000 } = {}) {
|
|
171
|
+
const { command, args, shell } = npmSpawnCommand({ platform });
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
const child = spawnImpl(command, args, { shell, stdio: 'inherit', windowsHide: true, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
|
|
174
|
+
child.once('error', reject);
|
|
175
|
+
child.once('close', resolve);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function packageVersion() {
|
|
180
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
181
|
+
return pkg.version;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Implements `veo update [--check]`, `veo upgrade`, and `veo check update`.
|
|
186
|
+
* Returns the process exit code. Deps are injectable for tests.
|
|
187
|
+
*/
|
|
188
|
+
export async function updateMain(args, {
|
|
189
|
+
registry = defaultRegistry(),
|
|
190
|
+
fetchImpl = fetch,
|
|
191
|
+
spawnImpl = spawn,
|
|
192
|
+
platform = process.platform,
|
|
193
|
+
stdout = process.stdout,
|
|
194
|
+
stderr = process.stderr,
|
|
195
|
+
current,
|
|
196
|
+
keep = RELEASE,
|
|
197
|
+
pruneRoot = undefined,
|
|
198
|
+
} = {}) {
|
|
199
|
+
current = current ?? await packageVersion();
|
|
200
|
+
const isCheckCommand = args[0] === 'check';
|
|
201
|
+
const rest = isCheckCommand ? args.slice(2) : args.slice(1);
|
|
202
|
+
if (rest.includes('-h') || rest.includes('--help')) {
|
|
203
|
+
stdout.write(UPDATE_HELP);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const checkOnly = isCheckCommand || rest.includes('--check') || (rest.length === 1 && rest[0] === 'check');
|
|
207
|
+
const unknown = rest.filter(token => token !== '--check' && !(checkOnly && token === 'check'));
|
|
208
|
+
if (unknown.length) throw new Error(`Unknown option for veo update: ${unknown.join(' ')}. Use: veo update [--check]`);
|
|
209
|
+
const currentText = `veo ${current}`;
|
|
210
|
+
let latest;
|
|
211
|
+
try {
|
|
212
|
+
latest = await fetchLatestVersion({ registry, fetchImpl, timeoutMs: 10_000 });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
stderr.write(`veo: ${readableError(error)}\nUpdate manually with: npm install -g ${PACKAGE}@latest\n`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
const newer = compareVersions(latest, current) > 0;
|
|
218
|
+
if (checkOnly) {
|
|
219
|
+
stdout.write(newer ? `Update available: veo ${latest} (you have ${current}). Run: veo update\n` : `${currentText} is up to date.\n`);
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
if (newer) {
|
|
223
|
+
stdout.write(`Updating ${currentText} → ${latest} with npm…\n`);
|
|
224
|
+
let code;
|
|
225
|
+
try {
|
|
226
|
+
code = await runNpmUpdate({ spawnImpl, platform, signal: AbortSignal.timeout(600_000) });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const reason = error.code === 'ENOENT' ? 'npm was not found. Install Node.js/npm, or update manually.' : `Could not run npm: ${readableError(error)}`;
|
|
229
|
+
stderr.write(`veo: ${reason}\nManual command: npm install -g ${PACKAGE}@latest\n`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
if (code !== 0) {
|
|
233
|
+
stderr.write(`veo: npm exited with code ${code}. Update manually with: npm install -g ${PACKAGE}@latest\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
stdout.write(`veo updated to ${latest}. The next veo call uses the new version.\n`);
|
|
237
|
+
} else {
|
|
238
|
+
stdout.write(`${currentText} is up to date.\n`);
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const removed = await pruneBackendCaches({ keep, root: pruneRoot });
|
|
242
|
+
if (removed) stdout.write(`Removed ${removed} old backend cache${removed === 1 ? '' : 's'}.\n`);
|
|
243
|
+
} catch {
|
|
244
|
+
// Cosmetic housekeeping must never fail the update.
|
|
245
|
+
}
|
|
246
|
+
return 0;
|
|
247
|
+
}
|