UNPKG

andrade-soulseek-downloader

Version:

Simple, safe Soulseek download library with built-in rate limiting to prevent bans

367 lines 17.8 kB
"use strict"; 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