andrade-soulseek-downloader
Version:
Simple, safe Soulseek download library with built-in rate limiting to prevent bans
331 lines • 14.8 kB
JavaScript
;
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.InteractiveSoulseek = void 0;
exports.runInteractiveSoulseek = runInteractiveSoulseek;
const dotenv = __importStar(require("dotenv"));
const prompts_1 = __importDefault(require("prompts"));
const core_1 = require("../../core");
const utils_1 = require("../../utils");
const chalk_1 = __importDefault(require("chalk"));
dotenv.config();
/**
* Interactive Soulseek-only CLI mode.
* Allows user to search, select files manually, and retry on failures.
*/
class InteractiveSoulseek {
downloader;
logger;
isRunning = true;
constructor() {
this.logger = new utils_1.Logger();
this.downloader = new core_1.SoulseekDownloader({
searchTimeout: parseInt(process.env.SOULSEEK_SEARCH_TIMEOUT || '30000'),
downloadTimeout: parseInt(process.env.SOULSEEK_DOWNLOAD_TIMEOUT || '120000'),
searchDelay: parseInt(process.env.SOULSEEK_SEARCH_DELAY || '5000'),
downloadDelay: parseInt(process.env.SOULSEEK_DOWNLOAD_DELAY || '3000'),
});
// Handle Ctrl+C gracefully
prompts_1.default.override({ cancel: () => { this.isRunning = false; } });
}
/**
* Checks if user wants to quit.
*/
isQuitCommand(value) {
if (typeof value === 'string') {
const lower = value.toLowerCase();
return lower === 'quit' || lower === 'q';
}
return false;
}
/**
* Displays welcome header.
*/
displayWelcome() {
console.log('\n' + chalk_1.default.cyan.bold('═══════════════════════════════════════════════════'));
console.log(chalk_1.default.cyan.bold(' 🎵 Interactive Soulseek Download Mode 🎵'));
console.log(chalk_1.default.cyan.bold('═══════════════════════════════════════════════════'));
console.log(chalk_1.default.gray('Type "quit" or "q" at any time to exit\n'));
}
/**
* Gets search query from user.
*/
async getSearchQuery() {
const response = await (0, prompts_1.default)({
type: 'text',
name: 'query',
message: '🔍 Enter search query (artist + title):',
validate: (value) => value.length > 0 || 'Search query cannot be empty',
});
if (!response.query || this.isQuitCommand(response.query)) {
return null;
}
return response.query;
}
/**
* Formats a result for display in the selection menu.
*/
formatResult(result) {
const fileName = result.file.split(/[/\\]/).pop() || result.file;
const fileExt = fileName.slice(fileName.lastIndexOf('.')).toLowerCase();
const sizeMB = (result.size / 1024 / 1024).toFixed(2);
const speedMBs = (result.speed / 1024 / 1024).toFixed(1);
// Format quality indicator
let qualityIcon = '●';
if (fileExt === '.flac' || fileExt === '.wav' || fileExt === '.aif' || fileExt === '.aiff') {
qualityIcon = '♦';
}
else if (result.bitrate >= 320) {
qualityIcon = '⭐';
}
// Truncate filename if too long
const displayName = fileName.length > 50 ? fileName.substring(0, 47) + '...' : fileName;
return `${qualityIcon} ${displayName} │ ${result.user} │ ${result.bitrate}kbps │ ${sizeMB}MB │ ${speedMBs}MB/s`;
}
/**
* Gets user file selection with arrow keys.
*/
async getUserSelection(results) {
const choices = results.map((result, index) => ({
title: this.formatResult(result),
value: result,
}));
// Add refresh and quit options
choices.push({
title: chalk_1.default.cyan('🔄 Refresh search results'),
value: 'refresh',
});
const response = await (0, prompts_1.default)({
type: 'select',
name: 'file',
message: '📥 Select file to download:',
choices,
initial: 0,
});
if (!response.file) {
return null; // User cancelled
}
if (response.file === 'refresh') {
return 'refresh';
}
return response.file;
}
/**
* Performs search with user query.
*/
async performSearch(query) {
try {
console.log(chalk_1.default.cyan(`\n🔍 Searching for: "${query}"...\n`));
// Connect if not connected
await this.downloader.connect();
// Parse query (simple split by space, user can provide "artist title")
const parts = query.split(' ');
const artist = parts.slice(0, Math.ceil(parts.length / 2)).join(' ');
const title = parts.slice(Math.ceil(parts.length / 2)).join(' ');
// Search with relaxed matching to get all results
const results = await this.downloader.search({
artist: artist || query,
title: title || '',
minBitrate: parseInt(process.env.SOULSEEK_MIN_QUALITY_BITRATE || '96'),
maxBitrate: process.env.SOULSEEK_MAX_QUALITY_BITRATE
? parseInt(process.env.SOULSEEK_MAX_QUALITY_BITRATE)
: undefined,
timeout: parseInt(process.env.SOULSEEK_SEARCH_TIMEOUT || '30000'),
maxResults: 100,
strictMatching: false, // Relaxed to get more results
});
if (results.length === 0) {
console.log(chalk_1.default.red('❌ No results found. Try a different query.'));
return null;
}
// Filter out results with no available slots
const availableResults = results.filter(result => result.slots);
if (availableResults.length === 0) {
console.log(chalk_1.default.red('❌ No results with available slots found.'));
console.log(chalk_1.default.yellow(` Found ${results.length} results but all users have no slots available.`));
console.log(chalk_1.default.gray(' Try a different query or wait for users to free up slots.'));
return null;
}
if (availableResults.length < results.length) {
console.log(chalk_1.default.yellow(`ℹ Filtered out ${results.length - availableResults.length} results with no available slots`));
}
return availableResults;
}
catch (error) {
console.log(chalk_1.default.red(`❌ Search error: ${error.message}`));
return null;
}
}
/**
* Attempts to download selected file.
*/
async attemptDownload(result, query) {
try {
const fileName = result.file.split(/[/\\]/).pop() || 'download';
const downloadDir = process.env.SOULSEEK_DOWNLOAD_DIR || './downloads';
console.log(chalk_1.default.cyan('\n📥 Starting download...'));
console.log(chalk_1.default.gray(`File: ${fileName}`));
console.log(chalk_1.default.gray(`User: ${result.user}`));
console.log(chalk_1.default.gray(`Size: ${(result.size / 1024 / 1024).toFixed(2)} MB`));
console.log(chalk_1.default.gray(`Bitrate: ${result.bitrate} kbps\n`));
const downloadResult = await this.downloader.download(result, query, // Use query as artist
'', // No specific title
undefined, // No folder
query.replace(/[^\p{L}\p{N} ]/gu, '').replace(/\s+/g, '_') // Sanitized filename
);
if (downloadResult.path) {
console.log('\n' + chalk_1.default.green.bold('✅ Download successful!'));
console.log(chalk_1.default.green(`📁 Saved to: ${downloadResult.path}\n`));
return true;
}
else if (downloadResult.timeout) {
console.log(chalk_1.default.red('\n❌ Download timed out'));
return false;
}
else {
console.log(chalk_1.default.red('\n❌ Download failed'));
return false;
}
}
catch (error) {
console.log(chalk_1.default.red(`\n❌ Download error: ${error.message}`));
return false;
}
}
/**
* Main interactive loop.
*/
async run() {
this.displayWelcome();
try {
while (this.isRunning) {
// Get search query
const query = await this.getSearchQuery();
if (!query) {
console.log(chalk_1.default.yellow('\n👋 Exiting...'));
break;
}
// Perform search
let results = await this.performSearch(query);
if (!results || results.length === 0) {
continue; // Ask for new query
}
// Download loop for current search
let downloadSuccess = false;
while (!downloadSuccess && this.isRunning) {
console.log(chalk_1.default.cyan(`\n📊 Found ${results.length} files with available slots`));
// Get user selection with arrow keys
const selection = await this.getUserSelection(results);
if (!selection) {
// User cancelled (Ctrl+C)
console.log(chalk_1.default.yellow('\n👋 Exiting...'));
this.isRunning = false;
break;
}
if (selection === 'refresh') {
console.log(chalk_1.default.cyan('\n🔄 Refreshing search results...'));
results = await this.performSearch(query);
if (!results || results.length === 0) {
break; // Exit download loop, will ask for new query
}
continue;
}
// selection is a SoulseekSearchResult
downloadSuccess = await this.attemptDownload(selection, query);
if (downloadSuccess) {
// Ask if user wants to download another file
const response = await (0, prompts_1.default)({
type: 'confirm',
name: 'continue',
message: '🎵 Download another track?',
initial: false,
});
if (response.continue) {
downloadSuccess = false; // Reset to exit download loop and get new query
break; // Exit download loop to get new query
}
else {
this.isRunning = false;
console.log(chalk_1.default.yellow('\n👋 Goodbye!'));
}
}
else {
// Download failed, ask what to do
const response = await (0, prompts_1.default)({
type: 'select',
name: 'action',
message: '🔄 What would you like to do?',
choices: [
{ title: 'Try another file from results', value: 'retry' },
{ title: 'Refresh search results', value: 'refresh' },
{ title: 'Quit', value: 'quit' },
],
initial: 0,
});
if (!response.action || response.action === 'quit') {
this.isRunning = false;
console.log(chalk_1.default.yellow('\n👋 Exiting...'));
break;
}
else if (response.action === 'refresh') {
console.log(chalk_1.default.cyan('\n🔄 Refreshing search results...'));
results = await this.performSearch(query);
if (!results || results.length === 0) {
break;
}
}
// Otherwise continue loop to select another file (action === 'retry')
}
}
}
}
finally {
await this.downloader.disconnect();
console.log(chalk_1.default.gray('\n✨ Disconnected from Soulseek network\n'));
}
}
}
exports.InteractiveSoulseek = InteractiveSoulseek;
/**
* Entry point for interactive mode.
*/
async function runInteractiveSoulseek() {
const interactive = new InteractiveSoulseek();
await interactive.run();
}
//# sourceMappingURL=interactive-soulseek.js.map