UNPKG

andrade-soulseek-downloader

Version:

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

1,015 lines 57.2 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 and Juno downloaders only in non-test environments
let YouTubeDownloader;
let JunoDownloader;
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');
        }
    }
    try {
        JunoDownloader = require('../infrastructure/services/juno-downloader').JunoDownloader;
    }
    catch (error) {
        // Juno downloader not available
        console.warn('⚠️  Juno downloader module not loaded');
        console.warn(`   Reason: ${error.message || error}`);
        if (error.code === 'MODULE_NOT_FOUND') {
            console.warn('   💡 Fix: Ensure puppeteer is installed in dependencies');
        }
    }
}
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;
    junoDownloader;
    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();
        }
        // Initialize Juno downloader if available
        if (JunoDownloader) {
            this.junoDownloader = new JunoDownloader();
        }
        // 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;
                    // Add error handlers to prevent unhandled errors from crashing the process
                    if (this.client.on) {
                        this.client.on('error', (err) => {
                            this.logger.error(`Soulseek client error: ${err.message}`);
                            this.logger.debug('Error occurred but will continue operation');
                        });
                    }
                    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})`);
                    // If SOULSEEK_MAX_QUALITY_BITRATE is not set, prioritize lossless formats (FLAC, WAV, AIF, AIFF)
                    if (!maxBitrate) {
                        const losslessFormats = ['.flac', '.wav', '.aif', '.aiff', '.alac', '.ape'];
                        const losslessResults = filteredResults.filter(result => {
                            const ext = result.file.toLowerCase().slice(result.file.lastIndexOf('.'));
                            return losslessFormats.includes(ext);
                        });
                        if (losslessResults.length > 0) {
                            this.logger.debug(`  • Found ${losslessResults.length} lossless format files (FLAC/WAV/AIF) - prioritizing these!`);
                            // Keep lossless files at the top, append lossy formats after
                            const lossyResults = filteredResults.filter(result => {
                                const ext = result.file.toLowerCase().slice(result.file.lastIndexOf('.'));
                                return !losslessFormats.includes(ext);
                            });
                            filteredResults = [...losslessResults, ...lossyResults];
                        }
                    }
                    // 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
            let fileName = customFileName || this.generateFileName(artist, title);
            // Add bitrate suffix if this is a low quality file compared to user's minimum preference
            const userMinBitrate = this.downloadConfig.minQualityBitrate || 128;
            if (result.bitrate < userMinBitrate) {
                fileName = `${fileName}_${result.bitrate}kbps`;
                this.logger.warning(`⚠️  Low quality file (${result.bitrate}kbps < ${userMinBitrate}kbps requested) - adding bitrate suffix`);
            }
            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;
                let lastProgressTime = Date.now();
                let timeout;
                // Reset timeout on progress - this allows slow but active downloads to continue
                const resetTimeout = () => {
                    if (timeout)
                        clearTimeout(timeout);
                    lastProgressTime = Date.now();
                    timeout = setTimeout(() => {
                        if (!isCancelled) {
                            const inactiveSeconds = Math.floor((Date.now() - lastProgressTime) / 1000);
                            isTimeout = true;
                            if (!isRacing) {
                                this.logger.stopProgressBar();
                            }
                            this.logger.warning(`⏱️ Download timeout after ${inactiveSeconds}s of inactivity for user ${result.user}`);
                            this.failedUsers.add(result.user);
                            resolve({ path: null, timeout: true, cancelled: false });
                        }
                    }, this.downloadConfig.downloadTimeout);
                };
                // Start initial timeout
                resetTimeout();
                // 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) => {
                    // Reset timeout on every progress event - download is still active
                    resetTimeout();
                    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) => {
                    clearTimeout(timeout);
                    if (!isRacing) {
                        this.logger.stopProgressBar();
                    }
                    this.logger.error(`Download error: ${err.message}`);
                    this.logger.debug(`Adding user ${result.user} to failed users list`);
                    this.failedUsers.add(result.user);
                    // Clean up partial download if exists
                    if (fs_1.default.existsSync(downloadPath)) {
                        try {
                            fs_1.default.unlinkSync(downloadPath);
                            this.logger.debug('Cleaned up partial download file');
                        }
                        catch (cleanupErr) {
                            // Ignore cleanup errors
                        }
                    }
                    // Resolve the promise with null path to continue to next fallback
                    if (!isCancelled) {
                        resolve({ path: null, timeout: false, cancelled: false });
                    }
                });
                // 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 still no results at desired quality, try ANY quality as last resort before YouTube
            let lowQualityFallback = false;
            let originalMinBitrate = searchOptions.minBitrate;
            if (results.length === 0 && searchOptions.minBitrate && searchOptions.minBitrate > 128) {
                this.logger.warning(`⚠️  No results found at ${searchOptions.minBitrate}+ kbps, searching for lower quality files...`);
                searchOptions.minBitrate = 96; // Accept any quality >= 96kbps
                searchOptions.strictMatching = false;
                results = await this.search(searchOptions);
                lowQualityFallback = true;
            }
            if (results.length === 0) {
                this.logger.error('❌ No results found on Soulseek network');
                // Try Juno fallback first
                if (this.junoDownloader) {
                    this.logger.section('🎵 Juno.co.uk Fallback');
                    this.logger.info('🎶 Attempting to download snippet from Juno.co.uk...');
                    this.logger.info(`🔍 Searching Juno for: "${artist} - ${title}"`);
                    // Determine Juno download directory
                    let junoDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads';
                    if (folderName) {
                        junoDir = path_1.default.join(junoDir, folderName);
                        if (!fs_1.default.existsSync(junoDir)) {
                            fs_1.default.mkdirSync(junoDir, { recursive: true });
                        }
                    }
                    const junoResult = await this.junoDownloader.download({
                        artist,
                        title,
                        destinationDir: junoDir,
                        customFileName
                    });
                    if (junoResult) {
                        this.logger.success(`✅ Successfully downloaded snippet from Juno!`);
                        this.logger.info(`📍 File saved to: ${junoResult}`);
                        this.logger.info('💡 Note: This is a preview snippet from Juno.co.uk');
                        return junoResult;
                    }
                    this.logger.warning('⚠️  Juno snippet not found, trying YouTube...');
                }
                // Try YouTube fallback if Juno failed or not available
                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 Soulseek, Juno, 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)`);
                    this.logger.info(`🔄 Will try ALL ${availableResults.length} users before falling back to Juno/YouTube`);
                }
                else {
                    this.logger.info(`📂 Starting with ${availableResults.length} files at ${bitrate}kbps (all have available slots)`);
                    this.logger.info(`🔄 Will try ALL ${availableResults.length} users before falling back to Juno/YouTube`);
                }
                // 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 before falling back
                // No arbitrary limits - we want to exhaust all Soulseek options before Juno/YouTube
                const maxAttemptsThisBitrate = orderedResults.length;
                // 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 trying ALL ${attemptCount} available users across all quality levels`);
            this.logger.info(`💡 Exhausted all Soulseek options - now trying alternative sources...`);
            // Try Juno fallback first
            if (this.junoDownloader) {
                this.logger.section('🎵 Juno.co.uk Fallback');
                this.logger.warning('⚠️  All Soulseek attempts failed');
                this.logger.info('🎶 Attempting to download snippet from Juno.co.uk...');
                this.logger.info(`🔍 Searching Juno for: "${artist} - ${title}"`);
                // Determine Juno download directory
                let junoDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads';
                if (folderName) {
                    junoDir = path_1.default.join(junoDir, folderName);
                    if (!fs_1.default.existsSync(junoDir)) {
                        fs_1.default.mkdirSync(junoDir, { recursive: true });
                    }
                }
                const junoResult = await this.junoDownloader.download({
                    artist,
                    title,
                    destinationDir: junoDir,
                    customFileName
                });
                if (junoResult) {
                    this.logger.success(`✅ Successfully downloaded snippet from Juno!`);
                    this.logger.info(`📍 File saved to: ${junoResult}`);
                    this.logger.info('💡 Note: This is a preview snippet from Juno.co.uk');
                    return junoResult;
                }
                this.logger.warning('⚠️  Juno snippet not found, trying YouTube...');
            }
            // 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 Soulseek, Juno, 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