@mailo037/veo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/bin/veo.js +4 -0
- package/package.json +39 -0
- package/src/backend.js +223 -0
- package/src/cli.js +73 -0
- package/src/downloader.js +87 -0
- package/src/progress.js +40 -0
- package/src/utils.js +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 veo contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# veo
|
|
2
|
+
|
|
3
|
+
A small, clean video-downloading CLI powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp). No banners, just progress and the saved file path.
|
|
4
|
+
|
|
5
|
+
**Only download content you own or are authorized or legally permitted to download.** Respect copyright, website terms, and access restrictions. veo does not bypass DRM, private-content access controls, or paywalls.
|
|
6
|
+
|
|
7
|
+
## Install and run
|
|
8
|
+
|
|
9
|
+
Requires **Node.js 22+** and npm. Internet access is needed for installation and first-use backend setup.
|
|
10
|
+
|
|
11
|
+
The npm package is `@mailo037/veo`; the installed command is `veo`.
|
|
12
|
+
Once this package has been published:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @mailo037/veo "https://example.com/video.mp4"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or install globally:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g @mailo037/veo
|
|
22
|
+
veo "https://example.com/video.mp4"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
From this checkout, without publishing:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
node bin/veo.js --help
|
|
30
|
+
npm link
|
|
31
|
+
veo "https://example.com/video.mp4"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`package.json` maps `"veo": "./bin/veo.js"`; npm creates the executable/shim automatically. No custom command prefix is needed. Use only trusted URLs, and quote URLs to protect query strings from your shell.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
veo <url> [options]
|
|
40
|
+
|
|
41
|
+
-q, --quality <quality> best, 2160p, 1440p, 1080p, 720p, 480p, 360p
|
|
42
|
+
-o, --output <path> Output directory (default: current working directory)
|
|
43
|
+
--audio Audio only (MP3 by default)
|
|
44
|
+
--format <format> Video: mp4, mkv, webm, mov
|
|
45
|
+
Audio: mp3, m4a, aac, opus, flac, wav
|
|
46
|
+
--version Show version
|
|
47
|
+
-h, --help Show help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
veo "https://youtube.com/watch?v=VIDEO_ID" -q 1080p
|
|
52
|
+
veo "https://x.com/USER/status/STATUS_ID" -q best
|
|
53
|
+
veo "https://example.com/video.mp4" --audio
|
|
54
|
+
veo "https://example.com/video.mp4" --audio --format flac
|
|
55
|
+
veo "https://example.com/video.mp4" --format webm -o ./videos
|
|
56
|
+
npx @mailo037/veo "https://example.com/video.mp4" --output ./downloads
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- One video per invocation; playlists and live streams are not supported.
|
|
60
|
+
- 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
|
+
- 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
|
+
- `--format` on video enables conversion when necessary. Audio formats require `--audio`.
|
|
63
|
+
- Original titles are preserved, with invalid filename characters sanitized and overly long names shortened. Existing files are never intentionally overwritten: duplicates get ` (1)`, ` (2)`, etc.
|
|
64
|
+
- 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
|
+
- 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: ...`.
|
|
66
|
+
- Exit status is `0` on success, `1` on errors, and `130` on cancellation.
|
|
67
|
+
|
|
68
|
+
## Supported sites and backend
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
Official standalone yt-dlp is acquired on first download and cached outside the package directory; help/version never download it. The release is pinned and SHA-256 verified against hashes shipped with this package. FFmpeg and FFprobe are supplied by the `ffmpeg-static` and `ffprobe-static` dependencies during npm installation. These binaries have their own licenses; see their upstream packages. Normal installs must allow dependency install scripts. No Python installation is needed on supported standalone platforms.
|
|
73
|
+
|
|
74
|
+
The existing Node executable is explicitly enabled as yt-dlp's JavaScript runtime for YouTube. Local yt-dlp configuration and plugins are disabled for predictable execution. Arguments are passed without a shell.
|
|
75
|
+
|
|
76
|
+
Advanced overrides for managed installations:
|
|
77
|
+
|
|
78
|
+
- `VEO_YT_DLP_PATH`: absolute path to a trusted, current yt-dlp executable.
|
|
79
|
+
- `VEO_FFMPEG_PATH`: directory containing both `ffmpeg` and `ffprobe` executables (with `.exe` on Windows).
|
|
80
|
+
|
|
81
|
+
Automatic yt-dlp acquisition covers mainstream Windows, macOS and Linux architectures; FFmpeg/FFprobe platform availability may be narrower. Unsupported systems should supply trusted native binaries. A blocked GitHub request, proxy, disabled install scripts, or unsupported native binary can prevent setup; errors include guidance. Maintainers should update the pinned yt-dlp release and hashes as websites change, or users can supply an up-to-date executable override.
|
|
82
|
+
|
|
83
|
+
## Develop and publish
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm install
|
|
87
|
+
npm test
|
|
88
|
+
npm run test:smoke
|
|
89
|
+
npm pack --dry-run
|
|
90
|
+
npm link
|
|
91
|
+
veo --version
|
|
92
|
+
# Then, authenticated as the account that owns the name:
|
|
93
|
+
npm publish
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The smoke test generates a two-second synthetic video with FFmpeg, serves it on loopback, and runs real yt-dlp downloads. It checks default output, duplicate names, quality fallback, audio extraction, conversion and nonzero failures. It needs network access once for yt-dlp acquisition; it downloads no third-party video. Unit tests require no network.
|
|
97
|
+
|
|
98
|
+
The published tarball only includes `bin/`, `src/`, package metadata, README and LICENSE. The lockfile is kept for reproducible development. There is no build step. npm makes the shebang-bearing bin executable on installation (on Unix, `chmod +x bin/veo.js` also enables direct checkout execution).
|
|
99
|
+
|
|
100
|
+
Modules separate argument parsing (`src/cli.js`), backend setup (`src/backend.js`), download orchestration (`src/downloader.js`), progress (`src/progress.js`), and filename/error helpers (`src/utils.js`) so new options and providers can be added without replacing the CLI.
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT for this CLI. yt-dlp and FFmpeg/FFprobe retain their respective upstream licenses.
|
package/bin/veo.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mailo037/veo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A clean, simple video downloader powered by yt-dlp.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"veo": "bin/veo.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test",
|
|
20
|
+
"test:smoke": "node scripts/smoke.js",
|
|
21
|
+
"prepublishOnly": "npm test"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"cli",
|
|
25
|
+
"video",
|
|
26
|
+
"downloader",
|
|
27
|
+
"yt-dlp",
|
|
28
|
+
"audio"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"preferGlobal": true,
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"ffmpeg-static": "5.3.0",
|
|
37
|
+
"ffprobe-static": "3.1.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/backend.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream, createWriteStream, constants } from 'node:fs';
|
|
3
|
+
import { access, chmod, copyFile, lstat, mkdir, rename, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Readable, Transform } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
export const RELEASE = '2026.08.19';
|
|
12
|
+
const RELEASE_URL = `https://github.com/yt-dlp/yt-dlp/releases/download/${RELEASE}`;
|
|
13
|
+
const MAX_BYTES = 256 * 1024 * 1024;
|
|
14
|
+
const DOWNLOAD_TIMEOUT = 180_000;
|
|
15
|
+
// Official hashes fetched at development time; never trust a runtime checksum download.
|
|
16
|
+
// https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/SHA2-256SUMS
|
|
17
|
+
const HASHES = Object.freeze({
|
|
18
|
+
'yt-dlp.exe': '66674953fe251b89f4d08c5f0e35e0728679bd67ab3d7d05c0562af101dd3e7a',
|
|
19
|
+
'yt-dlp_arm64.exe': '05b438997bafc3affdfda9d041353c9d73e04dc842207254b655b0887c4445b0',
|
|
20
|
+
'yt-dlp_x86.exe': 'a8f91bd41452506bc81ebd2f369b186fea0ee7075413ba00cef9fd346a0a5d0c',
|
|
21
|
+
'yt-dlp_macos': '0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202',
|
|
22
|
+
'yt-dlp_linux': '58162f9bfdc27458ea47bfcb311cf47028f17d8154a8bf7d689861d46399230a',
|
|
23
|
+
'yt-dlp_linux_aarch64': 'b16e4dab368a816cd05d477d698a605a6ae87ccee1c8ffd38fa21d7254141fcc',
|
|
24
|
+
'yt-dlp_musllinux': 'f3dec9cfeaf304cec98290fe41c6ad465d4b747d302473559643e7af24929722',
|
|
25
|
+
'yt-dlp_musllinux_aarch64': '17b164c4d258be92bb1ad146cb7c336b783aedb380814aabbcb7d52937f77e57',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export function selectAsset(platform = process.platform, arch = process.arch, musl = false) {
|
|
29
|
+
if (platform === 'win32') return { x64: 'yt-dlp.exe', arm64: 'yt-dlp_arm64.exe', ia32: 'yt-dlp_x86.exe' }[arch];
|
|
30
|
+
if (platform === 'darwin' && ['x64', 'arm64'].includes(arch)) return 'yt-dlp_macos';
|
|
31
|
+
if (platform === 'linux' && ['x64', 'arm64'].includes(arch)) return `yt-dlp_${musl ? 'musl' : ''}linux${arch === 'arm64' ? '_aarch64' : ''}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function cacheDirectory(asset) {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
let base;
|
|
37
|
+
if (process.platform === 'win32') base = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
38
|
+
else if (process.platform === 'darwin') base = path.join(home, 'Library', 'Caches');
|
|
39
|
+
else base = process.env.XDG_CACHE_HOME || path.join(home, '.cache');
|
|
40
|
+
// Invalid relative OS cache settings must not write into the working directory.
|
|
41
|
+
if (!path.isAbsolute(base)) base = path.join(home, '.cache');
|
|
42
|
+
return path.join(base, 'veo', 'backends', RELEASE, `${process.platform}-${process.arch}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function envPath(name) {
|
|
46
|
+
if (!(name in process.env)) return undefined;
|
|
47
|
+
const value = process.env[name];
|
|
48
|
+
if (!value?.trim() || value.includes('\0')) throw new Error(`${name} must be a non-empty filesystem path.`);
|
|
49
|
+
return path.resolve(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function executable(file, label) {
|
|
53
|
+
if (typeof file !== 'string' || !file) throw new Error(`${label} is unavailable on ${process.platform}/${process.arch}.`);
|
|
54
|
+
try {
|
|
55
|
+
if (!(await stat(file)).isFile()) throw new Error('not a regular file');
|
|
56
|
+
await access(file, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
|
|
57
|
+
} catch (cause) {
|
|
58
|
+
throw new Error(`${label} is missing or not executable: ${file}`, { cause });
|
|
59
|
+
}
|
|
60
|
+
return path.resolve(file);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function sha256(file, signal) {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
const hash = createHash('sha256');
|
|
66
|
+
let bytes = 0;
|
|
67
|
+
for await (const chunk of createReadStream(file, { signal })) {
|
|
68
|
+
bytes += chunk.length;
|
|
69
|
+
if (bytes > MAX_BYTES) throw new Error(`Backend file exceeds ${MAX_BYTES / 1024 / 1024} MiB: ${file}`);
|
|
70
|
+
hash.update(chunk);
|
|
71
|
+
}
|
|
72
|
+
return hash.digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function matches(file, expected, signal) {
|
|
76
|
+
try {
|
|
77
|
+
const info = await lstat(file);
|
|
78
|
+
// Do not accept cached symlinks or special files.
|
|
79
|
+
if (!info.isFile() || info.size > MAX_BYTES) return false;
|
|
80
|
+
return await sha256(file, signal) === expected;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code === 'ENOENT') return false;
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function publish(temp, destination, expected, signal) {
|
|
88
|
+
signal?.throwIfAborted();
|
|
89
|
+
if (process.platform !== 'win32') await chmod(temp, 0o755);
|
|
90
|
+
try {
|
|
91
|
+
await rename(temp, destination);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Another process may have finished the same acquisition on Windows.
|
|
94
|
+
if (['EEXIST', 'EPERM', 'EACCES'].includes(error.code)
|
|
95
|
+
&& await matches(destination, expected, signal)) return;
|
|
96
|
+
// A directory at the destination is stale cache from an older layout.
|
|
97
|
+
if (['EEXIST', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EISDIR'].includes(error.code)) {
|
|
98
|
+
const info = await lstat(destination).catch(() => undefined);
|
|
99
|
+
if (info?.isDirectory()) {
|
|
100
|
+
await rm(destination, { recursive: true, force: true });
|
|
101
|
+
try { await rename(temp, destination); return; } catch { /* Fall through. */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function acquire(asset, directory, signal, status) {
|
|
109
|
+
const destination = path.join(directory, process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp');
|
|
110
|
+
const expected = HASHES[asset];
|
|
111
|
+
status('Checking yt-dlp…');
|
|
112
|
+
if (await matches(destination, expected, signal)) {
|
|
113
|
+
if (process.platform !== 'win32') await chmod(destination, 0o755);
|
|
114
|
+
return destination;
|
|
115
|
+
}
|
|
116
|
+
const temp = `${destination}.${randomUUID()}.tmp`;
|
|
117
|
+
const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT);
|
|
118
|
+
const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
119
|
+
status(`Downloading yt-dlp ${RELEASE} (first use)…`);
|
|
120
|
+
try {
|
|
121
|
+
const response = await fetch(`${RELEASE_URL}/${asset}`, { signal: downloadSignal });
|
|
122
|
+
if (!response.ok || !response.body) {
|
|
123
|
+
await response.body?.cancel();
|
|
124
|
+
throw new Error(`yt-dlp download failed: HTTP ${response.status}.`);
|
|
125
|
+
}
|
|
126
|
+
if (Number(response.headers.get('content-length')) > MAX_BYTES) {
|
|
127
|
+
await response.body.cancel();
|
|
128
|
+
throw new Error('yt-dlp download exceeds the size limit.');
|
|
129
|
+
}
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
const hash = createHash('sha256');
|
|
132
|
+
const verifier = new Transform({
|
|
133
|
+
transform(chunk, encoding, callback) {
|
|
134
|
+
bytes += chunk.length;
|
|
135
|
+
if (bytes > MAX_BYTES) return callback(new Error('yt-dlp download exceeds the size limit.'));
|
|
136
|
+
hash.update(chunk);
|
|
137
|
+
callback(null, chunk);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
await pipeline(Readable.fromWeb(response.body), verifier,
|
|
141
|
+
createWriteStream(temp, { flags: 'wx', mode: 0o600 }), { signal: downloadSignal });
|
|
142
|
+
if (hash.digest('hex') !== expected) throw new Error('yt-dlp SHA-256 verification failed. The download was discarded.');
|
|
143
|
+
await publish(temp, destination, expected, downloadSignal);
|
|
144
|
+
return destination;
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
signal?.throwIfAborted();
|
|
147
|
+
if (timeout.aborted) throw new Error('yt-dlp download timed out after 3 minutes. Please retry.', { cause });
|
|
148
|
+
throw new Error(`Unable to prepare yt-dlp: ${cause.message} You can set VEO_YT_DLP_PATH to a trusted local executable.`, { cause });
|
|
149
|
+
} finally {
|
|
150
|
+
await rm(temp, { force: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function stage(source, destination, signal) {
|
|
155
|
+
const expected = await sha256(source, signal);
|
|
156
|
+
if (await matches(destination, expected, signal)) {
|
|
157
|
+
if (process.platform !== 'win32') await chmod(destination, 0o755);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const temp = `${destination}.${randomUUID()}.tmp`;
|
|
161
|
+
try {
|
|
162
|
+
signal?.throwIfAborted();
|
|
163
|
+
await copyFile(source, temp, constants.COPYFILE_EXCL);
|
|
164
|
+
if (!await matches(temp, expected, signal)) throw new Error(`Backend copy verification failed: ${source}`);
|
|
165
|
+
await publish(temp, destination, expected, signal);
|
|
166
|
+
} finally {
|
|
167
|
+
await rm(temp, { force: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Resolve native tools without executing them or using a shell.
|
|
173
|
+
* VEO_YT_DLP_PATH: trusted executable file; bypasses acquisition/pinned hash checks.
|
|
174
|
+
* VEO_FFMPEG_PATH: directory containing both ffmpeg[.exe] and ffprobe[.exe].
|
|
175
|
+
* Relative overrides resolve against cwd; no PATH search or install-time custom hook.
|
|
176
|
+
* ffmpeg-static's own FFMPEG_BIN override is honored by its normal module API.
|
|
177
|
+
* onStatus receives plain strings. Throws on cancellation or acquisition failure.
|
|
178
|
+
*/
|
|
179
|
+
export async function resolveBackend({ signal, onStatus } = {}) {
|
|
180
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal.');
|
|
181
|
+
if (onStatus !== undefined && typeof onStatus !== 'function') throw new TypeError('onStatus must be a function.');
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
const status = onStatus || (() => {});
|
|
184
|
+
const ytOverride = envPath('VEO_YT_DLP_PATH');
|
|
185
|
+
const ffOverride = envPath('VEO_FFMPEG_PATH');
|
|
186
|
+
// Diagnostic reports expose glibc when linked against it; no shell probe needed.
|
|
187
|
+
const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
|
|
188
|
+
const asset = ytOverride ? undefined : selectAsset(process.platform, process.arch, musl);
|
|
189
|
+
if (!ytOverride && !asset) throw new Error(`No standalone yt-dlp is available for ${process.platform}/${process.arch}. Set VEO_YT_DLP_PATH to a trusted executable.`);
|
|
190
|
+
// Validate overrides and dependencies before doing any network work.
|
|
191
|
+
let ytDlp = ytOverride ? await executable(ytOverride, 'VEO_YT_DLP_PATH') : undefined;
|
|
192
|
+
const suffix = process.platform === 'win32' ? '.exe' : '';
|
|
193
|
+
let ffmpeg;
|
|
194
|
+
let ffprobe;
|
|
195
|
+
if (ffOverride) {
|
|
196
|
+
await executable(path.join(ffOverride, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
|
|
197
|
+
await executable(path.join(ffOverride, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
|
|
198
|
+
} else {
|
|
199
|
+
// ffprobe-static exits the process on unsupported OSes/arches: guard before require.
|
|
200
|
+
if (!['win32', 'darwin', 'linux'].includes(process.platform)
|
|
201
|
+
|| (process.platform === 'darwin' && !['x64', 'arm64'].includes(process.arch))
|
|
202
|
+
|| (process.platform === 'win32' && process.arch === 'arm64')) {
|
|
203
|
+
throw new Error('Static media tools do not support this platform. Set VEO_FFMPEG_PATH to a directory containing ffmpeg and ffprobe.');
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
ffmpeg = await executable(require('ffmpeg-static'), 'ffmpeg-static');
|
|
207
|
+
ffprobe = await executable(require('ffprobe-static').path, 'ffprobe-static');
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
throw new Error(`Cannot load media tools: ${cause.message} Install ffmpeg-static and ffprobe-static, or set VEO_FFMPEG_PATH to a directory containing both binaries.`, { cause });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const directory = cacheDirectory(asset);
|
|
213
|
+
if (!ytOverride || !ffOverride) await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
214
|
+
if (!ytDlp) ytDlp = await acquire(asset, directory, signal, status);
|
|
215
|
+
else status('Using VEO_YT_DLP_PATH override.');
|
|
216
|
+
if (!ffOverride) {
|
|
217
|
+
status('Preparing ffmpeg and ffprobe…');
|
|
218
|
+
await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
|
|
219
|
+
await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
|
|
220
|
+
} else status('Using VEO_FFMPEG_PATH override.');
|
|
221
|
+
signal?.throwIfAborted();
|
|
222
|
+
return { ytDlp, ffmpegLocation: ffOverride || directory };
|
|
223
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createReporter } from './progress.js';
|
|
4
|
+
import { QUALITIES, VIDEO_FORMATS, AUDIO_FORMATS, validateUrl, readableError, cleanText } from './utils.js';
|
|
5
|
+
|
|
6
|
+
export const HELP = `veo - simple video downloader
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
veo <url> [options]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
-q, --quality <quality> Video quality (best, 2160p, 1440p, 1080p, 720p, 480p, 360p)
|
|
13
|
+
-o, --output <path> Output directory (default: current directory)
|
|
14
|
+
--audio Download audio only (default: mp3)
|
|
15
|
+
--format <format> Video: mp4, mkv, webm, mov; audio: mp3, m4a, aac, opus, flac, wav
|
|
16
|
+
--version Show version
|
|
17
|
+
-h, --help Show help
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
veo "https://youtube.com/watch?v=..."
|
|
21
|
+
veo <url> -q 1080p
|
|
22
|
+
veo <url> --audio
|
|
23
|
+
veo <url> -o ./downloads
|
|
24
|
+
|
|
25
|
+
Only download content you are authorized or legally permitted to download.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
export function parseCli(args) {
|
|
29
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, strict: true, options: {
|
|
30
|
+
quality: { type: 'string', short: 'q', default: 'best' },
|
|
31
|
+
output: { type: 'string', short: 'o', default: process.cwd() },
|
|
32
|
+
audio: { type: 'boolean', default: false },
|
|
33
|
+
format: { type: 'string' },
|
|
34
|
+
help: { type: 'boolean', short: 'h' },
|
|
35
|
+
version: { type: 'boolean' },
|
|
36
|
+
} });
|
|
37
|
+
if (values.help || values.version) return values;
|
|
38
|
+
if (positionals.length !== 1) throw new Error('Provide exactly one video URL. Run veo --help for usage.');
|
|
39
|
+
if (!QUALITIES.includes(values.quality)) throw new Error(`Invalid quality. Choose: ${QUALITIES.join(', ')}.`);
|
|
40
|
+
if (!values.output.trim()) throw new Error('The output directory cannot be empty.');
|
|
41
|
+
if (values.audio && values.quality !== 'best') throw new Error('--quality is for video; omit it when using --audio.');
|
|
42
|
+
const formats = values.audio ? AUDIO_FORMATS : VIDEO_FORMATS;
|
|
43
|
+
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.' : ''}`);
|
|
44
|
+
return { ...values, url: validateUrl(positionals[0]) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function main(args = process.argv.slice(2)) {
|
|
48
|
+
const reporter = createReporter();
|
|
49
|
+
const controller = new AbortController();
|
|
50
|
+
const cancel = () => controller.abort();
|
|
51
|
+
process.once('SIGINT', cancel);
|
|
52
|
+
process.once('SIGTERM', cancel);
|
|
53
|
+
try {
|
|
54
|
+
const options = parseCli(args);
|
|
55
|
+
if (options.help) { process.stdout.write(HELP); return 0; }
|
|
56
|
+
if (options.version) {
|
|
57
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
58
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
const { download } = await import('./downloader.js');
|
|
62
|
+
const saved = await download(options, { signal: controller.signal, reporter });
|
|
63
|
+
process.stdout.write(`Saved: ${cleanText(saved)}\n`);
|
|
64
|
+
return 0;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
reporter.finish();
|
|
67
|
+
process.stderr.write(`veo: ${readableError(error)}\n`);
|
|
68
|
+
return controller.signal.aborted ? 130 : 1;
|
|
69
|
+
} finally {
|
|
70
|
+
process.removeListener('SIGINT', cancel);
|
|
71
|
+
process.removeListener('SIGTERM', cancel);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolveBackend } from './backend.js';
|
|
6
|
+
import { closestHeight, saveUnique } from './utils.js';
|
|
7
|
+
|
|
8
|
+
export function runBackend(executable, args, { signal, onLine } = {}) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(executable, args, { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], signal });
|
|
11
|
+
let output = '';
|
|
12
|
+
let errors = '';
|
|
13
|
+
let failure;
|
|
14
|
+
const lines = createInterface({ input: child.stdout });
|
|
15
|
+
lines.on('line', line => {
|
|
16
|
+
if (onLine) {
|
|
17
|
+
try { onLine(line); } catch (error) { failure = error; child.kill(); }
|
|
18
|
+
} else if (output.length < 32 * 1024 * 1024) output += `${line}\n`;
|
|
19
|
+
else { failure = new Error('Video metadata exceeded the supported size.'); child.kill(); }
|
|
20
|
+
});
|
|
21
|
+
child.stderr.on('data', data => { errors = (errors + data).slice(-16000); });
|
|
22
|
+
child.on('error', error => { failure = error; });
|
|
23
|
+
child.on('close', code => {
|
|
24
|
+
lines.close();
|
|
25
|
+
if (failure) reject(failure);
|
|
26
|
+
else if (signal?.aborted) reject(new DOMException('Cancelled', 'AbortError'));
|
|
27
|
+
else if (code !== 0) reject(new Error(errors || `Downloading backend exited with code ${code}.`));
|
|
28
|
+
else resolve(output);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatSelector(height) {
|
|
34
|
+
const filter = height ? `[height=${height}]` : '';
|
|
35
|
+
return `bv${filter}+ba/b${filter}/bv${filter}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function download(options, { signal, reporter, backendResolver = resolveBackend } = {}) {
|
|
39
|
+
const directory = path.resolve(options.output);
|
|
40
|
+
await mkdir(directory, { recursive: true });
|
|
41
|
+
const backend = await backendResolver({ signal, onStatus: message => reporter?.status(message) });
|
|
42
|
+
const common = ['--ignore-config', '--no-plugin-dirs', '--no-playlist', '--no-colors', '--no-warnings',
|
|
43
|
+
'--socket-timeout', '30', '--retries', '3', '--fragment-retries', '3',
|
|
44
|
+
'--no-js-runtimes', '--js-runtimes', `node:${process.execPath}`,
|
|
45
|
+
'--ffmpeg-location', backend.ffmpegLocation];
|
|
46
|
+
reporter?.status('Reading video…');
|
|
47
|
+
const metadata = JSON.parse(await runBackend(backend.ytDlp,
|
|
48
|
+
[...common, '--dump-single-json', '--skip-download', '--', options.url], { signal }));
|
|
49
|
+
if (metadata._type === 'playlist' || metadata.entries) throw new Error('This URL is a collection. Please provide a single video URL.');
|
|
50
|
+
if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
|
|
51
|
+
if (metadata.has_drm) throw new Error('This content is DRM-protected.');
|
|
52
|
+
const height = options.audio ? null : closestHeight(metadata.formats || [metadata], options.quality);
|
|
53
|
+
if (!options.audio && options.quality !== 'best') {
|
|
54
|
+
reporter?.status(height ? `Quality: ${height}p${height !== parseInt(options.quality, 10) ? ` (closest to ${options.quality})` : ''}` : 'Resolution unknown; using the best available stream.');
|
|
55
|
+
}
|
|
56
|
+
const temporary = await mkdtemp(path.join(directory, '.veo-'));
|
|
57
|
+
try {
|
|
58
|
+
// A private staging directory keeps titles out of yt-dlp templates and isolates partial files.
|
|
59
|
+
const template = path.join(temporary, 'media.%(ext)s').replaceAll('%', '%%').replace('%%(ext)s', '%(ext)s');
|
|
60
|
+
const args = [...common, '--no-overwrites', '--no-continue', '--newline', '--progress', '--progress-delta', '0.2',
|
|
61
|
+
'--progress-template', 'download:veo-progress:%(progress)j',
|
|
62
|
+
'--print', 'after_move:veo-file:%(filepath)j', '--no-simulate', '-o', template];
|
|
63
|
+
if (options.audio) args.push('-f', 'ba/b', '--extract-audio', '--audio-format', options.format || 'mp3', '--audio-quality', '0');
|
|
64
|
+
else {
|
|
65
|
+
args.push('-f', formatSelector(height));
|
|
66
|
+
// Merge into a permissive container first; e.g. H.264 cannot be merged
|
|
67
|
+
// straight into WebM before the requested codec conversion runs.
|
|
68
|
+
if (options.format) args.push('--merge-output-format', 'mkv', '--recode-video', options.format);
|
|
69
|
+
else args.push('-S', 'vcodec:h264,acodec:aac', '--merge-output-format', 'mp4/mkv');
|
|
70
|
+
}
|
|
71
|
+
args.push('--', options.url);
|
|
72
|
+
let saved;
|
|
73
|
+
reporter?.status(options.audio ? 'Downloading audio…' : 'Downloading…');
|
|
74
|
+
await runBackend(backend.ytDlp, args, { signal, onLine(line) {
|
|
75
|
+
if (line.startsWith('veo-progress:')) reporter?.progress(JSON.parse(line.slice('veo-progress:'.length)));
|
|
76
|
+
if (line.startsWith('veo-file:')) saved = JSON.parse(line.slice('veo-file:'.length));
|
|
77
|
+
} });
|
|
78
|
+
if (!saved) throw new Error('The backend finished without producing a file.');
|
|
79
|
+
saved = path.resolve(saved);
|
|
80
|
+
if (path.dirname(saved) !== temporary || !(await stat(saved)).isFile()) throw new Error('The backend returned an invalid saved file path.');
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
return await saveUnique(saved, directory, metadata.title || metadata.id || 'video');
|
|
83
|
+
} finally {
|
|
84
|
+
reporter?.finish();
|
|
85
|
+
await rm(temporary, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cleanText } from './utils.js';
|
|
2
|
+
|
|
3
|
+
function bytes(value) {
|
|
4
|
+
if (!Number.isFinite(value) || value < 0) return '?';
|
|
5
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
6
|
+
let i = 0;
|
|
7
|
+
while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; }
|
|
8
|
+
return `${value.toFixed(i ? 1 : 0)} ${units[i]}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatProgress(data) {
|
|
12
|
+
const done = Number.isFinite(data.downloaded_bytes) ? data.downloaded_bytes : 0;
|
|
13
|
+
const total = data.total_bytes || data.total_bytes_estimate;
|
|
14
|
+
const percent = total > 0 ? Math.min(100, done / total * 100) : null;
|
|
15
|
+
const filled = percent === null ? 0 : Math.round(percent / 5);
|
|
16
|
+
const eta = Number.isFinite(data.eta) ? `${Math.floor(data.eta / 60)}:${String(Math.floor(data.eta % 60)).padStart(2, '0')}` : '?';
|
|
17
|
+
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
|
+
|
|
20
|
+
export function createReporter(stream = process.stderr) {
|
|
21
|
+
let active = false;
|
|
22
|
+
let lastLog = 0;
|
|
23
|
+
function clear() {
|
|
24
|
+
if (active && stream.isTTY) stream.write('\r\x1b[2K');
|
|
25
|
+
active = false;
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
status(message) { clear(); stream.write(`${cleanText(message)}\n`); },
|
|
29
|
+
progress(data) {
|
|
30
|
+
if (stream.isTTY) {
|
|
31
|
+
stream.write(`\r\x1b[2K${formatProgress(data)}`);
|
|
32
|
+
active = true;
|
|
33
|
+
} else if (Date.now() - lastLog >= 5000 || data.status === 'finished') {
|
|
34
|
+
stream.write(`${formatProgress(data)}\n`);
|
|
35
|
+
lastLog = Date.now();
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
finish: clear,
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { copyFile, unlink } from 'node:fs/promises';
|
|
3
|
+
import { constants } 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
|
+
|
|
9
|
+
export function validateUrl(input) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(input);
|
|
12
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error();
|
|
13
|
+
return url.href;
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error('Invalid URL. Use a complete http:// or https:// video URL without embedded credentials.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Untrusted site titles must never become paths, terminal escapes, or device names.
|
|
20
|
+
export function sanitizeTitle(title) {
|
|
21
|
+
let name = String(title || 'video').normalize('NFC')
|
|
22
|
+
.replace(/[<>:"/\\|?*\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '_')
|
|
23
|
+
.trim().replace(/[. ]+$/g, '');
|
|
24
|
+
if (/^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(name)) name = `_${name}`;
|
|
25
|
+
let shortened = '';
|
|
26
|
+
for (const char of name) {
|
|
27
|
+
if (Buffer.byteLength(shortened + char) > 180) break;
|
|
28
|
+
shortened += char;
|
|
29
|
+
}
|
|
30
|
+
return shortened.replace(/[. ]+$/g, '') || 'video';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function cleanText(value) {
|
|
34
|
+
return String(value).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function closestHeight(formats, quality) {
|
|
38
|
+
if (quality === 'best') return null;
|
|
39
|
+
const target = Number.parseInt(quality, 10);
|
|
40
|
+
const heights = [...new Set(formats.filter(f => f.vcodec !== 'none' && !f.has_drm && Number.isFinite(f.height) && f.height > 0).map(f => f.height))];
|
|
41
|
+
// Equidistant alternatives prefer the smaller download.
|
|
42
|
+
return heights.sort((a, b) => Math.abs(a - target) - Math.abs(b - target) || a - b)[0] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// COPYFILE_EXCL makes name allocation race-safe, including concurrent veo processes.
|
|
46
|
+
export async function saveUnique(source, directory, title) {
|
|
47
|
+
const extension = path.extname(source).toLowerCase();
|
|
48
|
+
if (!/^\.[a-z0-9]{1,8}$/.test(extension)) throw new Error('The backend returned an invalid output extension.');
|
|
49
|
+
const name = sanitizeTitle(title);
|
|
50
|
+
for (let number = 0; ; number++) {
|
|
51
|
+
const destination = path.join(directory, `${name}${number ? ` (${number})` : ''}${extension}`);
|
|
52
|
+
try {
|
|
53
|
+
await copyFile(source, destination, constants.COPYFILE_EXCL);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error.code === 'EEXIST') continue;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
await unlink(source);
|
|
59
|
+
return destination;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readableError(error) {
|
|
64
|
+
if (error.name === 'AbortError') return 'Cancelled.';
|
|
65
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') return 'Permission denied. Choose a writable output directory or check executable permissions.';
|
|
66
|
+
if (error.code === 'ENOSPC') return 'Not enough disk space to save the download.';
|
|
67
|
+
const text = cleanText(error.message || error);
|
|
68
|
+
if (/unsupported url|no suitable extractor/i.test(text)) return 'This URL or website is not supported by the downloading backend.';
|
|
69
|
+
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; use a publicly accessible video you are allowed to download.';
|
|
70
|
+
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.';
|
|
71
|
+
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.';
|
|
72
|
+
if (/drm.protected|digital rights/i.test(text)) return 'This content is DRM-protected. veo does not remove DRM.';
|
|
73
|
+
if (/network|timed? ?out|connection|resolve|ENOTFOUND|ECONN|fetch failed|HTTP Error (403|429|5\d\d)/i.test(text)) return 'Network request failed or the site blocked the request. Check your connection and URL, then try again later.';
|
|
74
|
+
if (/not available|unavailable|geo.?restrict|country/i.test(text)) return 'This content is unavailable or restricted in your region.';
|
|
75
|
+
return text.slice(-1200) || 'Download failed. Please try again.';
|
|
76
|
+
}
|