UNPKG

andrade-soulseek-downloader

Version:

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

163 lines 6.75 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RateLimiter = void 0; const p_queue_1 = __importDefault(require("p-queue")); const logger_1 = require("./logger"); class RateLimiter { queue; logger; lastSearchTime = 0; lastDownloadTime = 0; config; static instance = null; errorCount = 0; constructor(config) { this.logger = new logger_1.Logger(); // Conservative defaults to prevent bans this.config = { searchDelay: config?.searchDelay ?? 5000, // 5 seconds between searches downloadDelay: config?.downloadDelay ?? 3000, // 3 seconds between downloads maxConcurrent: config?.maxConcurrent ?? 1, // Only 1 operation at a time cooldownAfterError: config?.cooldownAfterError ?? 10000 // 10 second cooldown after errors }; // Create queue with concurrency limit this.queue = new p_queue_1.default({ concurrency: this.config.maxConcurrent, interval: 1000, // Rate limit window intervalCap: 2 // Max 2 operations per second absolute limit }); // Warn if user tries to use unsafe concurrency if (this.config.maxConcurrent > 1) { this.logger.warning('⚠️ Running multiple concurrent operations may result in a Soulseek ban!'); this.logger.warning('⚠️ It is strongly recommended to keep maxConcurrent at 1'); } } // Singleton pattern to ensure only one rate limiter exists static getInstance(config) { if (!RateLimiter.instance) { RateLimiter.instance = new RateLimiter(config); } return RateLimiter.instance; } async waitForSearchDelay() { const now = Date.now(); const timeSinceLastSearch = now - this.lastSearchTime; const requiredDelay = this.config.searchDelay; if (timeSinceLastSearch < requiredDelay) { const waitTime = requiredDelay - timeSinceLastSearch; this.logger.info(`⏳ Rate limiting: Waiting ${(waitTime / 1000).toFixed(1)}s before next search`); this.logger.debug(` • Last search: ${(timeSinceLastSearch / 1000).toFixed(1)}s ago`); this.logger.debug(` • Required delay: ${(requiredDelay / 1000).toFixed(1)}s`); this.logger.startCountdownSpinner('Rate limit cooldown', waitTime); await this.delay(waitTime); this.logger.succeedCountdownSpinner('Rate limit cooldown complete'); } this.lastSearchTime = Date.now(); } async waitForDownloadDelay() { const now = Date.now(); const timeSinceLastDownload = now - this.lastDownloadTime; const requiredDelay = this.config.downloadDelay; if (timeSinceLastDownload < requiredDelay) { const waitTime = requiredDelay - timeSinceLastDownload; this.logger.info(`⏳ Rate limiting: Waiting ${(waitTime / 1000).toFixed(1)}s before next download`); this.logger.debug(` • Last download: ${(timeSinceLastDownload / 1000).toFixed(1)}s ago`); this.logger.debug(` • Required delay: ${(requiredDelay / 1000).toFixed(1)}s`); this.logger.startCountdownSpinner('Rate limit cooldown', waitTime); await this.delay(waitTime); this.logger.succeedCountdownSpinner('Rate limit cooldown complete'); } this.lastDownloadTime = Date.now(); } async handleError() { this.errorCount++; this.logger.warning(`⚠️ Error detected (count: ${this.errorCount})`); if (this.errorCount >= 3) { const cooldown = this.config.cooldownAfterError * 2; // Double cooldown after multiple errors this.logger.error(`🛑 Multiple errors detected (${this.errorCount}). Extended cooldown to avoid ban`); this.logger.startCountdownSpinner(`Error recovery cooldown (extended)`, cooldown); await this.delay(cooldown); this.logger.succeedCountdownSpinner('Error recovery complete, resetting error count'); this.errorCount = 0; // Reset error count after cooldown } else { const cooldown = this.config.cooldownAfterError; this.logger.info(`⏳ Error recovery: Cooling down for ${cooldown / 1000}s`); this.logger.startCountdownSpinner('Error recovery cooldown', cooldown); await this.delay(cooldown); this.logger.succeedCountdownSpinner('Error recovery complete'); } } async executeSearch(fn) { const queueSize = this.queue.size; const pendingCount = this.queue.pending; if (queueSize > 0) { this.logger.info(`📋 Added search to queue (position: ${queueSize + 1}, pending: ${pendingCount})`); } return this.queue.add(async () => { if (queueSize > 0) { this.logger.info('🎯 Processing search from queue'); } await this.waitForSearchDelay(); try { const result = await fn(); return result; } catch (error) { await this.handleError(); throw error; } }); } async executeDownload(fn) { const queueSize = this.queue.size; const pendingCount = this.queue.pending; if (queueSize > 0) { this.logger.info(`📋 Added download to queue (position: ${queueSize + 1}, pending: ${pendingCount})`); } return this.queue.add(async () => { if (queueSize > 0) { this.logger.info('🎯 Processing download from queue'); } await this.waitForDownloadDelay(); try { const result = await fn(); return result; } catch (error) { await this.handleError(); throw error; } }); } delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } getQueueSize() { return this.queue.size; } getPendingCount() { return this.queue.pending; } async onIdle() { return this.queue.onIdle(); } clear() { this.queue.clear(); } pause() { this.queue.pause(); } resume() { this.queue.start(); } // Reset instance (useful for testing) static reset() { RateLimiter.instance = null; } } exports.RateLimiter = RateLimiter; //# sourceMappingURL=rate-limiter.js.map