andrade-soulseek-downloader
Version:
Simple, safe Soulseek download library with built-in rate limiting to prevent bans
163 lines • 6.75 kB
JavaScript
"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