npmytd 1.0.7 → 1.0.9
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/package.json +2 -4
- package/src/cli.js +3 -19
- package/src/downloader.js +111 -138
- package/src/prompts.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "npmytd",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "A CLI tool to download YouTube videos or audio.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"type": "module",
|
|
@@ -22,8 +22,6 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"chalk": "^5.6.2",
|
|
24
24
|
"cli-progress": "^3.12.0",
|
|
25
|
-
"
|
|
26
|
-
"inquirer": "^13.2.2",
|
|
27
|
-
"ytdl-core": "^4.11.5"
|
|
25
|
+
"inquirer": "^13.2.2"
|
|
28
26
|
}
|
|
29
27
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getYouTubeUrl, getOutputFormat, getQuality, getOutputPath } from './prompts.js';
|
|
2
2
|
import { downloadYouTubeMedia } from './downloader.js';
|
|
3
|
-
import ytdl from 'ytdl-core';
|
|
4
3
|
import chalk from 'chalk';
|
|
5
4
|
import path from 'path';
|
|
6
5
|
import { sanitizeFilename } from './utils.js';
|
|
@@ -33,25 +32,10 @@ async function main() {
|
|
|
33
32
|
const format = await getOutputFormat();
|
|
34
33
|
const quality = await getQuality(format);
|
|
35
34
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
videoInfo = await ytdl.getInfo(url);
|
|
40
|
-
} catch (error) {
|
|
41
|
-
if (error.message.includes('Could not extract functions')) {
|
|
42
|
-
console.error(chalk.red(`Error fetching video information: ${error.message}`));
|
|
43
|
-
console.error(chalk.yellow(`This often happens when YouTube updates its website, temporarily breaking the downloader.
|
|
44
|
-
Please try again later, or check the 'ytdl-core' GitHub page for updates.`));
|
|
45
|
-
} else {
|
|
46
|
-
console.error(chalk.red('Error fetching video information:', error.message));
|
|
47
|
-
}
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
const suggestedFilename = sanitizeFilename(videoInfo.videoDetails.title);
|
|
35
|
+
// outputPath will now be the directory
|
|
36
|
+
const outputDirectory = await getOutputPath();
|
|
51
37
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await downloadYouTubeMedia(url, format, quality, outputPath);
|
|
38
|
+
await downloadYouTubeMedia(url, format, quality, outputDirectory);
|
|
55
39
|
|
|
56
40
|
console.log(chalk.bold.green(`
|
|
57
41
|
Process completed successfully!`));
|
package/src/downloader.js
CHANGED
|
@@ -1,26 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
2
|
import cliProgress from 'cli-progress';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import fs from 'fs';
|
|
5
|
-
import ffmpeg from 'fluent-ffmpeg';
|
|
4
|
+
import { promises as fs } from 'fs'; // Use promises API for fs
|
|
6
5
|
import { sanitizeFilename, ensureDirectoryExists } from './utils.js';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
|
|
9
|
-
// Ensure ffmpeg path is set if it's not in the system's PATH
|
|
10
|
-
// This might be necessary on some systems. For broader compatibility,
|
|
11
|
-
// it's good practice to ensure ffmpeg is discoverable.
|
|
12
|
-
// ffmpeg.setFfmpegPath('/path/to/your/ffmpeg'); // Uncomment and set if needed
|
|
13
|
-
|
|
14
8
|
export async function downloadYouTubeMedia(url, format, quality, outputPath) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const targetDirectory = path.dirname(outputPath);
|
|
21
|
-
const targetFilename = path.basename(outputPath);
|
|
22
|
-
const finalPath = path.join(targetDirectory, targetFilename);
|
|
9
|
+
let videoTitle = 'unknown_video'; // Default title
|
|
10
|
+
const targetDirectory = path.dirname(outputPath);
|
|
11
|
+
let finalFileName = path.basename(outputPath); // Will be updated after getting info
|
|
12
|
+
let fullOutputPath;
|
|
23
13
|
|
|
14
|
+
try {
|
|
24
15
|
// Ensure the output directory exists
|
|
25
16
|
const dirExists = await ensureDirectoryExists(targetDirectory);
|
|
26
17
|
if (!dirExists) {
|
|
@@ -28,158 +19,140 @@ export async function downloadYouTubeMedia(url, format, quality, outputPath) {
|
|
|
28
19
|
return;
|
|
29
20
|
}
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
22
|
+
// --- Step 1: Get video information using yt-dlp --print-json ---
|
|
23
|
+
console.log(chalk.blue('Fetching video information...'));
|
|
24
|
+
const infoProcess = spawn('yt-dlp', ['--print-json', url]);
|
|
25
|
+
let rawInfo = '';
|
|
26
|
+
infoProcess.stdout.on('data', (data) => {
|
|
27
|
+
rawInfo += data.toString();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await new Promise((resolve, reject) => {
|
|
31
|
+
infoProcess.on('close', (code) => {
|
|
32
|
+
if (code === 0) {
|
|
33
|
+
try {
|
|
34
|
+
const videoInfo = JSON.parse(rawInfo);
|
|
35
|
+
videoTitle = sanitizeFilename(videoInfo.title);
|
|
36
|
+
resolve();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
reject(new Error('Failed to parse video info from yt-dlp.'));
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
reject(new Error(`yt-dlp info command failed with code ${code}`));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
infoProcess.on('error', (err) => {
|
|
45
|
+
reject(new Error(`Failed to run yt-dlp. Is yt-dlp installed and in your PATH? (${err.message})`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Update finalFileName with sanitized video title and desired extension
|
|
50
|
+
const outputExtension = format === 'mp4' ? '.mp4' : '.mp3';
|
|
51
|
+
finalFileName = `${videoTitle}${outputExtension}`;
|
|
52
|
+
fullOutputPath = path.join(targetDirectory, finalFileName);
|
|
34
53
|
|
|
35
|
-
|
|
54
|
+
console.log(chalk.blue(`\nDownloading "${videoTitle}" as ${format.toUpperCase()}...`));
|
|
55
|
+
|
|
56
|
+
// --- Step 2: Construct yt-dlp arguments for download ---
|
|
57
|
+
const ytDlpArgs = [url, '-o', fullOutputPath]; // -o for output path
|
|
36
58
|
|
|
37
59
|
if (format === 'mp4') {
|
|
38
|
-
outputExtension = '.mp4';
|
|
39
60
|
switch (quality) {
|
|
40
61
|
case 'highest':
|
|
41
|
-
|
|
42
|
-
|
|
62
|
+
ytDlpArgs.push('-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]');
|
|
63
|
+
ytDlpArgs.push('--merge-output-format', 'mp4');
|
|
43
64
|
break;
|
|
44
65
|
case 'medium':
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Fallback if 720p with audio is not found
|
|
48
|
-
if (!info.formats.find(f => f.qualityLabel === '720p' && f.hasAudio && f.hasVideo)) {
|
|
49
|
-
ytdlOptions.filter = (format) => format.qualityLabel === '480p' && format.hasAudio && format.hasVideo;
|
|
50
|
-
}
|
|
51
|
-
if (!info.formats.find(f => f.qualityLabel === '480p' && f.hasAudio && f.hasVideo)) {
|
|
52
|
-
ytdlOptions.quality = 'lowestvideo'; // Fallback to lowest if medium not found
|
|
53
|
-
}
|
|
66
|
+
ytDlpArgs.push('-f', 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]/best[height<=720][ext=mp4]');
|
|
67
|
+
ytDlpArgs.push('--merge-output-format', 'mp4');
|
|
54
68
|
break;
|
|
55
69
|
case 'lowest':
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
ytdlOptions.quality = 'lowestvideo'; // Fallback to lowest available video with audio
|
|
59
|
-
}
|
|
70
|
+
ytDlpArgs.push('-f', 'bestvideo[height<=360][ext=mp4]+bestaudio[ext=m4a]/best[height<=360][ext=mp4]');
|
|
71
|
+
ytDlpArgs.push('--merge-output-format', 'mp4');
|
|
60
72
|
break;
|
|
61
73
|
}
|
|
62
74
|
} else if (format === 'mp3') {
|
|
63
|
-
|
|
64
|
-
ytdlOptions.filter = 'audioonly';
|
|
75
|
+
ytDlpArgs.push('--extract-audio', '--audio-format', 'mp3');
|
|
65
76
|
switch (quality) {
|
|
66
77
|
case 'highest':
|
|
67
|
-
|
|
78
|
+
ytDlpArgs.push('--audio-quality', '0'); // best quality
|
|
68
79
|
break;
|
|
69
80
|
case 'medium':
|
|
70
|
-
|
|
81
|
+
ytDlpArgs.push('--audio-quality', '5'); // VBR ~130-160 kbps
|
|
71
82
|
break;
|
|
72
83
|
case 'lowest':
|
|
73
|
-
|
|
84
|
+
ytDlpArgs.push('--audio-quality', '9'); // VBR ~45-85 kbps
|
|
74
85
|
break;
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
// --- Step 3: Execute yt-dlp for download and show progress ---
|
|
90
|
+
const downloadProcess = spawn('yt-dlp', ytDlpArgs);
|
|
78
91
|
const bar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
|
|
79
|
-
let
|
|
80
|
-
let
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
92
|
+
let totalDownloadSize = 0; // in bytes
|
|
93
|
+
let currentProgress = 0; // in percentage or bytes
|
|
94
|
+
|
|
95
|
+
downloadProcess.stderr.on('data', (data) => {
|
|
96
|
+
const output = data.toString();
|
|
97
|
+
// yt-dlp progress format example: [download] 10.0% of 100.00MiB at 1.23MiB/s ETA 00:05
|
|
98
|
+
const progressMatch = output.match(/\[download\]\s+(\d+\.\d+)% of (~?\d+\.\d+\w+)/);
|
|
99
|
+
const sizeMatch = output.match(/\[download\] Destination: (.+)/); // Match the final output file to determine size if no % progress
|
|
100
|
+
|
|
101
|
+
if (progressMatch) {
|
|
102
|
+
const percentage = parseFloat(progressMatch[1]);
|
|
103
|
+
const totalSizeStr = progressMatch[2];
|
|
104
|
+
|
|
105
|
+
// yt-dlp might report 'of ~SIZE' when total size is not known yet.
|
|
106
|
+
// For simplicity, we'll start the bar when we have a good estimate.
|
|
107
|
+
if (totalDownloadSize === 0 && !totalSizeStr.startsWith('~')) {
|
|
108
|
+
// Convert string size (e.g., 100.00MiB) to bytes. Simple approximation.
|
|
109
|
+
const sizeValue = parseFloat(totalSizeStr.match(/(\d+\.\d+)/)[1]);
|
|
110
|
+
const sizeUnit = totalSizeStr.match(/(\w+)$/)[1];
|
|
111
|
+
let multiplier = 1;
|
|
112
|
+
if (sizeUnit === 'KiB') multiplier = 1024;
|
|
113
|
+
else if (sizeUnit === 'MiB') multiplier = 1024 * 1024;
|
|
114
|
+
else if (sizeUnit === 'GiB') multiplier = 1024 * 1024 * 1024;
|
|
115
|
+
totalDownloadSize = sizeValue * multiplier;
|
|
116
|
+
bar.start(100, percentage); // Start with 100 as total and update with percentage
|
|
117
|
+
}
|
|
118
|
+
bar.update(percentage);
|
|
119
|
+
} else if (output.includes('[download] 100%')) {
|
|
120
|
+
// Ensure bar reaches 100% at the end
|
|
121
|
+
bar.update(100);
|
|
122
|
+
}
|
|
123
|
+
// For debugging yt-dlp output
|
|
124
|
+
// console.error(output.trim());
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
downloadProcess.on('close', (code) => {
|
|
129
|
+
bar.stop();
|
|
130
|
+
if (code === 0) {
|
|
131
|
+
console.log(chalk.green(`\nDownload completed: ${fullOutputPath}`));
|
|
105
132
|
resolve();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
console.error(chalk.red(`
|
|
110
|
-
Error during download:`, err.message));
|
|
111
|
-
reject(err);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
} else if (format === 'mp3') {
|
|
116
|
-
const audioStream = ytdl(url, ytdlOptions);
|
|
117
|
-
const tempFilePath = finalPath + '.tmp'; // Use a temporary file for download
|
|
118
|
-
|
|
119
|
-
audioStream.on('response', (res) => {
|
|
120
|
-
totalBytes = parseInt(res.headers['content-length'], 10);
|
|
121
|
-
bar.start(totalBytes, 0);
|
|
133
|
+
} else {
|
|
134
|
+
reject(new Error(`yt-dlp download failed with code ${code}`));
|
|
135
|
+
}
|
|
122
136
|
});
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
bar.update(receivedBytes);
|
|
137
|
+
downloadProcess.on('error', (err) => {
|
|
138
|
+
bar.stop();
|
|
139
|
+
reject(new Error(`Failed to run yt-dlp. Is yt-dlp installed and in your PATH? (${err.message})`));
|
|
127
140
|
});
|
|
141
|
+
});
|
|
128
142
|
|
|
129
|
-
await new Promise((resolve, reject) => {
|
|
130
|
-
audioStream.pipe(fs.createWriteStream(tempFilePath))
|
|
131
|
-
.on('finish', () => {
|
|
132
|
-
bar.stop();
|
|
133
|
-
console.log(chalk.blue(`
|
|
134
|
-
Download of audio stream completed. Starting conversion to MP3...`));
|
|
135
|
-
|
|
136
|
-
// Check if ffmpeg is available
|
|
137
|
-
ffmpeg.getFfmpegVersion((err) => {
|
|
138
|
-
if (err) {
|
|
139
|
-
console.error(chalk.red(`ffmpeg is not installed or not found in your system's PATH.`));
|
|
140
|
-
console.error(chalk.red('Please install ffmpeg to convert to MP3.'));
|
|
141
|
-
console.error(chalk.red(`You can download it from https://ffmpeg.org/download.html and ensure it's accessible in your PATH.`));
|
|
142
|
-
reject(new Error('ffmpeg not found.'));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
ffmpeg(tempFilePath)
|
|
147
|
-
.audioBitrate(quality === 'highest' ? 320 : (quality === 'medium' ? 192 : 128)) // Set bitrate based on quality
|
|
148
|
-
.save(finalPath + outputExtension)
|
|
149
|
-
.on('end', () => {
|
|
150
|
-
fs.unlink(tempFilePath, (err) => { // Clean up temporary file
|
|
151
|
-
if (err) console.error(chalk.yellow('Warning: Could not remove temporary file:', err.message));
|
|
152
|
-
});
|
|
153
|
-
console.log(chalk.green(`
|
|
154
|
-
Conversion and download completed: ${finalPath}${outputExtension}`));
|
|
155
|
-
resolve();
|
|
156
|
-
})
|
|
157
|
-
.on('error', (err) => {
|
|
158
|
-
fs.unlink(tempFilePath, (err) => { // Clean up temporary file on error
|
|
159
|
-
if (err) console.error(chalk.yellow('Warning: Could not remove temporary file:', err.message));
|
|
160
|
-
});
|
|
161
|
-
console.error(chalk.red(`
|
|
162
|
-
Error during MP3 conversion:`, err.message));
|
|
163
|
-
reject(err);
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
})
|
|
167
|
-
.on('error', (err) => {
|
|
168
|
-
bar.stop();
|
|
169
|
-
console.error(chalk.red(`
|
|
170
|
-
Error during audio stream download:`, err.message));
|
|
171
|
-
reject(err);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
143
|
} catch (error) {
|
|
176
|
-
if (error.message.includes('
|
|
177
|
-
console.error(chalk.red(
|
|
178
|
-
|
|
144
|
+
if (error.message.includes('Failed to run yt-dlp')) {
|
|
145
|
+
console.error(chalk.red(error.message));
|
|
146
|
+
console.error(chalk.yellow('Please ensure yt-dlp is installed and available in your system\'s PATH.'));
|
|
147
|
+
console.error(chalk.yellow('You can install it with `pip install yt-dlp` or from https://github.com/yt-dlp/yt-dlp/releases.'));
|
|
148
|
+
} else if (error.message.includes('Failed to parse video info')) {
|
|
149
|
+
console.error(chalk.red(error.message));
|
|
150
|
+
} else if (error.message.includes('This video is unavailable')) {
|
|
179
151
|
console.error(chalk.red('This video is unavailable. It may have been removed or is private.'));
|
|
180
|
-
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
181
154
|
console.error(chalk.red('An unexpected error occurred during the download process:'), error.message);
|
|
182
155
|
}
|
|
183
156
|
process.exit(1);
|
|
184
157
|
}
|
|
185
|
-
}
|
|
158
|
+
}
|
package/src/prompts.js
CHANGED
|
@@ -71,8 +71,8 @@ export async function getQuality(format) {
|
|
|
71
71
|
return answers.quality;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
export async function getOutputPath(
|
|
75
|
-
const defaultPath =
|
|
74
|
+
export async function getOutputPath() { // Removed defaultFilename parameter
|
|
75
|
+
const defaultPath = process.cwd(); // Default to current working directory
|
|
76
76
|
|
|
77
77
|
const questions = [
|
|
78
78
|
{
|
|
@@ -90,5 +90,5 @@ export async function getOutputPath(defaultFilename = 'download') {
|
|
|
90
90
|
},
|
|
91
91
|
];
|
|
92
92
|
const answers = await inquirer.prompt(questions);
|
|
93
|
-
return answers.outputPath;
|
|
93
|
+
return answers.outputPath; // Returns just the directory path
|
|
94
94
|
}
|