ytdwn 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 +136 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +562 -0
- package/dist/src/banner.d.ts +8 -0
- package/dist/src/binary.d.ts +12 -0
- package/dist/src/colors.d.ts +23 -0
- package/dist/src/config.d.ts +8 -0
- package/dist/src/download.d.ts +16 -0
- package/dist/src/settings.d.ts +24 -0
- package/dist/src/timestamp.d.ts +9 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Batikan Kutluer
|
|
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,136 @@
|
|
|
1
|
+
# YTDWN
|
|
2
|
+
|
|
3
|
+
A fast and simple CLI tool to download audio from YouTube videos. Powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp).
|
|
4
|
+
|
|
5
|
+
## ✨ Features
|
|
6
|
+
|
|
7
|
+
- 🎵 Download audio from YouTube in MP3 or other formats
|
|
8
|
+
- ✂️ Clip specific sections of videos (e.g., `1:30-2:45`)
|
|
9
|
+
- 📁 Set a default download folder
|
|
10
|
+
- 🚀 Fast parallel downloads with concurrent fragments
|
|
11
|
+
- 🎨 Beautiful progress bar and colored output
|
|
12
|
+
- 📦 Auto-downloads yt-dlp binary if not present
|
|
13
|
+
|
|
14
|
+
## 📋 Requirements
|
|
15
|
+
|
|
16
|
+
- [Node.js](https://nodejs.org) >= 18.0.0 or [Bun](https://bun.sh) >= 1.0.0
|
|
17
|
+
|
|
18
|
+
## 🚀 Installation
|
|
19
|
+
|
|
20
|
+
### Using npm/npx
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Run directly without installing
|
|
24
|
+
npx ytdwn prepare
|
|
25
|
+
npx ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
26
|
+
|
|
27
|
+
# Or install globally
|
|
28
|
+
npm install -g ytdwn
|
|
29
|
+
ytdwn prepare
|
|
30
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Using Bun
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# Run directly without installing
|
|
37
|
+
bunx ytdwn prepare
|
|
38
|
+
bunx ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
39
|
+
|
|
40
|
+
# Or install globally
|
|
41
|
+
bun add -g ytdwn
|
|
42
|
+
ytdwn prepare
|
|
43
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### From Source
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Clone the repository
|
|
50
|
+
git clone https://github.com/batikankutluer/ytdwn.git
|
|
51
|
+
cd ytdwn
|
|
52
|
+
|
|
53
|
+
# Install dependencies
|
|
54
|
+
bun install
|
|
55
|
+
|
|
56
|
+
# Run directly from source (no build needed)
|
|
57
|
+
bun run index.ts prepare
|
|
58
|
+
bun run index.ts https://www.youtube.com/watch?v=VIDEO_ID
|
|
59
|
+
|
|
60
|
+
# Or build and use the compiled version
|
|
61
|
+
bun run build
|
|
62
|
+
node dist/index.js prepare
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 📖 Usage
|
|
66
|
+
|
|
67
|
+
### Basic Download
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# Download audio from a YouTube URL
|
|
71
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
72
|
+
|
|
73
|
+
# Or use the short form
|
|
74
|
+
ytdwn https://youtu.be/VIDEO_ID
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Options
|
|
78
|
+
|
|
79
|
+
| Flag | Description | Example |
|
|
80
|
+
| ----------------------- | ------------------------------ | ---------------- |
|
|
81
|
+
| `-f, --format <format>` | Audio format (default: mp3) | `-f opus` |
|
|
82
|
+
| `-c, --clip <range>` | Clip a specific time range | `-c 01:30-02:45` |
|
|
83
|
+
| `-q, --quiet` | Minimal output (only filename) | `-q` |
|
|
84
|
+
| `-v, --version` | Show version | `-v` |
|
|
85
|
+
| `-h, --help` | Show help | `-h` |
|
|
86
|
+
|
|
87
|
+
### Examples
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Download as MP3 (default)
|
|
91
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID
|
|
92
|
+
|
|
93
|
+
# Download as OPUS format
|
|
94
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID -f opus
|
|
95
|
+
|
|
96
|
+
# Download only a portion (from 1:30 to 2:45)
|
|
97
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID -c 1:30-2:45
|
|
98
|
+
|
|
99
|
+
# Quiet mode - outputs only the filename
|
|
100
|
+
ytdwn https://www.youtube.com/watch?v=VIDEO_ID -q
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Commands
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Download and prepare yt-dlp binary
|
|
107
|
+
ytdwn prepare
|
|
108
|
+
|
|
109
|
+
# Set default download folder
|
|
110
|
+
ytdwn setDefaultFolder ~/Music/YouTube
|
|
111
|
+
|
|
112
|
+
# View current download folder
|
|
113
|
+
ytdwn setDefaultFolder
|
|
114
|
+
|
|
115
|
+
# Reset to current directory
|
|
116
|
+
ytdwn setDefaultFolder --reset
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## ⚙️ Configuration
|
|
120
|
+
|
|
121
|
+
Settings are stored in `~/.ytdwn.json`:
|
|
122
|
+
|
|
123
|
+
- `downloadDir`: Default folder for downloaded files
|
|
124
|
+
- `binaryPath`: Cached path to yt-dlp binary
|
|
125
|
+
|
|
126
|
+
## 🛠️ Tech Stack
|
|
127
|
+
|
|
128
|
+
- **Runtime**: [Node.js](https://nodejs.org) or [Bun](https://bun.sh)
|
|
129
|
+
- **CLI Framework**: [Commander.js](https://github.com/tj/commander.js)
|
|
130
|
+
- **Downloader**: [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
131
|
+
- **Audio Processing**: [FFmpeg](https://ffmpeg.org)
|
|
132
|
+
- **Styling**: [cfonts](https://github.com/dominikwilkowski/cfonts), [gradient-string](https://github.com/bokub/gradient-string), [picocolors](https://github.com/alexeyraspopov/picocolors)
|
|
133
|
+
|
|
134
|
+
## 📄 License
|
|
135
|
+
|
|
136
|
+
MIT - see [LICENSE](LICENSE) file for details
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
var APP_NAME = "ytdwn";
|
|
9
|
+
var APP_TAGLINE = "YouTube to MP3 • Fast & Simple";
|
|
10
|
+
var APP_VERSION = "1.0.0";
|
|
11
|
+
var DEFAULT_FORMAT = "mp3";
|
|
12
|
+
var DEFAULT_AUDIO_QUALITY = "0";
|
|
13
|
+
var CONCURRENT_FRAGMENTS = "8";
|
|
14
|
+
var BIN_DIR = join(process.cwd(), "bin");
|
|
15
|
+
var getOutputTemplate = (downloadDir) => join(downloadDir, "%(title)s.%(ext)s");
|
|
16
|
+
|
|
17
|
+
// src/binary.ts
|
|
18
|
+
import { constants as fsConstants } from "fs";
|
|
19
|
+
import { access, chmod, mkdir, writeFile as writeFile2 } from "fs/promises";
|
|
20
|
+
import { platform } from "os";
|
|
21
|
+
import { join as join3, dirname } from "path";
|
|
22
|
+
import YTDlpWrap from "yt-dlp-wrap";
|
|
23
|
+
|
|
24
|
+
// src/settings.ts
|
|
25
|
+
import { readFile, writeFile } from "fs/promises";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
import { join as join2, resolve } from "path";
|
|
28
|
+
var SETTINGS_PATH = join2(homedir(), ".ytdwn.json");
|
|
29
|
+
async function load() {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(await readFile(SETTINGS_PATH, "utf-8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
var save = (settings) => writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
37
|
+
async function getDownloadDir() {
|
|
38
|
+
const { downloadDir } = await load();
|
|
39
|
+
return downloadDir ?? process.cwd();
|
|
40
|
+
}
|
|
41
|
+
async function setDownloadDir(dir) {
|
|
42
|
+
const settings = await load();
|
|
43
|
+
await save({ ...settings, downloadDir: resolve(dir) });
|
|
44
|
+
}
|
|
45
|
+
async function resetDownloadDir() {
|
|
46
|
+
const { downloadDir, ...rest } = await load();
|
|
47
|
+
await save(rest);
|
|
48
|
+
}
|
|
49
|
+
async function getCachedBinaryPath() {
|
|
50
|
+
const { binaryPath } = await load();
|
|
51
|
+
return binaryPath ?? null;
|
|
52
|
+
}
|
|
53
|
+
async function setCachedBinaryPath(path) {
|
|
54
|
+
const settings = await load();
|
|
55
|
+
await save({ ...settings, binaryPath: path });
|
|
56
|
+
}
|
|
57
|
+
async function clearCachedBinaryPath() {
|
|
58
|
+
const { binaryPath, ...rest } = await load();
|
|
59
|
+
await save(rest);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/binary.ts
|
|
63
|
+
var BINARY_NAMES = {
|
|
64
|
+
win32: ["yt-dlp.exe"],
|
|
65
|
+
darwin: {
|
|
66
|
+
arm64: ["yt-dlp_macos_arm64", "yt-dlp_macos_aarch64", "yt-dlp_macos"],
|
|
67
|
+
x64: ["yt-dlp_macos"]
|
|
68
|
+
},
|
|
69
|
+
linux: {
|
|
70
|
+
arm64: ["yt-dlp_linux_arm64", "yt-dlp_linux_aarch64", "yt-dlp"],
|
|
71
|
+
x64: ["yt-dlp_linux", "yt-dlp"]
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var FALLBACK_BINARY = "yt-dlp";
|
|
75
|
+
function getCandidateNames() {
|
|
76
|
+
const platformNames = BINARY_NAMES[process.platform];
|
|
77
|
+
if (!platformNames)
|
|
78
|
+
return [FALLBACK_BINARY];
|
|
79
|
+
if (Array.isArray(platformNames))
|
|
80
|
+
return platformNames;
|
|
81
|
+
const archNames = platformNames[process.arch];
|
|
82
|
+
return archNames ?? [FALLBACK_BINARY];
|
|
83
|
+
}
|
|
84
|
+
function getCandidatePaths() {
|
|
85
|
+
return getCandidateNames().map((name) => join3(BIN_DIR, name));
|
|
86
|
+
}
|
|
87
|
+
async function isExecutable(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
await access(filePath, fsConstants.X_OK);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function downloadFile(url, targetPath) {
|
|
96
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
97
|
+
const response = await fetch(url);
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
100
|
+
}
|
|
101
|
+
await writeFile2(targetPath, Buffer.from(await response.arrayBuffer()));
|
|
102
|
+
if (platform() !== "win32") {
|
|
103
|
+
await chmod(targetPath, 493);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function pickAsset(release) {
|
|
107
|
+
const assets = release.assets ?? [];
|
|
108
|
+
const candidates = getCandidateNames();
|
|
109
|
+
const match = assets.find((a) => candidates.includes(a.name)) ?? assets.find((a) => a.name === FALLBACK_BINARY);
|
|
110
|
+
if (!match) {
|
|
111
|
+
throw new Error("Suitable yt-dlp binary not found for this platform.");
|
|
112
|
+
}
|
|
113
|
+
return match;
|
|
114
|
+
}
|
|
115
|
+
async function findBinary() {
|
|
116
|
+
const cached = await getCachedBinaryPath();
|
|
117
|
+
if (cached && await isExecutable(cached)) {
|
|
118
|
+
return cached;
|
|
119
|
+
}
|
|
120
|
+
for (const candidate of getCandidatePaths()) {
|
|
121
|
+
if (await isExecutable(candidate)) {
|
|
122
|
+
await setCachedBinaryPath(candidate);
|
|
123
|
+
return candidate;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (cached)
|
|
127
|
+
await clearCachedBinaryPath();
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
async function requireBinary() {
|
|
131
|
+
const existing = await findBinary();
|
|
132
|
+
if (existing)
|
|
133
|
+
return existing;
|
|
134
|
+
throw new Error("yt-dlp binary not found. Run 'ytdwn prepare' first.");
|
|
135
|
+
}
|
|
136
|
+
async function downloadLatestBinary() {
|
|
137
|
+
const [release] = await YTDlpWrap.getGithubReleases(1, 1);
|
|
138
|
+
if (!release) {
|
|
139
|
+
throw new Error("Failed to fetch yt-dlp release from GitHub.");
|
|
140
|
+
}
|
|
141
|
+
const asset = pickAsset(release);
|
|
142
|
+
const binaryPath = join3(BIN_DIR, asset.name);
|
|
143
|
+
if (await isExecutable(binaryPath)) {
|
|
144
|
+
await setCachedBinaryPath(binaryPath);
|
|
145
|
+
return binaryPath;
|
|
146
|
+
}
|
|
147
|
+
await downloadFile(asset.browser_download_url, binaryPath);
|
|
148
|
+
await setCachedBinaryPath(binaryPath);
|
|
149
|
+
return binaryPath;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/download.ts
|
|
153
|
+
import { mkdir as mkdir2 } from "fs/promises";
|
|
154
|
+
import { spawn } from "child_process";
|
|
155
|
+
import ffmpegInstaller from "@ffmpeg-installer/ffmpeg";
|
|
156
|
+
|
|
157
|
+
// src/timestamp.ts
|
|
158
|
+
var TIME_FORMAT_ERROR = "Invalid time format. Use MM:SS or HH:MM:SS (e.g. 0:02 or 01:23:45).";
|
|
159
|
+
var RANGE_FORMAT_ERROR = "Range must be in 'start-end' format (e.g. 0:02-23:10).";
|
|
160
|
+
var pad = (value) => value.padStart(2, "0");
|
|
161
|
+
function normalizeTimeParts(parts) {
|
|
162
|
+
if (parts.length === 2) {
|
|
163
|
+
return ["0", parts[0] ?? "0", parts[1] ?? "0"];
|
|
164
|
+
}
|
|
165
|
+
return [parts[0] ?? "0", parts[1] ?? "0", parts[2] ?? "0"];
|
|
166
|
+
}
|
|
167
|
+
function parseTimestamp(raw) {
|
|
168
|
+
const parts = raw.split(":").map((p) => p.trim());
|
|
169
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
170
|
+
throw new Error(TIME_FORMAT_ERROR);
|
|
171
|
+
}
|
|
172
|
+
const [h, m, s] = normalizeTimeParts(parts);
|
|
173
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
174
|
+
}
|
|
175
|
+
function parseClipRange(range) {
|
|
176
|
+
const parts = range.split("-").map((p) => p.trim());
|
|
177
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
178
|
+
throw new Error(RANGE_FORMAT_ERROR);
|
|
179
|
+
}
|
|
180
|
+
return `${parseTimestamp(parts[0])}-${parseTimestamp(parts[1])}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/colors.ts
|
|
184
|
+
import pc from "picocolors";
|
|
185
|
+
var c = {
|
|
186
|
+
success: (text) => pc.green(text),
|
|
187
|
+
error: (text) => pc.red(text),
|
|
188
|
+
warn: (text) => pc.yellow(text),
|
|
189
|
+
info: (text) => pc.cyan(text),
|
|
190
|
+
dim: (text) => pc.dim(text),
|
|
191
|
+
bold: (text) => pc.bold(text),
|
|
192
|
+
file: (text) => pc.bold(pc.white(text)),
|
|
193
|
+
size: (text) => pc.dim(text),
|
|
194
|
+
speed: (text) => pc.cyan(text),
|
|
195
|
+
bar: {
|
|
196
|
+
filled: (text) => pc.green(text),
|
|
197
|
+
empty: (text) => pc.dim(text),
|
|
198
|
+
percent: (text) => pc.bold(text)
|
|
199
|
+
},
|
|
200
|
+
sym: {
|
|
201
|
+
success: pc.green("✓"),
|
|
202
|
+
error: pc.red("✗"),
|
|
203
|
+
warn: pc.yellow("⚠"),
|
|
204
|
+
info: pc.cyan("ℹ"),
|
|
205
|
+
arrow: pc.dim("→")
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// src/download.ts
|
|
210
|
+
var SPINNER_FRAMES = [
|
|
211
|
+
"⠋",
|
|
212
|
+
"⠙",
|
|
213
|
+
"⠹",
|
|
214
|
+
"⠸",
|
|
215
|
+
"⠼",
|
|
216
|
+
"⠴",
|
|
217
|
+
"⠦",
|
|
218
|
+
"⠧",
|
|
219
|
+
"⠇",
|
|
220
|
+
"⠏"
|
|
221
|
+
];
|
|
222
|
+
var SPINNER_INTERVAL_MS = 80;
|
|
223
|
+
var PROGRESS_BAR_WIDTH = 12;
|
|
224
|
+
var LINE_CLEAR_WIDTH = 60;
|
|
225
|
+
var REGEX = {
|
|
226
|
+
percent: /(\d+\.?\d*)%/,
|
|
227
|
+
total: /of\s+~?([\d.]+\s*\w+)/i,
|
|
228
|
+
speed: /at\s+([\d.]+\s*\w+\/s)/i,
|
|
229
|
+
size: /([\d.]+)\s*(\w+)/,
|
|
230
|
+
binaryUnits: /([KMG])iB/g,
|
|
231
|
+
destination: /\[(?:ExtractAudio|Merger)\].*?Destination:\s*(.+)$/m,
|
|
232
|
+
downloadDest: /\[download\]\s+Destination:\s*(.+)$/m,
|
|
233
|
+
fileSize: /~?([\d.]+\s*[KMG]i?B)/i
|
|
234
|
+
};
|
|
235
|
+
var PHASE_MESSAGES = new Map([
|
|
236
|
+
["Extracting URL", "Extracting..."],
|
|
237
|
+
["Downloading webpage", "Fetching info..."]
|
|
238
|
+
]);
|
|
239
|
+
var write = (text) => process.stdout.write(text);
|
|
240
|
+
var clearLine = () => write(`\r${" ".repeat(LINE_CLEAR_WIDTH)}\r`);
|
|
241
|
+
function createSpinner(initialMessage = "Getting ready...", quiet = false) {
|
|
242
|
+
let frame = 0;
|
|
243
|
+
let message = initialMessage;
|
|
244
|
+
let stopped = false;
|
|
245
|
+
if (quiet) {
|
|
246
|
+
return {
|
|
247
|
+
update: () => {},
|
|
248
|
+
stop: () => {}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const interval = setInterval(() => {
|
|
252
|
+
if (!stopped) {
|
|
253
|
+
const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
|
|
254
|
+
write(`\r${c.info(frameChar)} ${c.dim(message)}`);
|
|
255
|
+
}
|
|
256
|
+
}, SPINNER_INTERVAL_MS);
|
|
257
|
+
return {
|
|
258
|
+
update: (msg) => {
|
|
259
|
+
message = msg;
|
|
260
|
+
},
|
|
261
|
+
stop: () => {
|
|
262
|
+
if (stopped)
|
|
263
|
+
return;
|
|
264
|
+
stopped = true;
|
|
265
|
+
clearInterval(interval);
|
|
266
|
+
clearLine();
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function renderProgress({ percent, speed }, quiet = false) {
|
|
271
|
+
if (quiet)
|
|
272
|
+
return;
|
|
273
|
+
const filled = Math.round(percent / 100 * PROGRESS_BAR_WIDTH);
|
|
274
|
+
const bar = c.bar.filled("█".repeat(filled)) + c.bar.empty("░".repeat(PROGRESS_BAR_WIDTH - filled));
|
|
275
|
+
const speedText = speed ? ` ${c.speed(speed)}` : "";
|
|
276
|
+
write(`\r${c.bold("Downloading:")} ${bar} ${c.bar.percent(`${percent.toFixed(0)}%`)}${speedText} `);
|
|
277
|
+
}
|
|
278
|
+
var normalizeUnit = (value) => value.replace(REGEX.binaryUnits, "$1B");
|
|
279
|
+
function parseProgress(text) {
|
|
280
|
+
const percentMatch = text.match(REGEX.percent);
|
|
281
|
+
if (!percentMatch?.[1])
|
|
282
|
+
return null;
|
|
283
|
+
const percent = parseFloat(percentMatch[1]);
|
|
284
|
+
const total = text.match(REGEX.total)?.[1]?.trim();
|
|
285
|
+
const speed = text.match(REGEX.speed)?.[1];
|
|
286
|
+
let downloaded;
|
|
287
|
+
if (total) {
|
|
288
|
+
const [, size, unit] = total.match(REGEX.size) ?? [];
|
|
289
|
+
if (size && unit) {
|
|
290
|
+
downloaded = normalizeUnit(`${(percent / 100 * parseFloat(size)).toFixed(1)}${unit}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
percent,
|
|
295
|
+
downloaded,
|
|
296
|
+
total: total ? normalizeUnit(total) : undefined,
|
|
297
|
+
speed: speed ? normalizeUnit(speed) : undefined
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function detectPhase(text) {
|
|
301
|
+
for (const [pattern, message] of PHASE_MESSAGES) {
|
|
302
|
+
if (text.includes(pattern))
|
|
303
|
+
return message;
|
|
304
|
+
}
|
|
305
|
+
return text.includes("Downloading") && !text.includes("%") ? "Preparing..." : null;
|
|
306
|
+
}
|
|
307
|
+
var isConvertingPhase = (text) => text.includes("ExtractAudio") || text.includes("Converting");
|
|
308
|
+
function extractFileName(text) {
|
|
309
|
+
const match = text.match(REGEX.destination) || text.match(REGEX.downloadDest);
|
|
310
|
+
if (match?.[1]) {
|
|
311
|
+
const fullPath = match[1].trim();
|
|
312
|
+
return fullPath.split("/").pop() || null;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
function extractFileSize(text) {
|
|
317
|
+
const match = text.match(REGEX.fileSize);
|
|
318
|
+
return match?.[1] || null;
|
|
319
|
+
}
|
|
320
|
+
function buildArgs(url, options, downloadDir) {
|
|
321
|
+
const baseArgs = [
|
|
322
|
+
url,
|
|
323
|
+
"-f",
|
|
324
|
+
"bestaudio/best",
|
|
325
|
+
"-x",
|
|
326
|
+
"--audio-format",
|
|
327
|
+
options.format,
|
|
328
|
+
"--audio-quality",
|
|
329
|
+
DEFAULT_AUDIO_QUALITY,
|
|
330
|
+
"-o",
|
|
331
|
+
getOutputTemplate(downloadDir),
|
|
332
|
+
"--no-playlist",
|
|
333
|
+
"--newline",
|
|
334
|
+
"--progress",
|
|
335
|
+
"--concurrent-fragments",
|
|
336
|
+
CONCURRENT_FRAGMENTS,
|
|
337
|
+
"--no-check-certificates",
|
|
338
|
+
"--prefer-free-formats"
|
|
339
|
+
];
|
|
340
|
+
const conditionalArgs = [];
|
|
341
|
+
if (options.clip) {
|
|
342
|
+
conditionalArgs.push("--download-sections", `*${parseClipRange(options.clip)}`);
|
|
343
|
+
}
|
|
344
|
+
if (ffmpegInstaller?.path) {
|
|
345
|
+
conditionalArgs.push("--ffmpeg-location", ffmpegInstaller.path);
|
|
346
|
+
}
|
|
347
|
+
return [...baseArgs, ...conditionalArgs];
|
|
348
|
+
}
|
|
349
|
+
function createOutputHandler(spinner, state) {
|
|
350
|
+
return (data) => {
|
|
351
|
+
const text = data.toString();
|
|
352
|
+
const fileName = extractFileName(text);
|
|
353
|
+
if (fileName)
|
|
354
|
+
state.fileName = fileName;
|
|
355
|
+
const fileSize = extractFileSize(text);
|
|
356
|
+
if (fileSize)
|
|
357
|
+
state.fileSize = fileSize;
|
|
358
|
+
if (state.phase !== "converting" && isConvertingPhase(text)) {
|
|
359
|
+
state.phase = "converting";
|
|
360
|
+
spinner.stop();
|
|
361
|
+
clearLine();
|
|
362
|
+
if (!state.quiet) {
|
|
363
|
+
let frame = 0;
|
|
364
|
+
state.convertingInterval = setInterval(() => {
|
|
365
|
+
const frameChar = SPINNER_FRAMES[frame++ % SPINNER_FRAMES.length] ?? "⠋";
|
|
366
|
+
write(`\r${c.info(frameChar)} ${c.dim("Converting...")}`);
|
|
367
|
+
}, SPINNER_INTERVAL_MS);
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const progress = parseProgress(text);
|
|
372
|
+
if (progress && progress.percent > state.lastPercent) {
|
|
373
|
+
if (state.phase === "init") {
|
|
374
|
+
state.phase = "downloading";
|
|
375
|
+
spinner.stop();
|
|
376
|
+
}
|
|
377
|
+
state.lastPercent = progress.percent;
|
|
378
|
+
renderProgress(progress, state.quiet);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (state.phase === "init") {
|
|
382
|
+
const message = detectPhase(text);
|
|
383
|
+
if (message)
|
|
384
|
+
spinner.update(message);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function attachProcessHandlers(child, spinner, downloadDir, quiet) {
|
|
389
|
+
return new Promise((resolve2, reject) => {
|
|
390
|
+
const state = {
|
|
391
|
+
phase: "init",
|
|
392
|
+
lastPercent: 0,
|
|
393
|
+
fileName: null,
|
|
394
|
+
fileSize: null,
|
|
395
|
+
quiet,
|
|
396
|
+
convertingInterval: null
|
|
397
|
+
};
|
|
398
|
+
const handleOutput = createOutputHandler(spinner, state);
|
|
399
|
+
const cleanup = () => {
|
|
400
|
+
spinner.stop();
|
|
401
|
+
if (state.convertingInterval) {
|
|
402
|
+
clearInterval(state.convertingInterval);
|
|
403
|
+
}
|
|
404
|
+
if (!quiet)
|
|
405
|
+
clearLine();
|
|
406
|
+
};
|
|
407
|
+
child.stdout?.on("data", handleOutput);
|
|
408
|
+
child.stderr?.on("data", handleOutput);
|
|
409
|
+
child.on("close", (code) => {
|
|
410
|
+
cleanup();
|
|
411
|
+
if (code === 0) {
|
|
412
|
+
resolve2({
|
|
413
|
+
filePath: downloadDir,
|
|
414
|
+
fileName: state.fileName || "audio",
|
|
415
|
+
fileSize: state.fileSize || undefined
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
reject(new Error(`Download failed (code ${code})`));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
child.on("error", (err) => {
|
|
422
|
+
cleanup();
|
|
423
|
+
reject(err);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
async function downloadAudio(url, options) {
|
|
428
|
+
const [downloadDir, binaryPath] = await Promise.all([
|
|
429
|
+
getDownloadDir().then(async (dir) => {
|
|
430
|
+
await mkdir2(dir, { recursive: true });
|
|
431
|
+
return dir;
|
|
432
|
+
}),
|
|
433
|
+
requireBinary()
|
|
434
|
+
]);
|
|
435
|
+
const quiet = options.quiet ?? false;
|
|
436
|
+
const spinner = createSpinner("Getting ready...", quiet);
|
|
437
|
+
const child = spawn(binaryPath, buildArgs(url, options, downloadDir));
|
|
438
|
+
return attachProcessHandlers(child, spinner, downloadDir, quiet);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/banner.ts
|
|
442
|
+
import cfonts from "cfonts";
|
|
443
|
+
import gradient from "gradient-string";
|
|
444
|
+
var GRADIENT_COLORS = ["#ff6b6b", "#feca57", "#48dbfb", "#ff9ff3", "#54a0ff"];
|
|
445
|
+
function showBanner() {
|
|
446
|
+
const result = cfonts.render(APP_NAME, {
|
|
447
|
+
font: "block",
|
|
448
|
+
align: "left",
|
|
449
|
+
colors: ["system"],
|
|
450
|
+
background: "transparent",
|
|
451
|
+
letterSpacing: 1,
|
|
452
|
+
lineHeight: 0,
|
|
453
|
+
space: false,
|
|
454
|
+
maxLength: 0,
|
|
455
|
+
rawMode: true
|
|
456
|
+
});
|
|
457
|
+
if (result && typeof result !== "boolean" && result.string) {
|
|
458
|
+
const gradientBanner = gradient(GRADIENT_COLORS)(result.string);
|
|
459
|
+
console.log(gradientBanner);
|
|
460
|
+
}
|
|
461
|
+
console.log(gradient(GRADIENT_COLORS)(` ${APP_TAGLINE}
|
|
462
|
+
|
|
463
|
+
`));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// index.ts
|
|
467
|
+
var formatError = (err) => {
|
|
468
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
469
|
+
if (msg.includes("code 1") || msg.includes("Video unavailable")) {
|
|
470
|
+
return "Video not found or private";
|
|
471
|
+
}
|
|
472
|
+
if (msg.includes("code 2")) {
|
|
473
|
+
return "Invalid URL format";
|
|
474
|
+
}
|
|
475
|
+
if (msg.includes("age")) {
|
|
476
|
+
return "Age-restricted video (login required)";
|
|
477
|
+
}
|
|
478
|
+
if (msg.includes("network") || msg.includes("connect")) {
|
|
479
|
+
return "Connection error, try again";
|
|
480
|
+
}
|
|
481
|
+
return msg;
|
|
482
|
+
};
|
|
483
|
+
var exitWithError = (err) => {
|
|
484
|
+
console.error(`${c.sym.error} ${c.error("Error:")} ${formatError(err)}`);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
};
|
|
487
|
+
var isUrl = (arg) => arg.startsWith("http://") || arg.startsWith("https://");
|
|
488
|
+
async function handlePrepare() {
|
|
489
|
+
try {
|
|
490
|
+
if (await findBinary()) {
|
|
491
|
+
console.log(`${c.sym.success} ${c.success("Ready")}`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
console.log(`${c.dim("Downloading yt-dlp...")}`);
|
|
495
|
+
await downloadLatestBinary();
|
|
496
|
+
console.log(`${c.sym.success} ${c.success("Ready")}`);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
exitWithError(err);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function handleSetFolder(folderPath, opts) {
|
|
502
|
+
try {
|
|
503
|
+
if (opts?.reset) {
|
|
504
|
+
await resetDownloadDir();
|
|
505
|
+
console.log(`${c.sym.success} ${c.success("Reset to current directory")}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (folderPath) {
|
|
509
|
+
await setDownloadDir(folderPath);
|
|
510
|
+
console.log(`${c.sym.success} ${c.info(folderPath)}`);
|
|
511
|
+
} else {
|
|
512
|
+
console.log(c.info(await getDownloadDir()));
|
|
513
|
+
}
|
|
514
|
+
} catch (err) {
|
|
515
|
+
exitWithError(err);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async function handleDownload(url, opts) {
|
|
519
|
+
try {
|
|
520
|
+
if (!opts.quiet) {
|
|
521
|
+
showBanner();
|
|
522
|
+
console.log(`${c.dim("URL:")} ${c.info(url)}
|
|
523
|
+
`);
|
|
524
|
+
}
|
|
525
|
+
const result = await downloadAudio(url, {
|
|
526
|
+
format: opts.format,
|
|
527
|
+
clip: opts.clip,
|
|
528
|
+
quiet: opts.quiet
|
|
529
|
+
});
|
|
530
|
+
if (opts.quiet) {
|
|
531
|
+
console.log(result.fileName);
|
|
532
|
+
} else {
|
|
533
|
+
console.log();
|
|
534
|
+
console.log(`${c.sym.success} ${c.success("Process done!")}
|
|
535
|
+
`);
|
|
536
|
+
const sizeInfo = result.fileSize ? ` ${c.size(`(${result.fileSize})`)}` : "";
|
|
537
|
+
console.log(`${c.file(result.fileName)}${sizeInfo}`);
|
|
538
|
+
}
|
|
539
|
+
} catch (err) {
|
|
540
|
+
exitWithError(err);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
program.name(APP_NAME).usage("<url> [options]").description("Download audio from YouTube").version(APP_VERSION, "-v, --version").option("-f, --format <format>", "Audio format", DEFAULT_FORMAT).option("-c, --clip <range>", "Clip range (e.g. 1:30-2:45)").option("-q, --quiet", "Minimal output (only file name)").addHelpText("beforeAll", "").hook("preAction", (thisCommand) => {
|
|
544
|
+
if (thisCommand.args.includes("help") || process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
545
|
+
showBanner();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
program.command("prepare").description("Download yt-dlp binary").action(handlePrepare);
|
|
549
|
+
program.command("setDefaultFolder [path]").description("Set or view default download folder").option("-r, --reset", "Reset to current directory").action(handleSetFolder);
|
|
550
|
+
program.command("download <url>", { hidden: true }).description("Download audio from YouTube URL").option("-f, --format <format>", "Audio format", DEFAULT_FORMAT).option("-c, --clip <range>", "Clip range (e.g. 1:30-2:45)").option("-q, --quiet", "Minimal output").action(handleDownload);
|
|
551
|
+
var firstArg = process.argv[2];
|
|
552
|
+
if (firstArg && isUrl(firstArg)) {
|
|
553
|
+
process.argv.splice(2, 0, "download");
|
|
554
|
+
}
|
|
555
|
+
if (process.argv.length <= 2) {
|
|
556
|
+
showBanner();
|
|
557
|
+
program.help();
|
|
558
|
+
}
|
|
559
|
+
if (process.argv.includes("-v") || process.argv.includes("--version")) {
|
|
560
|
+
showBanner();
|
|
561
|
+
}
|
|
562
|
+
program.parse();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds an existing prepared binary. Uses cache for speed.
|
|
3
|
+
*/
|
|
4
|
+
export declare function findBinary(): Promise<string | null>;
|
|
5
|
+
/**
|
|
6
|
+
* Ensures a prepared binary exists, throws if not.
|
|
7
|
+
*/
|
|
8
|
+
export declare function requireBinary(): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Downloads the latest yt-dlp binary from GitHub.
|
|
11
|
+
*/
|
|
12
|
+
export declare function downloadLatestBinary(): Promise<string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const c: {
|
|
2
|
+
success: (text: string) => string;
|
|
3
|
+
error: (text: string) => string;
|
|
4
|
+
warn: (text: string) => string;
|
|
5
|
+
info: (text: string) => string;
|
|
6
|
+
dim: (text: string) => string;
|
|
7
|
+
bold: (text: string) => string;
|
|
8
|
+
file: (text: string) => string;
|
|
9
|
+
size: (text: string) => string;
|
|
10
|
+
speed: (text: string) => string;
|
|
11
|
+
bar: {
|
|
12
|
+
filled: (text: string) => string;
|
|
13
|
+
empty: (text: string) => string;
|
|
14
|
+
percent: (text: string) => string;
|
|
15
|
+
};
|
|
16
|
+
sym: {
|
|
17
|
+
success: string;
|
|
18
|
+
error: string;
|
|
19
|
+
warn: string;
|
|
20
|
+
info: string;
|
|
21
|
+
arrow: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const APP_NAME = "ytdwn";
|
|
2
|
+
export declare const APP_TAGLINE = "YouTube to MP3 \u2022 Fast & Simple";
|
|
3
|
+
export declare const APP_VERSION = "1.0.0";
|
|
4
|
+
export declare const DEFAULT_FORMAT = "mp3";
|
|
5
|
+
export declare const DEFAULT_AUDIO_QUALITY = "0";
|
|
6
|
+
export declare const CONCURRENT_FRAGMENTS = "8";
|
|
7
|
+
export declare const BIN_DIR: string;
|
|
8
|
+
export declare const getOutputTemplate: (downloadDir: string) => string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface DownloadOptions {
|
|
2
|
+
format: string;
|
|
3
|
+
clip?: string;
|
|
4
|
+
quiet?: boolean;
|
|
5
|
+
}
|
|
6
|
+
interface DownloadResult {
|
|
7
|
+
filePath: string;
|
|
8
|
+
fileName: string;
|
|
9
|
+
fileSize?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Downloads audio from a URL using yt-dlp.
|
|
13
|
+
* @returns Download result with file info
|
|
14
|
+
*/
|
|
15
|
+
export declare function downloadAudio(url: string, options: DownloadOptions): Promise<DownloadResult>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets the download directory. Falls back to current directory if not set.
|
|
3
|
+
*/
|
|
4
|
+
export declare function getDownloadDir(): Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* Sets the download directory.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setDownloadDir(dir: string): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Resets the download directory to default (current directory).
|
|
11
|
+
*/
|
|
12
|
+
export declare function resetDownloadDir(): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Gets the cached binary path.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getCachedBinaryPath(): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Sets the cached binary path.
|
|
19
|
+
*/
|
|
20
|
+
export declare function setCachedBinaryPath(path: string): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Clears the cached binary path.
|
|
23
|
+
*/
|
|
24
|
+
export declare function clearCachedBinaryPath(): Promise<void>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a raw timestamp string into HH:MM:SS format.
|
|
3
|
+
* Accepts MM:SS or HH:MM:SS formats.
|
|
4
|
+
*/
|
|
5
|
+
export declare function parseTimestamp(raw: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Parses a clip range string (start-end) into normalized format.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseClipRange(range: string): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ytdwn",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A fast and simple CLI tool to download audio from YouTube videos",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Batikan Kutluer",
|
|
7
|
+
"url": "https://github.com/batikankutluer"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/batikankutluer/ytdwn.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/batikankutluer/ytdwn#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/batikankutluer/ytdwn/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"youtube",
|
|
20
|
+
"download",
|
|
21
|
+
"mp3",
|
|
22
|
+
"audio",
|
|
23
|
+
"yt-dlp",
|
|
24
|
+
"cli",
|
|
25
|
+
"music",
|
|
26
|
+
"converter",
|
|
27
|
+
"extractor",
|
|
28
|
+
"bun"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18.0.0",
|
|
32
|
+
"bun": ">=1.0.0"
|
|
33
|
+
},
|
|
34
|
+
"type": "module",
|
|
35
|
+
"main": "dist/index.js",
|
|
36
|
+
"types": "dist/index.d.ts",
|
|
37
|
+
"bin": {
|
|
38
|
+
"ytdwn": "dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"dev": "bun run index.ts",
|
|
45
|
+
"build": "bun run build:clean && bun run build:bundle && bun run build:types && bun run build:chmod",
|
|
46
|
+
"build:clean": "rm -rf dist",
|
|
47
|
+
"build:bundle": "bun build ./index.ts --outfile ./dist/index.js --target node --packages external",
|
|
48
|
+
"build:types": "bun x tsc --project tsconfig.build.json --emitDeclarationOnly",
|
|
49
|
+
"build:chmod": "chmod +x dist/index.js",
|
|
50
|
+
"typecheck": "bun x tsc --noEmit",
|
|
51
|
+
"prepublishOnly": "bun run build"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
55
|
+
"cfonts": "^3.3.1",
|
|
56
|
+
"commander": "^14.0.2",
|
|
57
|
+
"gradient-string": "^3.0.0",
|
|
58
|
+
"picocolors": "^1.1.1",
|
|
59
|
+
"yt-dlp-wrap": "^2.3.12"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/bun": "^1.1.14",
|
|
63
|
+
"@types/node": "^22.0.0",
|
|
64
|
+
"typescript": "^5.7.0"
|
|
65
|
+
}
|
|
66
|
+
}
|