@lunarity/nebula-fetch-cli 0.0.2 → 0.0.4
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 +56 -1
- package/bun.lockb +0 -0
- package/commands/youtube.ts +138 -21
- package/dist/index.js +3488 -2790
- package/index.ts +33 -5
- package/package.json +11 -4
- package/tsconfig.json +6 -1
- package/utils/validators.ts +45 -0
package/README.md
CHANGED
|
@@ -1 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Nebula Fetch CLI
|
|
2
|
+
|
|
3
|
+
A command-line interface tool for downloading media from different platforms.
|
|
4
|
+
|
|
5
|
+
## 🚀 Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @lunarity/nebula-fetch-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 📖 Usage
|
|
12
|
+
|
|
13
|
+
### 📺 YouTube Downloads
|
|
14
|
+
|
|
15
|
+
Download videos or audio from YouTube using the following commands:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Download video (default)
|
|
19
|
+
nebula-fetch youtube <url>
|
|
20
|
+
# or using alias
|
|
21
|
+
nebula-fetch yt <url>
|
|
22
|
+
|
|
23
|
+
# Download audio only
|
|
24
|
+
nebula-fetch youtube <url> --audio
|
|
25
|
+
# or
|
|
26
|
+
nebula-fetch yt <url> -a
|
|
27
|
+
|
|
28
|
+
# Specify output path
|
|
29
|
+
nebula-fetch youtube <url> --output path/to/file
|
|
30
|
+
# or
|
|
31
|
+
nebula-fetch yt <url> -o path/to/file
|
|
32
|
+
|
|
33
|
+
# Show verbose output
|
|
34
|
+
nebula-fetch youtube <url> --verbose
|
|
35
|
+
# or
|
|
36
|
+
nebula-fetch yt <url> -v
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### ⚙️ Options
|
|
40
|
+
|
|
41
|
+
- `-o, --output <path>` - Specify the output path for the downloaded file
|
|
42
|
+
- `-a, --audio` - Download audio only (saves as .mp3)
|
|
43
|
+
- `-v, --verbose` - Show detailed information during download
|
|
44
|
+
- `--version` - Show version number
|
|
45
|
+
- `--help` - Show help information
|
|
46
|
+
|
|
47
|
+
## ✨ Features
|
|
48
|
+
|
|
49
|
+
- YouTube video downloads
|
|
50
|
+
- Audio-only extraction option
|
|
51
|
+
- Custom output path
|
|
52
|
+
- Verbose mode for detailed information
|
|
53
|
+
|
|
54
|
+
## 📄 License
|
|
55
|
+
|
|
56
|
+
MIT
|
package/bun.lockb
CHANGED
|
Binary file
|
package/commands/youtube.ts
CHANGED
|
@@ -1,39 +1,156 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
|
|
4
|
-
import { YtdlCore, toPipeableStream } from "@ybd-project/ytdl-core";
|
|
3
|
+
import { YtdlCore } from "@ybd-project/ytdl-core";
|
|
5
4
|
import chalk from "chalk";
|
|
5
|
+
import { Readable } from "stream";
|
|
6
|
+
|
|
7
|
+
interface DownloadOptions {
|
|
8
|
+
url: string;
|
|
9
|
+
audioOnly?: boolean;
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
outputPath?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function downloadYoutube(options: DownloadOptions): Promise<void> {
|
|
15
|
+
const { url, audioOnly = false, verbose = false, outputPath } = options;
|
|
6
16
|
|
|
7
|
-
export async function downloadYoutubeAudio(
|
|
8
|
-
url: string,
|
|
9
|
-
outputPath?: string
|
|
10
|
-
): Promise<void> {
|
|
11
17
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
console.log(chalk.cyan("🔍 Fetching video information..."));
|
|
19
|
+
// Use all clients to maximize chances of finding a working one
|
|
20
|
+
const ytdl = new YtdlCore({
|
|
21
|
+
clients: ["web", "android", "ios", "tv", "mweb"],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const info = await ytdl.getFullInfo(url);
|
|
25
|
+
|
|
26
|
+
console.log(chalk.cyan(`🎬 Video title: ${info.videoDetails.title}`));
|
|
27
|
+
console.log(
|
|
28
|
+
chalk.cyan(
|
|
29
|
+
`🎬 Video author: ${info.videoDetails.author?.name || "Unknown"}`
|
|
30
|
+
)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (verbose) {
|
|
34
|
+
console.log(JSON.stringify(info.videoDetails, null, 2));
|
|
14
35
|
}
|
|
36
|
+
|
|
37
|
+
const videoTitle = info.videoDetails.title.replace(/[^\w\s]/gi, "_");
|
|
15
38
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
// Filter to only valid (deciphered) formats
|
|
40
|
+
let validFormats = info.formats.filter(f => f.url);
|
|
41
|
+
|
|
42
|
+
if (validFormats.length === 0) {
|
|
43
|
+
throw new Error("No downloadable formats found (signatures could not be deciphered).");
|
|
44
|
+
}
|
|
19
45
|
|
|
20
|
-
|
|
21
|
-
|
|
46
|
+
if (verbose) {
|
|
47
|
+
console.log(`Found ${info.formats.length} total formats, ${validFormats.length} valid (deciphered).`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sort based on preference to try best ones first
|
|
51
|
+
validFormats.sort((a, b) => {
|
|
52
|
+
if (audioOnly) {
|
|
53
|
+
const aAudio = a.hasAudio && !a.hasVideo;
|
|
54
|
+
const bAudio = b.hasAudio && !b.hasVideo;
|
|
55
|
+
if (aAudio && !bAudio) return -1;
|
|
56
|
+
if (!aAudio && bAudio) return 1;
|
|
57
|
+
return (b.audioBitrate || 0) - (a.audioBitrate || 0);
|
|
58
|
+
} else {
|
|
59
|
+
const aMuxed = a.hasAudio && a.hasVideo;
|
|
60
|
+
const bMuxed = b.hasAudio && b.hasVideo;
|
|
61
|
+
if (aMuxed && !bMuxed) return -1;
|
|
62
|
+
if (!aMuxed && bMuxed) return 1;
|
|
63
|
+
return (b.bitrate || 0) - (a.bitrate || 0);
|
|
64
|
+
}
|
|
22
65
|
});
|
|
23
66
|
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
let workingFormat = null;
|
|
68
|
+
let workingUrl = "";
|
|
69
|
+
let finalUserAgent = "";
|
|
70
|
+
|
|
71
|
+
console.log(chalk.yellow("🔄 Testing formats to find a valid one..."));
|
|
72
|
+
|
|
73
|
+
for (const format of validFormats) {
|
|
74
|
+
let downloadUrl = format.url;
|
|
75
|
+
if (info.poToken && !downloadUrl.includes('pot=')) {
|
|
76
|
+
downloadUrl += `&pot=${encodeURIComponent(info.poToken)}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Determine User-Agent
|
|
80
|
+
let userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36';
|
|
81
|
+
if (format.sourceClientName === 'ios') {
|
|
82
|
+
userAgent = 'com.google.ios.youtube/19.29.1 (iPhone16,2; U; CPU iOS 17_5_1 like Mac OS X; en_US)';
|
|
83
|
+
} else if (format.sourceClientName === 'android') {
|
|
84
|
+
userAgent = 'com.google.android.youtube/19.29.35 (Linux; U; Android 14; en_US) gzip';
|
|
85
|
+
} else if (format.sourceClientName === 'tv') {
|
|
86
|
+
userAgent = 'Mozilla/5.0 (ChromiumNet/53.0.2785.124) Cobalt/53.0.2785.124-devel-000000-000 Star/1.0';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(downloadUrl, {
|
|
91
|
+
method: 'HEAD',
|
|
92
|
+
headers: {
|
|
93
|
+
'User-Agent': userAgent,
|
|
94
|
+
'Referer': 'https://www.youtube.com/',
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (response.ok) {
|
|
99
|
+
workingFormat = format;
|
|
100
|
+
workingUrl = downloadUrl;
|
|
101
|
+
finalUserAgent = userAgent;
|
|
102
|
+
if (verbose) console.log(chalk.green(` ✅ Found working format: ${format.itag} (${format.sourceClientName})`));
|
|
103
|
+
break;
|
|
104
|
+
} else {
|
|
105
|
+
if (verbose) console.log(chalk.red(` ❌ Format ${format.itag} (${format.sourceClientName}) failed: ${response.status}`));
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (verbose) console.log(chalk.red(` ❌ Format ${format.itag} error: ${e}`));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!workingFormat || !workingUrl) {
|
|
113
|
+
throw new Error("Could not find any valid downloadable format (all returned 403/Error).");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(chalk.blue(`⬇️ Downloading format: ${workingFormat.container} | ${workingFormat.qualityLabel || 'Audio'} | ${workingFormat.audioBitrate || '?'}kbps | Client: ${workingFormat.sourceClientName}`));
|
|
117
|
+
|
|
118
|
+
// Direct download bypass
|
|
119
|
+
const response = await fetch(workingUrl, {
|
|
120
|
+
headers: {
|
|
121
|
+
'User-Agent': finalUserAgent,
|
|
122
|
+
'Referer': 'https://www.youtube.com/',
|
|
123
|
+
}
|
|
26
124
|
});
|
|
27
125
|
|
|
126
|
+
if (!response.ok || !response.body) {
|
|
127
|
+
throw new Error(`Failed to download video stream: ${response.status} ${response.statusText}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
28
130
|
const outputFilePath =
|
|
29
|
-
outputPath ||
|
|
30
|
-
|
|
131
|
+
outputPath ||
|
|
132
|
+
path.join(process.cwd(), `${videoTitle}.${audioOnly ? "mp3" : "mp4"}`);
|
|
133
|
+
|
|
134
|
+
const fileStream = fs.createWriteStream(outputFilePath);
|
|
135
|
+
|
|
136
|
+
// Convert Web ReadableStream to Node Readable Stream
|
|
137
|
+
// @ts-ignore
|
|
138
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
139
|
+
|
|
140
|
+
await new Promise<void>((resolve, reject) => {
|
|
141
|
+
nodeStream.pipe(fileStream);
|
|
142
|
+
nodeStream.on('error', reject);
|
|
143
|
+
fileStream.on('finish', resolve);
|
|
144
|
+
fileStream.on('error', reject);
|
|
145
|
+
});
|
|
31
146
|
|
|
32
|
-
console.log(chalk.green(`✅ Download finished: ${outputFilePath}`));
|
|
147
|
+
console.log(chalk.green.bold(`✅ Download finished: ${outputFilePath}`));
|
|
33
148
|
} catch (error) {
|
|
34
149
|
if (error instanceof Error) {
|
|
35
|
-
console.error(chalk.red("Error:", error.message));
|
|
150
|
+
console.error(chalk.red.bold("❌ Error:", error.message));
|
|
151
|
+
if (verbose && error.stack) {
|
|
152
|
+
console.error(error.stack);
|
|
153
|
+
}
|
|
36
154
|
}
|
|
37
|
-
throw error;
|
|
38
155
|
}
|
|
39
|
-
}
|
|
156
|
+
}
|