UNPKG

andrade-soulseek-downloader

Version:

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

867 lines 48.7 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SoulseekDownloader = void 0; const dotenv = __importStar(require("dotenv")); const slsk_client_1 = __importDefault(require("../slsk-client")); const string_similarity_js_1 = require("string-similarity-js"); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const utils_1 = require("../utils"); // Conditionally import YouTube downloader only in non-test environments let YouTubeDownloader; if (process.env.NODE_ENV !== 'test') { try { YouTubeDownloader = require('../infrastructure/services/youtube-downloader').YouTubeDownloader; } catch (error) { // YouTube downloader not available console.warn('⚠️ YouTube downloader module not loaded'); console.warn(` Reason: ${error.message || error}`); if (error.code === 'MODULE_NOT_FOUND') { console.warn(' 💡 Fix: Move inversify and reflect-metadata to dependencies in package.json'); } } } dotenv.config(); /** * Main downloader class for Soulseek operations. * Handles connection, search, and download with rate limiting. */ class SoulseekDownloader { client; isConnected = false; downloadConfig; failedUsers = new Set(); logger; rateLimiter; youtubeDownloader; static activeInstance = null; /** * @param config - Download configuration options */ constructor(config) { // Warn if multiple instances are created if (SoulseekDownloader.activeInstance) { console.warn('⚠️ Warning: Multiple SoulseekDownloader instances detected!'); console.warn('⚠️ This may lead to rate limit violations and potential bans.'); console.warn('⚠️ Consider using a single instance or implementing a queue system.'); } SoulseekDownloader.activeInstance = this; this.logger = new utils_1.Logger(); this.validateEnvironment(); this.downloadConfig = { maxAttempts: config?.maxAttempts || 10, searchTimeout: config?.searchTimeout || parseInt(process.env.SOULSEEK_SEARCH_TIMEOUT || '30000'), downloadTimeout: config?.downloadTimeout || 120000, preferSlotsAvailable: config?.preferSlotsAvailable ?? true, minSpeed: config?.minSpeed || 0, minQualityBitrate: config?.minQualityBitrate || parseInt(process.env.SOULSEEK_MIN_QUALITY_BITRATE || '128'), maxQualityBitrate: config?.maxQualityBitrate || (process.env.SOULSEEK_MAX_QUALITY_BITRATE ? parseInt(process.env.SOULSEEK_MAX_QUALITY_BITRATE) : undefined), concurrentDownloads: config?.concurrentDownloads || 3, enableDownloadRacing: config?.enableDownloadRacing !== false, searchDelay: config?.searchDelay, downloadDelay: config?.downloadDelay, maxConcurrent: config?.maxConcurrent, cooldownAfterError: config?.cooldownAfterError }; // Initialize rate limiter with config this.rateLimiter = utils_1.RateLimiter.getInstance(this.downloadConfig); // Initialize YouTube downloader if available if (YouTubeDownloader) { this.youtubeDownloader = new YouTubeDownloader(); } // Show rate limit info this.logger.info('Rate limiting enabled to prevent Soulseek bans'); this.logger.debug(`Search delay: ${config?.searchDelay || 5000}ms, Download delay: ${config?.downloadDelay || 3000}ms`); } /** Validates required environment variables */ validateEnvironment() { const requiredEnvVars = [ 'SOULSEEK_USER', 'SOULSEEK_PASSWORD', 'SOULSEEK_SHARED_MUSIC_DIR', 'SOULSEEK_DOWNLOAD_DIR' ]; const missing = requiredEnvVars.filter(varName => !process.env[varName]); if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(', ')}`); } // Create download directory if it doesn't exist const downloadDir = process.env.SOULSEEK_DOWNLOAD_DIR; if (!fs_1.default.existsSync(downloadDir)) { fs_1.default.mkdirSync(downloadDir, { recursive: true }); } } /** Establishes connection to Soulseek network */ async connect() { if (this.isConnected) { this.logger.debug('Already connected to Soulseek network'); return; } this.logger.info('🌐 Initiating Soulseek network connection...'); this.logger.debug(`Connecting with user: ${process.env.SOULSEEK_USER}`); this.logger.debug(`Shared folder: ${process.env.SOULSEEK_SHARED_MUSIC_DIR}`); this.logger.startSpinner('Connecting to Soulseek network...'); return new Promise((resolve, reject) => { const connectionTimeout = setTimeout(() => { this.logger.failSpinner('Connection timeout (30s)'); reject(new Error('Connection to Soulseek timed out after 30 seconds')); }, 30000); slsk_client_1.default.connect({ user: process.env.SOULSEEK_USER, pass: process.env.SOULSEEK_PASSWORD, sharedFolders: [process.env.SOULSEEK_SHARED_MUSIC_DIR] }, (err, client) => { clearTimeout(connectionTimeout); if (err) { this.logger.failSpinner('Failed to connect to Soulseek'); this.logger.error(`Connection error: ${err.message}`); reject(err); } else { this.client = client; this.isConnected = true; this.logger.succeedSpinner(`Connected as ${process.env.SOULSEEK_USER}`); this.logger.info('✅ Successfully connected to Soulseek network'); this.logger.debug('Connection established, ready to search and download'); resolve(); } }); }); } /** Disconnects from Soulseek and cleans up */ async disconnect() { // Wait for all queued operations to complete if (this.rateLimiter.getQueueSize() > 0 || this.rateLimiter.getPendingCount() > 0) { this.logger.info('Waiting for queued operations to complete...'); await this.rateLimiter.onIdle(); } if (this.client && this.isConnected) { this.client.destroy(); this.isConnected = false; this.logger.info('Disconnected from Soulseek'); } // Clear the active instance if (SoulseekDownloader.activeInstance === this) { SoulseekDownloader.activeInstance = null; } } sanitizeQuery(artist, title) { return `${artist} ${title}`.replace(/[^\p{L}\p{N} ]/gu, ''); } generateFileName(artist, title) { const sanitizedArtist = artist.replace(/[^\p{L}\p{N} ]/gu, ''); const sanitizedTitle = title.replace(/[^\p{L}\p{N} ]/gu, ''); return `${sanitizedArtist} - ${sanitizedTitle}`.replace(/\s+/g, '_'); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } formatSpeed(speed) { if (speed < 1000) return `${speed} B/s`; if (speed < 1000000) return `${(speed / 1000).toFixed(1)} KB/s`; return `${(speed / 1000000).toFixed(1)} MB/s`; } matchesArtistAndTitle(filename, artist, title, strict = true) { const lowerFilename = filename.toLowerCase(); const lowerArtist = artist.toLowerCase(); const lowerTitle = title.toLowerCase(); if (strict) { return lowerFilename.includes(lowerArtist) && lowerFilename.includes(lowerTitle); } else { // Relaxed matching: just check if title is present return lowerFilename.includes(lowerTitle) || (lowerFilename.includes(lowerArtist) && (0, string_similarity_js_1.stringSimilarity)(lowerFilename, `${lowerArtist} ${lowerTitle}`) > 0.3); } } /** * Searches for tracks on Soulseek network. * @param options - Search configuration * @returns Array of search results sorted by quality */ async search(options) { // Use rate limiter to queue and delay search return this.rateLimiter.executeSearch(async () => { if (!this.isConnected) { await this.connect(); } const query = this.sanitizeQuery(options.artist, options.title); const minBitrate = options.minBitrate || parseInt(process.env.SOULSEEK_MIN_QUALITY_BITRATE || '320'); const maxBitrate = options.maxBitrate; const timeout = options.timeout || this.downloadConfig.searchTimeout; const maxResults = options.maxResults || 100; const qualityRange = maxBitrate ? `${minBitrate}-${maxBitrate} kbps` : `${minBitrate}+ kbps`; this.logger.info(`🔍 Starting search for: "${options.artist}" - "${options.title}"`); this.logger.debug(`Search parameters:`); this.logger.debug(` • Query: "${query}"`); this.logger.debug(` • Quality range: ${qualityRange}`); this.logger.debug(` • Timeout: ${timeout}ms (${(timeout / 1000).toFixed(1)}s)`); this.logger.debug(` • Max results: ${maxResults}`); this.logger.startCountdownSpinner(`Searching for "${query}" (${qualityRange})`, timeout); return new Promise((resolve, reject) => { this.client.search({ req: query.toLowerCase(), timeout: timeout }, (err, results) => { if (err) { this.logger.failCountdownSpinner('Search failed'); this.logger.error(`Search error: ${err.message}`); reject(err); return; } this.logger.succeedCountdownSpinner(`Found ${results.length} results`); this.logger.info(`📊 Search completed: ${results.length} total results found`); // Display ALL raw results before filtering (limited display) if (results.length > 0) { this.logger.section('🔍 Raw Search Results (before filtering)'); this.logger.displaySearchResults(results.slice(0, 50), 15); // Show top 15 of first 50 raw results } // Filter by bitrate range this.logger.debug('Filtering results by quality...'); let filteredResults = results.filter(result => { const meetsMinimum = result.bitrate >= minBitrate; const meetsMaximum = !maxBitrate || result.bitrate <= maxBitrate; return meetsMinimum && meetsMaximum; }); this.logger.debug(` • After bitrate filter: ${filteredResults.length} results (${qualityRange})`); // Filter out users that have previously failed const beforeFailedFilter = filteredResults.length; filteredResults = filteredResults.filter(result => !this.failedUsers.has(result.user)); if (beforeFailedFilter > filteredResults.length) { this.logger.debug(` • Excluded ${beforeFailedFilter - filteredResults.length} results from previously failed users`); } // Filter by minimum speed if configured if (this.downloadConfig.minSpeed && this.downloadConfig.minSpeed > 0) { const beforeSpeedFilter = filteredResults.length; filteredResults = filteredResults.filter(result => result.speed >= this.downloadConfig.minSpeed); this.logger.debug(` • After speed filter: ${filteredResults.length} results (min ${this.formatSpeed(this.downloadConfig.minSpeed)})`); } // Filter by filename matching const strictMatching = options.strictMatching !== false; const beforeMatchingFilter = filteredResults.length; filteredResults = filteredResults.filter(result => { const filename = path_1.default.basename(result.file); const filenameWithoutExt = filename.split('.').slice(0, -1).join('.'); return this.matchesArtistAndTitle(filenameWithoutExt, options.artist, options.title, strictMatching); }); this.logger.debug(` • After filename matching: ${filteredResults.length} results (${strictMatching ? 'strict' : 'relaxed'} mode)`); // Calculate matching scores and quality scores filteredResults = filteredResults.map(result => { const filename = path_1.default.basename(result.file); const filenameWithoutExt = filename.split('.').slice(0, -1).join('.'); // Text matching score (0-1) result.discoseekMatchingScore = (0, string_similarity_js_1.stringSimilarity)(query.toLowerCase(), filenameWithoutExt.toLowerCase()); return result; }); // Smart quality-first sorting algorithm with speed prioritization filteredResults.sort((a, b) => { // Calculate quality score for each file (0-100) const getQualityScore = (file) => { let score = 0; // Bitrate is the most important factor (up to 40 points) // Normalize bitrate score: 320kbps = 40 points, 128kbps = 16 points const bitrateScore = Math.min(40, (file.bitrate / 320) * 40); score += bitrateScore; // User speed is now second most important (up to 25 points) // Normalize speed: >10MB/s = 25 points, 5MB/s = 12.5 points, 1MB/s = 2.5 points const speedScore = Math.min(25, (file.speed / 10000000) * 25); score += speedScore; // Slot availability is still critical (up to 25 points) // More slots = better availability if (file.slots) { const slotCount = typeof file.slots === 'number' ? file.slots : 1; // Give more points for more slots (1 slot = 15 points, 5+ slots = 25 points) const slotScore = Math.min(25, 15 + (slotCount * 2)); score += slotScore; } // File name match accuracy (up to 15 points) const matchScore = (file.discoseekMatchingScore || 0) * 15; score += matchScore; return score; }; const scoreA = getQualityScore(a); const scoreB = getQualityScore(b); // Sort by quality score (highest first) return scoreB - scoreA; }); // Log quality distribution if (filteredResults.length > 0) { const bitrateGroups = { '320': filteredResults.filter(r => r.bitrate >= 320).length, '256': filteredResults.filter(r => r.bitrate >= 256 && r.bitrate < 320).length, '192': filteredResults.filter(r => r.bitrate >= 192 && r.bitrate < 256).length, '128': filteredResults.filter(r => r.bitrate >= 128 && r.bitrate < 192).length }; this.logger.debug(`Quality distribution: 320kbps(${bitrateGroups['320']}), 256kbps(${bitrateGroups['256']}), 192kbps(${bitrateGroups['192']}), 128kbps(${bitrateGroups['128']})`); } this.logger.info(`Filtered: ${filteredResults.length} high-quality results`); if (this.downloadConfig.preferSlotsAvailable) { const withSlots = filteredResults.filter(r => r.slots).length; this.logger.debug(`With available slots: ${withSlots}`); } // Display all filtered results sorted if (filteredResults.length > 0) { this.logger.section('✨ Filtered & Sorted Results (after quality filtering)'); this.logger.displaySearchResults(filteredResults, 25); } else { this.logger.warning('⚠️ No results match your quality criteria'); } resolve(filteredResults.slice(0, maxResults)); }); }); }); } /** * Downloads a file from Soulseek peer. * @param result - Search result to download * @param artist - Artist name for filename * @param title - Track title for filename * @param folderName - Optional subfolder name within SOULSEEK_DOWNLOAD_DIR * @param customFileName - Optional custom filename (without extension) * @param abortSignal - Optional abort signal to cancel download * @param isRacing - Whether this is part of a race (uses temp filename) * @returns Object with download path and timeout status */ async download(result, artist, title, folderName, customFileName, abortSignal, isRacing = false) { // If racing, bypass rate limiter (the race itself is rate limited) const downloadFunc = async () => { if (!this.isConnected) { await this.connect(); } // Determine the download directory let downloadDir = process.env.SOULSEEK_DOWNLOAD_DIR; if (folderName) { downloadDir = path_1.default.join(downloadDir, folderName); // Create folder if it doesn't exist if (!fs_1.default.existsSync(downloadDir)) { fs_1.default.mkdirSync(downloadDir, { recursive: true }); this.logger.debug(`Created folder: ${downloadDir}`); } } // Determine the filename const fileName = customFileName || this.generateFileName(artist, title); const ext = path_1.default.extname(result.file); const finalPath = path_1.default.join(downloadDir, `${fileName}${ext}`); // Use temporary filename if racing to avoid conflicts const downloadPath = isRacing ? path_1.default.join(downloadDir, `.tmp_${Date.now()}_${result.user.replace(/[^a-zA-Z0-9]/g, '')}_${fileName}${ext}`) : finalPath; // Check if final file already exists (skip even in racing mode) if (fs_1.default.existsSync(finalPath)) { this.logger.info(`📁 File already exists, skipping download: ${finalPath}`); return { path: finalPath, timeout: false }; } this.logger.info(`📥 Starting download from user: ${result.user}`); this.logger.debug(`Download details:`); this.logger.debug(` • File: ${path_1.default.basename(result.file)}`); this.logger.debug(` • Size: ${this.logger.formatSize(result.size)}`); this.logger.debug(` • Bitrate: ${result.bitrate} kbps`); this.logger.debug(` • User speed: ${this.formatSpeed(result.speed)}`); this.logger.debug(` • Slots available: ${result.slots ? 'Yes' : 'No'}`); this.logger.debug(` • Destination: ${downloadPath}`); const startTime = Date.now(); let progressStarted = false; // Only show progress bar if not racing (racing shows simpler status) if (!isRacing) { this.logger.info(`⏳ Waiting for download to start...`); this.logger.startProgressBar(result.size, 0); } else { this.logger.debug(`🏃 Racing download from ${result.user}...`); } return new Promise((resolve) => { let isTimeout = false; let isCancelled = false; const timeout = setTimeout(() => { if (!isCancelled) { isTimeout = true; if (!isRacing) { this.logger.stopProgressBar(); } this.logger.warning(`⏱️ Download timeout after ${this.downloadConfig.downloadTimeout / 1000}s for user ${result.user}`); this.failedUsers.add(result.user); resolve({ path: null, timeout: true, cancelled: false }); } }, this.downloadConfig.downloadTimeout); // Handle abort signal if provided if (abortSignal) { abortSignal.addEventListener('abort', () => { isCancelled = true; clearTimeout(timeout); this.logger.debug(`Download cancelled for user ${result.user}`); // Clean up temp file if it exists if (isRacing && fs_1.default.existsSync(downloadPath)) { try { fs_1.default.unlinkSync(downloadPath); this.logger.debug(`Cleaned up cancelled download temp file`); } catch (err) { // Ignore cleanup errors } } // Note: We can't actually cancel the underlying download, but we can ignore the result resolve({ path: null, timeout: false, cancelled: true }); }); } const progressEmitter = this.client.download({ file: result, path: downloadPath }, (err) => { clearTimeout(timeout); if (!isRacing) { this.logger.stopProgressBar(); } if (err) { this.logger.error(`❌ Download failed on soulseek: ${err.message}`); this.logger.debug(`Adding user ${result.user} to failed users list`); this.failedUsers.add(result.user); // Clean up partial download if (fs_1.default.existsSync(downloadPath)) { this.logger.debug('Cleaning up partial download file'); fs_1.default.unlinkSync(downloadPath); } resolve({ path: null, timeout: isTimeout, cancelled: isCancelled }); } else if (!isCancelled) { // If racing, rename temp file to final name if (isRacing && downloadPath !== finalPath) { try { // Check again if final file was created by another racer if (fs_1.default.existsSync(finalPath)) { // Another racer won, delete our temp file fs_1.default.unlinkSync(downloadPath); this.logger.debug(`Another racer already saved the file, cleaning up temp file`); resolve({ path: finalPath, timeout: false, cancelled: false }); return; } // We won, rename temp to final fs_1.default.renameSync(downloadPath, finalPath); this.logger.debug(`Renamed temp file to final: ${finalPath}`); } catch (err) { this.logger.error(`Failed to rename temp file: ${err}`); // Clean up temp file on error if (fs_1.default.existsSync(downloadPath)) { fs_1.default.unlinkSync(downloadPath); } resolve({ path: null, timeout: false, cancelled: false }); return; } } const elapsed = (Date.now() - startTime) / 1000; const speed = result.size / elapsed; this.logger.success(`✅ Downloaded ${this.logger.formatSize(result.size)} in ${elapsed.toFixed(1)}s (${this.logger.formatSpeed(speed)})`); this.logger.info(`📍 File saved to: ${isRacing ? finalPath : downloadPath}`); resolve({ path: isRacing ? finalPath : downloadPath, timeout: false, cancelled: false }); } }); // Listen to progress events progressEmitter.on('start', (data) => { progressStarted = true; this.logger.info(`📊 Download started, tracking progress...`); this.logger.debug(`File: ${data.file}, Size: ${data.size}`); }); progressEmitter.on('progress', (data) => { if (!progressStarted) { progressStarted = true; this.logger.info(`📊 Download progress started`); } const elapsed = Math.max(1, (Date.now() - startTime) / 1000); const speed = data.received / elapsed; // Only update progress bar if not racing if (!isRacing) { // Debug log every 10% or so if (Math.floor(data.percentage / 10) !== Math.floor((data.received - 1000) / data.total * 10)) { this.logger.debug(`Progress: ${data.percentage.toFixed(1)}% (${this.logger.formatSize(data.received)}/${this.logger.formatSize(data.total)})`); } this.logger.updateProgressBar(data.received, { speed: this.formatSpeed(speed), percentage: data.percentage.toFixed(1) }); } else { // For racing, update the racing progress bar this.logger.updateRacingBar(result.user, data.received, this.formatSpeed(speed)); } }); progressEmitter.on('error', (err) => { this.logger.error(`Download error: ${err.message}`); }); // Log if we get any emitter (for debugging) this.logger.debug(`Progress emitter created, waiting for events...`); }); }; // Apply rate limiting only if not racing if (isRacing) { return downloadFunc(); } else { return this.rateLimiter.executeDownload(downloadFunc); } } /** * Races multiple downloads in parallel and returns the first successful one. * @param candidates - Array of search results to try downloading * @param artist - Artist name * @param title - Track title * @param folderName - Optional subfolder name * @param customFileName - Optional custom filename * @returns Path to downloaded file or null if all failed */ async raceDownloads(candidates, artist, title, folderName, customFileName) { if (candidates.length === 0) { return { path: null, allFailed: true }; } // Apply rate limiting to the entire race as one operation return this.rateLimiter.executeDownload(async () => { const abortController = new AbortController(); // Start multi-progress bars for racing this.logger.startRacingBars(candidates.map(c => ({ user: c.user, size: c.size }))); const racers = candidates.map((result, index) => this.download(result, artist, title, folderName, customFileName, abortController.signal, true) // isRacing = true .then(downloadResult => ({ ...downloadResult, result, index }))); this.logger.info(`🏁 Racing ${candidates.length} downloads in parallel...`); // Keep track of completed downloads let completedCount = 0; let successfulPath = null; // Create a promise that resolves when we have a successful download return new Promise((resolve) => { racers.forEach(racer => { racer.then(result => { completedCount++; // If we found a successful download, cancel all others and resolve if (result.path && !successfulPath) { successfulPath = result.path; abortController.abort(); // Cancel all other downloads // Mark winner and losers in progress bars this.logger.markRacingWinner(result.result.user); candidates.forEach((c, i) => { if (i !== result.index) { this.logger.markRacingLoser(c.user, 'Cancelled'); } }); this.logger.success(`🏆 Download race won by user ${result.result.user} (racer #${result.index + 1})`); // Small delay for cleanup and visual effect setTimeout(() => { this.logger.stopRacingBars(); resolve({ path: successfulPath, allFailed: false }); }, 1500); } // If all downloads completed without success else if (completedCount === candidates.length && !successfulPath) { this.logger.debug('All race participants failed'); this.logger.stopRacingBars(); resolve({ path: null, allFailed: true }); } }).catch(err => { completedCount++; // Mark this racer as failed const failedResult = candidates[racers.indexOf(racer)]; if (failedResult) { this.logger.markRacingLoser(failedResult.user, 'Failed'); } // If all downloads failed if (completedCount === candidates.length && !successfulPath) { this.logger.stopRacingBars(); resolve({ path: null, allFailed: true }); } }); }); }); }); } /** * Performs search and download with quality-first strategy. * @param artist - Artist name * @param title - Track title * @param folderName - Optional subfolder name within SOULSEEK_DOWNLOAD_DIR * @param customFileName - Optional custom filename (without extension) * @returns Path to downloaded file or null if failed */ async searchAndDownload(artist, title, folderName, customFileName) { try { // Check if file already exists before doing anything let downloadDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads'; if (folderName) { downloadDir = path_1.default.join(downloadDir, folderName); } const fileName = customFileName || this.generateFileName(artist, title); // Check for common audio file extensions const extensions = ['.mp3', '.flac', '.m4a', '.wav', '.ogg', '.aac', '.opus']; for (const ext of extensions) { const filePath = path_1.default.join(downloadDir, `${fileName}${ext}`); if (fs_1.default.existsSync(filePath)) { this.logger.success(`✅ File already exists, skipping download: ${filePath}`); return filePath; } } let attemptCount = 0; // File doesn't exist, proceed with search this.logger.info('🔍 Searching for best quality version...'); const searchOptions = { artist, title, minBitrate: this.downloadConfig.minQualityBitrate || 128, maxBitrate: this.downloadConfig.maxQualityBitrate, timeout: this.downloadConfig.searchTimeout, // Use configured search timeout maxResults: 200, // Get more results to find best quality strictMatching: true }; let results = await this.search(searchOptions); // If no results with strict matching, try relaxed if (results.length === 0) { this.logger.warning('⚠️ No exact matches found, trying relaxed search...'); searchOptions.strictMatching = false; results = await this.search(searchOptions); if (results.length === 0) { this.logger.error('❌ No results found on Soulseek network'); if (this.youtubeDownloader) { this.logger.section('🎥 YouTube Fallback'); this.logger.info('📺 Attempting to download from YouTube as fallback...'); this.logger.info(`🔍 Searching YouTube for: "${artist} - ${title}"`); // Determine YouTube download directory let youtubeDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads'; if (folderName) { youtubeDir = path_1.default.join(youtubeDir, folderName); if (!fs_1.default.existsSync(youtubeDir)) { fs_1.default.mkdirSync(youtubeDir, { recursive: true }); } } const youtubeResult = await this.youtubeDownloader.download({ artist, title, destinationDir: youtubeDir, customFileName }); if (youtubeResult) { this.logger.success(`✅ Successfully downloaded from YouTube!`); this.logger.info(`📍 File saved to: ${youtubeResult}`); return youtubeResult; } this.logger.error('❌ Download failed from both Soulseek and YouTube'); } else { this.logger.error('❌ No YouTube fallback available (module not loaded)'); this.logger.info('💡 Tip: Check that inversify and reflect-metadata are installed'); } return null; } } // Group results by bitrate for progressive attempts const resultsByBitrate = {}; results.forEach(result => { if (!resultsByBitrate[result.bitrate]) { resultsByBitrate[result.bitrate] = []; } resultsByBitrate[result.bitrate].push(result); }); // Get sorted bitrates (highest first) const availableBitrates = Object.keys(resultsByBitrate) .map(b => parseInt(b)) .sort((a, b) => b - a); // Create detailed breakdown of available files const bitrateBreakdown = availableBitrates.map(bitrate => { const count = resultsByBitrate[bitrate].length; const withSlots = resultsByBitrate[bitrate].filter(r => r.slots).length; const percentage = count > 0 ? Math.round((withSlots / count) * 100) : 0; return `${bitrate}kbps: ${withSlots}/${count} with slots (${percentage}%)`; }).join(', '); this.logger.success(`📊 Found files with bitrates: ${availableBitrates.join(', ')} kbps`); this.logger.info(`📋 Available files: ${bitrateBreakdown}`); this.logger.info(`🎯 Will try to download best quality first (${availableBitrates[0]} kbps)`); // Try each bitrate level, starting from highest for (const bitrate of availableBitrates) { const bitrateResults = resultsByBitrate[bitrate]; // Filter out users with no slots available (they'll likely timeout) const availableResults = bitrateResults.filter(r => r.slots); if (availableResults.length === 0) { this.logger.warning(`⚠️ No users with available slots at ${bitrate}kbps (${bitrateResults.length} files found but all slots occupied), skipping...`); continue; // Skip to next bitrate } if (bitrate !== availableBitrates[0]) { this.logger.section(`📉 Trying ${bitrate} kbps files (${availableResults.length} with available slots)`); } else { this.logger.info(`📂 Starting with ${availableResults.length} files at ${bitrate}kbps (all have available slots)`); } // Sort by number of slots first, then by speed // Priority: More slots = better availability, then faster speed const orderedResults = availableResults.sort((a, b) => { // First sort by number of slots (more slots first) // Note: slots can be boolean (true/false) or number const slotsA = typeof a.slots === 'number' ? a.slots : (a.slots ? 1 : 0); const slotsB = typeof b.slots === 'number' ? b.slots : (b.slots ? 1 : 0); if (slotsA !== slotsB) { return slotsB - slotsA; // More slots first } // If same number of slots, sort by speed return b.speed - a.speed; // Faster speed first }); // Try all available users at this bitrate level // But limit total attempts across all bitrates to avoid excessive retries const maxAttemptsPerBitrate = orderedResults.length; const maxAttemptsThisBitrate = Math.min(maxAttemptsPerBitrate, 20); // Cap at 20 attempts per bitrate to be reasonable // Use racing if enabled if (this.downloadConfig.enableDownloadRacing && orderedResults.length > 1) { const raceSize = Math.min(this.downloadConfig.concurrentDownloads || 3, maxAttemptsThisBitrate); for (let i = 0; i < maxAttemptsThisBitrate; i += raceSize) { const raceCandidates = orderedResults.slice(i, Math.min(i + raceSize, maxAttemptsThisBitrate)); attemptCount += raceCandidates.length; this.logger.section(`🏃 Racing ${raceCandidates.length} downloads at ${bitrate}kbps`); raceCandidates.forEach((result, idx) => { this.logger.searchResult({ user: result.user, file: result.file, size: result.size, bitrate: result.bitrate, slots: result.slots, speed: result.speed, score: result.discoseekMatchingScore }, i + idx + 1, bitrateResults.length); }); const raceResult = await this.raceDownloads(raceCandidates, artist, title, folderName, customFileName); if (raceResult.path) { this.logger.success(`🎉 Downloaded ${bitrate}kbps version successfully!`); this.logger.info(`✅ Got best available quality after ${attemptCount} attempts`); return raceResult.path; } if (i + raceSize < maxAttemptsThisBitrate) { this.logger.info(`⏱️ Race failed. Trying next batch of users at ${bitrate}kbps...`); await this.delay(1000); // Small delay between race batches } } } else { // Sequential download (original behavior) for (let i = 0; i < maxAttemptsThisBitrate; i++) { attemptCount++; const result = orderedResults[i]; this.logger.searchResult({ user: result.user, file: result.file, size: result.size, bitrate: result.bitrate, slots: result.slots, speed: result.speed, score: result.discoseekMatchingScore }, attemptCount, bitrateResults.length); const downloadResult = await this.download(result, artist, title, folderName, customFileName); if (downloadResult.path) { this.logger.success(`🎉 Downloaded ${bitrate}kbps version successfully!`); this.logger.info(`✅ Got best available quality after ${attemptCount} attempts`); return downloadResult.path; } // If download timed out, try other users at the same bitrate before moving to lower quality if (downloadResult.timeout) { const remainingUsersAtThisBitrate = maxAttemptsThisBitrate - i - 1; const totalRemainingAtThisBitrate = orderedResults.length - i - 1; if (remainingUsersAtThisBitrate > 0) { this.logger.info(`⏱️ Download timed out. Trying alternative user (${remainingUsersAtThisBitrate} more attempts, ${totalRemainingAtThisBitrate} total users at ${bitrate}kbps)...`); } else if (bitrate !== availableBitrates[availableBitrates.length - 1]) { this.logger.info(`⏱️ Download timed out. Reached attempt limit at ${bitrate}kbps, will try lower quality...`); } // Continue to next user in the loop } // Add a small delay between failed attempts to be respectful if (i < maxAttemptsThisBitrate - 1) { await this.delay(1000); // 1 second between attempts } } } // If we tried all files at this bitrate, clear failed users for next bitrate if (bitrate !== availableBitrates[availableBitrates.length - 1]) { this.logger.info('Moving to lower bitrate...'); // Keep some failed users to avoid retrying completely dead connections const recentlyFailed = Array.from(this.failedUsers).slice(-5); this.failedUsers.clear(); recentlyFailed.forEach(user => this.failedUsers.add(user)); } } this.logger.error(`❌ Failed to download from Soulseek after ${attemptCount} attempts across all quality levels`); // Try YouTube as last resort if available if (this.youtubeDownloader) { this.logger.section('🎥 YouTube Fallback'); this.logger.warning('⚠️ All Soulseek attempts failed'); this.logger.info('📺 Attempting to download from YouTube as fallback...'); this.logger.info(`🔍 Searching YouTube for: "${artist} - ${title}"`); // Determine YouTube download directory let youtubeDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads'; if (folderName) { youtubeDir = path_1.default.join(youtubeDir, folderName); if (!fs_1.default.existsSync(youtubeDir)) { fs_1.default.mkdirSync(youtubeDir, { recursive: true }); } } const youtubeResult = await this.youtubeDownloader.download({ artist, title, destinationDir: youtubeDir, customFileName }); if (youtubeResult) { this.logger.success(`✅ Successfully downloaded from YouTube!`); this.logger.info(`📍 File saved to: ${youtubeResult}`); this.logger.info('💡 Note: YouTube audio quality may be lower than Soulseek sources'); return youtubeResult; } this.logger.error('❌ Download failed from both Soulseek and YouTube'); } else { this.logger.warning('⚠️ YouTube fallback not available'); this.logger.info('💡 Tip: Ensure inversify and reflect-metadata are in dependencies'); } return null; } catch (error) { this.logger.error(`Search and download error: ${error}`); return null; } } } exports.SoulseekDownloader = SoulseekDownloader; //# sourceMappingURL=soulseek-downloader.js.map