andrade-soulseek-downloader
Version:
Simple, safe Soulseek download library with built-in rate limiting to prevent bans
367 lines • 17.8 kB
JavaScript
;
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.YouTubeDownloader = void 0;
const ytdl_core_1 = __importDefault(require("@distube/ytdl-core"));
const ytsr_1 = __importDefault(require("@distube/ytsr"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
const ffmpeg_1 = __importDefault(require("@ffmpeg-installer/ffmpeg"));
const inversify_1 = require("inversify");
const child_process_1 = require("child_process");
const util_1 = require("util");
const sleep = (0, util_1.promisify)(setTimeout);
let YouTubeDownloader = class YouTubeDownloader {
ytDlpPath = null;
maxRetries = 3;
retryDelay = 2000; // 2 seconds
constructor() {
// Set ffmpeg path
fluent_ffmpeg_1.default.setFfmpegPath(ffmpeg_1.default.path);
// Check if yt-dlp is available
this.checkYtDlp();
}
checkYtDlp() {
try {
(0, child_process_1.execSync)('which yt-dlp', { stdio: 'pipe' });
this.ytDlpPath = 'yt-dlp';
// Get version
try {
const version = (0, child_process_1.execSync)('yt-dlp --version', { encoding: 'utf-8' }).trim();
console.log(`[YouTube] ✓ yt-dlp found (v${version})`);
// Check if update is needed (basic check)
const versionDate = version.replace(/\./g, '');
const currentDate = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const monthsOld = (parseInt(currentDate.slice(0, 6)) - parseInt(versionDate.slice(0, 6)));
if (monthsOld > 2) {
console.log('[YouTube] 💡 Consider updating yt-dlp: pip install --upgrade yt-dlp');
}
}
catch {
console.log('[YouTube] ✓ yt-dlp found - will use for better reliability');
}
}
catch {
console.log('[YouTube] ⚠️ yt-dlp not found - falling back to ytdl-core (may have issues with 403 errors)');
console.log('[YouTube] 💡 Install yt-dlp for better results: https://github.com/yt-dlp/yt-dlp#installation');
}
}
calculateRelevanceScore(searchQuery, videoTitle, videoDuration, views) {
const queryWords = searchQuery.toLowerCase().split(' ');
const titleWords = videoTitle.toLowerCase();
let score = 0;
// Check for exact matches
if (titleWords.includes(searchQuery.toLowerCase())) {
score += 50;
}
// Check for individual word matches
queryWords.forEach(word => {
if (titleWords.includes(word)) {
score += 10;
}
});
// Prefer videos with reasonable duration (3-10 minutes)
const durationParts = videoDuration.split(':');
const totalSeconds = durationParts.reduce((acc, part, index) => {
return acc + parseInt(part) * Math.pow(60, durationParts.length - 1 - index);
}, 0);
if (totalSeconds >= 180 && totalSeconds <= 600) {
score += 20;
}
// Consider view count (logarithmic scale)
score += Math.log10(views + 1) * 2;
return score;
}
async searchYouTube(options) {
const searchQuery = `${options.artist} ${options.title} ${options.label || ''}`.trim();
try {
const searchResults = await (0, ytsr_1.default)(searchQuery, { limit: 10 });
const videos = searchResults.items
.filter((item) => item.type === 'video')
.map((video) => {
const viewsStr = typeof video.views === 'string'
? video.views.replace(/[^\d]/g, '')
: (video.views || '0').toString();
const viewCount = parseInt(viewsStr || '0');
const videoTitle = video.name || video.title || '';
return {
url: video.url,
title: videoTitle,
duration: video.duration || '0:00',
views: viewCount,
relevanceScore: this.calculateRelevanceScore(searchQuery, videoTitle, video.duration || '0:00', viewCount)
};
});
// Sort by relevance score
videos.sort((a, b) => b.relevanceScore - a.relevanceScore);
return videos;
}
catch (error) {
throw new Error(`Failed to search YouTube: ${error}`);
}
}
async convertToMp3(inputPath, outputPath) {
return new Promise((resolve, reject) => {
console.log('[YouTube] 🎵 Converting to MP3 (192kbps)...');
(0, fluent_ffmpeg_1.default)(inputPath)
.toFormat('mp3')
.audioBitrate('192k')
.on('progress', (progress) => {
process.stdout.write(`\r[YouTube] Converting: ${Math.round(progress.percent || 0)}%`);
})
.on('end', () => {
console.log('\n[YouTube] ✅ Conversion completed');
// Delete the temporary WebM file
fs_1.default.unlinkSync(inputPath);
resolve();
})
.on('error', (err) => {
console.error('[YouTube] ❌ Conversion error:', err);
reject(err);
})
.save(outputPath);
});
}
async downloadWithYtDlp(url, outputPath) {
return new Promise((resolve, reject) => {
const args = [
'--format', 'bestaudio[ext=m4a]/bestaudio/best',
'--extract-audio',
'--audio-format', 'mp3',
'--audio-quality', '192K',
'--output', outputPath.replace('.mp3', '.%(ext)s'),
'--no-playlist',
'--no-warnings',
'--progress',
'--newline',
'--retries', '5',
'--fragment-retries', '5',
'--no-check-certificate',
// Don't specify player_client - let yt-dlp use its default smart selection
// The default tvhtml5 client works better than android/ios which require PO tokens
];
// Add cookies file if provided via environment variable
const cookiesFile = process.env.YOUTUBE_COOKIES_FILE;
if (cookiesFile && fs_1.default.existsSync(cookiesFile)) {
args.push('--cookies', cookiesFile);
console.log('[YouTube] 🍪 Using cookies file for authentication');
}
args.push(url);
console.log('[YouTube] 🚀 Downloading with yt-dlp...');
const ytdlp = (0, child_process_1.spawn)(this.ytDlpPath, args);
let lastProgress = '';
ytdlp.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output.includes('[download]')) {
const match = output.match(/(\d+\.?\d*)%/);
if (match) {
const progress = match[1];
if (progress !== lastProgress) {
process.stdout.write(`\r[YouTube] ⬇️ Progress: ${progress}%`);
lastProgress = progress;
}
}
}
});
ytdlp.stderr.on('data', (data) => {
const error = data.toString();
if (!error.includes('WARNING')) {
console.error(`[YouTube] ⚠️ ${error.trim()}`);
}
});
ytdlp.on('close', (code) => {
console.log(''); // New line after progress
if (code === 0) {
console.log('[YouTube] ✅ Download completed');
resolve();
}
else {
reject(new Error(`yt-dlp exited with code ${code}`));
}
});
ytdlp.on('error', (error) => {
reject(new Error(`Failed to spawn yt-dlp: ${error.message}`));
});
});
}
async downloadVideo(url, outputPath) {
return new Promise(async (resolve, reject) => {
try {
// Get video info first
const info = await ytdl_core_1.default.getInfo(url);
console.log('[YouTube] 🎦 Video title:', info.videoDetails.title);
const lengthSeconds = parseInt(info.videoDetails.lengthSeconds);
console.log('[YouTube] ⏱️ Duration:', Math.floor(lengthSeconds / 60), 'minutes', lengthSeconds % 60, 'seconds');
// Get the best audio format
const audioFormats = ytdl_core_1.default.filterFormats(info.formats, 'audioonly');
if (audioFormats.length === 0) {
throw new Error('No audio formats found');
}
console.log(`[YouTube] 🎧 Found ${audioFormats.length} audio formats`);
const bestAudio = audioFormats[0];
console.log('[YouTube] 🎶 Using format:', bestAudio.mimeType, '| Audio bitrate:', bestAudio.audioBitrate || 'unknown');
// Download with the specific format
const stream = (0, ytdl_core_1.default)(url, {
format: bestAudio,
quality: 'highestaudio'
});
const writeStream = fs_1.default.createWriteStream(outputPath);
let downloadedBytes = 0;
const totalBytes = parseInt(bestAudio.contentLength || '0');
stream.on('progress', (chunkLength, downloaded, total) => {
downloadedBytes = downloaded;
const percent = ((downloaded / total) * 100).toFixed(1);
const downloadedMB = (downloaded / 1024 / 1024).toFixed(2);
const totalMB = (total / 1024 / 1024).toFixed(2);
process.stdout.write(`\r[YouTube] ⬇️ Downloading: ${percent}% (${downloadedMB} MB / ${totalMB} MB)`);
});
stream.on('error', (error) => {
console.error('\n[YouTube] ❌ Stream error:', error.message);
reject(error);
});
stream.on('end', () => {
console.log('\n[YouTube] 🏁 Download stream completed');
});
writeStream.on('error', (error) => {
console.error('[YouTube] ❌ Write error:', error.message);
reject(error);
});
writeStream.on('finish', () => {
console.log('[YouTube] 💾 File write completed');
resolve();
});
stream.pipe(writeStream);
}
catch (error) {
console.error('[YouTube] ❌ Setup error:', error);
reject(error);
}
});
}
async downloadWithRetry(url, outputPath) {
let lastError = null;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
if (attempt > 1) {
console.log(`[YouTube] 🔄 Retry attempt ${attempt}/${this.maxRetries}...`);
await sleep(this.retryDelay * attempt); // Exponential backoff
}
// Use yt-dlp if available, otherwise fall back to ytdl-core
if (this.ytDlpPath) {
await this.downloadWithYtDlp(url, outputPath);
return; // Success
}
else {
// For ytdl-core, we need to handle the temp file and conversion
const tempPath = outputPath.replace('.mp3', '_temp.webm');
await this.downloadVideo(url, tempPath);
await this.convertToMp3(tempPath, outputPath);
return; // Success
}
}
catch (error) {
lastError = error;
console.error(`[YouTube] ❌ Attempt ${attempt} failed: ${error.message}`);
// Don't retry on certain errors
if (error.message?.includes('410') ||
error.message?.includes('Video unavailable') ||
error.message?.includes('Private video')) {
throw error; // Don't retry these
}
if (attempt === this.maxRetries) {
throw new Error(`Failed after ${this.maxRetries} attempts: ${lastError?.message}`);
}
}
}
throw lastError || new Error('Download failed');
}
async download(options) {
const downloadDir = options.destinationDir || process.env.DOWNLOAD_DIR || './downloads';
// Ensure download directory exists
if (!fs_1.default.existsSync(downloadDir)) {
fs_1.default.mkdirSync(downloadDir, { recursive: true });
}
try {
// Search for videos
console.log(`[YouTube] 🔍 Searching for: "${options.artist} - ${options.title}"`);
const searchResults = await this.searchYouTube(options);
if (searchResults.length === 0) {
console.error('[YouTube] ❌ No search results found');
return null;
}
// Try multiple search results if the first ones fail
let lastError = null;
const maxResultsToTry = Math.min(3, searchResults.length);
for (let i = 0; i < maxResultsToTry; i++) {
const result = searchResults[i];
if (i === 0) {
console.log(`[YouTube] ✨ Found ${searchResults.length} results`);
console.log(`[YouTube] 🏆 Best match: "${result.title}"`);
}
else {
console.log(`[YouTube] 🔄 Trying alternative result #${i + 1}: "${result.title}"`);
}
console.log(`[YouTube] 🔗 URL: ${result.url}`);
console.log(`[YouTube] 📊 Relevance score: ${result.relevanceScore.toFixed(1)} | Duration: ${result.duration} | Views: ${result.views.toLocaleString()}`);
// Generate filenames with _youtube suffix
const baseFilename = options.customFileName || `${options.artist} - ${options.title}`;
const finalFilename = `${baseFilename}_youtube.mp3`.replace(/[<>:"/\\|?*]/g, '_');
const finalPath = path_1.default.join(downloadDir, finalFilename);
// Check if file already exists
if (fs_1.default.existsSync(finalPath)) {
console.log(`[YouTube] 📁 File already exists: ${finalPath}`);
return finalPath;
}
try {
// Download with retry logic
console.log('[YouTube] 🎯 Starting download process...');
await this.downloadWithRetry(result.url, finalPath);
console.log(`[YouTube] ✅ Downloaded successfully!`);
console.log(`[YouTube] 📍 Saved to: ${finalPath}`);
return finalPath;
}
catch (error) {
lastError = error;
console.error(`[YouTube] ❌ Failed to download from this result: ${error.message}`);
// Clean up partial files
if (fs_1.default.existsSync(finalPath)) {
fs_1.default.unlinkSync(finalPath);
}
// Continue to next result if available
if (i < maxResultsToTry - 1) {
continue;
}
}
}
// All attempts failed
console.error(`[YouTube] ❌ All download attempts failed`);
if (lastError?.message?.includes('410') || lastError?.message?.includes('403')) {
console.error('[YouTube] 🚨 Video may be age-restricted, private, or region-blocked');
console.error('[YouTube] 💡 Consider installing yt-dlp for better success rate: pip install yt-dlp');
}
return null;
}
catch (error) {
console.error(`[YouTube] ❌ Download failed: ${error.message || error}`);
return null;
}
}
};
exports.YouTubeDownloader = YouTubeDownloader;
exports.YouTubeDownloader = YouTubeDownloader = __decorate([
(0, inversify_1.injectable)(),
__metadata("design:paramtypes", [])
], YouTubeDownloader);
//# sourceMappingURL=youtube-downloader.js.map