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