UNPKG

aiabm

Version:

AI Audiobook Maker - Convert PDFs and text files to audiobooks using OpenAI TTS or Thorsten-Voice (native German)

862 lines (750 loc) โ€ข 30 kB
const ConfigManager = require('./ConfigManager'); const FileHandler = require('./FileHandler'); const TTSService = require('./TTSService'); const ThorstenVoiceService = require('./ThorstenVoiceService'); const VoicePreview = require('./VoicePreview'); const ProgressManager = require('./ProgressManager'); const UIHelpers = require('./UIHelpers'); const inquirer = require('inquirer'); const chalk = require('chalk'); const path = require('path'); const fs = require('fs-extra'); /** * Main class for creating audiobooks from text and PDF files. * Supports multiple TTS providers (OpenAI, Thorsten-Voice). */ class AudiobookMaker { /** * Creates a new AudiobookMaker instance. * Initializes service instances to null - they are created during initialization. */ constructor() { this.configManager = null; this.fileHandler = null; this.ttsService = null; this.voicePreview = null; this.progressManager = null; } /** * Initializes the AudiobookMaker with required services. * Sets up configuration manager, file handler, and progress tracking. * @throws {Error} When initialization fails */ async initialize() { this.configManager = new ConfigManager(); await this.configManager.initialize(); this.fileHandler = new FileHandler(); this.progressManager = new ProgressManager(this.configManager.configDir); await this.progressManager.initialize(); } /** * Opens the configuration management interface. * Allows users to set, update, or remove their API keys. * @throws {Error} When configuration management fails */ async manageConfig() { await this.configManager.manageApiKey(); } /** * Starts the interactive mode with main menu. * First checks for resumable sessions, then shows main menu options. * @throws {Error} When interactive mode fails */ async runInteractive() { // Show enhanced welcome banner UIHelpers.showWelcomeBanner(); // Check for resumable sessions first const resumeSession = await this.progressManager.showResumeDialog(); if (resumeSession) { return await this.resumeSession(resumeSession); } // Main menu await this.showMainMenu(); } async showMainMenu() { // eslint-disable-next-line no-constant-condition while (true) { const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: chalk.cyan('\n๐ŸŽง Main Menu - What would you like to do?'), choices: UIHelpers.getMainMenuChoices(), pageSize: 8, loop: false, }, ]); switch (action) { case 'convert': await this.startConversion(); break; case 'preview': await this.previewVoicesOnly(); break; case 'config': await this.configManager.manageApiKey(); break; case 'history': await this.showSessionHistory(); break; case 'clear_cache': await this.configManager.clearCache(); break; case 'help': UIHelpers.showHelpContent('general'); console.log(chalk.gray('Press any key to continue...')); await inquirer.prompt([{ type: 'input', name: 'continue', message: '' }]); break; case 'exit': console.log(chalk.yellow('\n๐Ÿ‘‹ Goodbye! Thank you for using AI Audiobook Maker! ๐ŸŒŸ')); process.exit(0); } } } async startConversion() { try { const apiKey = await this.configManager.ensureApiKey(); this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir()); this.voicePreview = new VoicePreview(this.ttsService); // Select file const filePath = await this.fileHandler.selectFile(); if (!filePath) {return;} // Process the file await this.processFile(filePath); } catch (error) { console.log(chalk.red(`โŒ Error: ${error.message}`)); } } /** * Processes a file and converts it to audiobook. * Can be called from CLI or interactive mode. * @param {string} filePath - Path to the input file (PDF or text) * @param {Object} cliOptions - CLI options (voice, speed, model) * @param {string} [cliOptions.voice] - Voice to use for TTS * @param {number} [cliOptions.speed] - Speech speed (0.25-4.0) * @param {string} [cliOptions.model] - TTS model to use * @throws {Error} When file processing fails */ async processFile(filePath, cliOptions = {}) { try { // Enhanced file analysis with progress const analysisSpinner = UIHelpers.createProgressBar('๐Ÿ” Analyzing file...').start(); // Read and analyze file const fileData = await this.fileHandler.readFile(filePath); const chunks = this.fileHandler.splitTextIntoChunks(fileData.content); const costInfo = this.fileHandler.calculateCost(fileData.content); analysisSpinner.succeed('โœ… File analysis completed'); // Enhanced file info display fileData.fileName = path.basename(filePath); UIHelpers.showProcessingInfo(fileData, { chunks: chunks.length, estimatedCost: `$${costInfo.estimatedCost.toFixed(3)}`, estimatedTime: this.estimateProcessingTime(chunks.length) }); // Check for existing session const existingSession = await this.progressManager.findExistingSession(filePath); if (existingSession && existingSession.progress.completedChunks > 0) { const resumeConfirmed = await this.promptResumeExisting(existingSession); if (resumeConfirmed) { return await this.resumeSession(existingSession, { chunks, fileData }); } } // Get conversion settings const settings = await this.getConversionSettings(cliOptions); if (!settings) {return;} // Create new session const session = await this.progressManager.createSession(filePath, settings); await this.progressManager.updateProgress(session.id, { totalChunks: chunks.length, status: 'processing', }); // Start conversion await this.convertToAudio(session, chunks, fileData, settings); } catch (error) { console.log(chalk.red(`โŒ Error processing file: ${error.message}`)); } } /** * Displays file information including size, cost estimates, and processing details. * @param {Object} fileData - File metadata (type, characterCount, wordCount, pageCount) * @param {Object} costInfo - Cost calculation results * @param {number} chunkCount - Number of text chunks for processing */ displayFileInfo(fileData, costInfo, chunkCount) { console.log(chalk.green('\nโœ… File analyzed successfully!')); console.log(chalk.white('\n๐Ÿ“Š File Information:')); console.log(chalk.gray(` Type: ${fileData.type.toUpperCase()}`)); console.log(chalk.gray(` Characters: ${fileData.characterCount.toLocaleString()}`)); console.log(chalk.gray(` Words: ${fileData.wordCount.toLocaleString()}`)); if (fileData.pageCount) { console.log(chalk.gray(` Pages: ${fileData.pageCount}`)); } console.log(chalk.gray(` Chunks: ${chunkCount}`)); console.log(chalk.gray(` Estimated cost: $${costInfo.estimatedCost.toFixed(2)} USD`)); const estimatedTime = this.ttsService?.estimateProcessingTime(fileData.characterCount) || '~Unknown'; console.log(chalk.gray(` Estimated time: ${estimatedTime}`)); } async promptResumeExisting(session) { const progress = `${session.progress.completedChunks}/${session.progress.totalChunks}`; console.log(chalk.yellow('\nโš ๏ธ Found existing conversion for this file')); console.log(chalk.gray(` Progress: ${progress} chunks (${session.progress.percentage}%)`)); console.log( chalk.gray(` Last updated: ${this.progressManager.getTimeAgo(session.updatedAt)}`) ); const { resume } = await inquirer.prompt([ { type: 'confirm', name: 'resume', message: 'Resume previous conversion?', default: true, }, ]); return resume; } async getConversionSettings(cliOptions = {}) { // Use CLI options if provided if (cliOptions.voice && cliOptions.speed && cliOptions.model) { await this.initializeServices('openai'); // Default to OpenAI for CLI return { provider: 'openai', voice: cliOptions.voice, speed: cliOptions.speed, model: cliOptions.model, outputOptions: 'single', }; } // Provider selection const provider = await this.showProviderSelection(); if (!provider) {return null;} // Initialize services based on selected provider try { await this.initializeServices(provider); } catch (error) { console.log(chalk.red(`โŒ Failed to initialize ${provider} service: ${error.message}`)); return null; } // Interactive voice selection based on provider const voice = await this.voicePreview.showVoiceSelection(provider); if (!voice) {return null;} // Get advanced settings const advancedSettings = await this.voicePreview.getAdvancedSettings(provider); return { provider, voice, ...advancedSettings, }; } /** * Initializes TTS services based on the selected provider. * @param {string} [provider='openai'] - TTS provider ('openai' or 'thorsten') * @throws {Error} When service initialization fails or provider is unavailable */ async initializeServices(provider = 'openai') { if (provider === 'openai') { // Check if we have a valid API key for OpenAI const apiKey = await this.configManager.ensureApiKey(); if (!apiKey) {throw new Error('OpenAI API key required');} this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir()); } else if (provider === 'thorsten') { this.ttsService = new ThorstenVoiceService(this.configManager.getCacheDir()); // Check if Thorsten-Voice is available const available = await this.ttsService.isAvailable(); if (!available) { console.log(chalk.yellow('โš ๏ธ Thorsten-Voice not found or not properly installed')); throw new Error('Thorsten-Voice not available'); } } else { throw new Error(`Unknown TTS provider: ${provider}`); } this.voicePreview = new VoicePreview(this.ttsService); } async showProviderSelection() { console.log(chalk.cyan('\n๐Ÿค– TTS Provider Selection')); console.log(chalk.gray('Choose your text-to-speech provider\n')); const { provider } = await inquirer.prompt([ { type: 'list', name: 'provider', message: 'Select TTS Provider:', choices: UIHelpers.getProviderChoices().slice(0, 2), // Only first 2 choices (openai, thorsten) default: 'openai', }, ]); // Check if local services are available if selected if (provider === 'thorsten') { // Pre-check Python version compatibility for Thorsten-Voice const pythonCompatible = await this.checkThorstenPythonCompatibility(); if (!pythonCompatible) { console.log(chalk.red('โŒ Thorsten-Voice requires Python 3.9-3.11, but Python 3.13+ detected')); console.log(chalk.yellow('๐Ÿ’ก Install Python 3.11: brew install python@3.11')); console.log(chalk.cyan('๐Ÿ”„ Switching to OpenAI TTS instead')); return 'openai'; } const available = await this.checkLocalServiceInstallation('thorsten'); if (!available) { const installed = await this.showLocalServiceInstallation('Thorsten-Voice'); if (!installed) { return 'openai'; // Fallback to OpenAI } // Re-check availability after installation with a small delay console.log(chalk.gray(' Verifying installation...')); await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay const nowAvailable = await this.checkLocalServiceInstallation('thorsten'); if (!nowAvailable) { console.log(chalk.red('โŒ Thorsten-Voice installation verification failed. Switching to OpenAI TTS')); console.log(chalk.yellow('๐Ÿ’ก You can try running the app again - sometimes the installation needs a restart')); return 'openai'; } } } return provider; } async checkThorstenPythonCompatibility() { const { exec } = require('child_process'); const { promisify } = require('util'); const execAsync = promisify(exec); try { // Check if we have a compatible Python version available const compatibleVersions = ['python3.11', 'python3.10', 'python3.9']; for (const pythonCmd of compatibleVersions) { try { await execAsync(`${pythonCmd} --version`); console.log(chalk.green(`โœ… Found compatible Python: ${pythonCmd}`)); return true; } catch (error) { // Continue to next version } } // Check default python3 version try { const { stdout } = await execAsync('python3 --version'); const versionMatch = stdout.match(/Python (\d+)\.(\d+)/); if (versionMatch) { const major = parseInt(versionMatch[1]); const minor = parseInt(versionMatch[2]); // Return false if Python 3.12+ is detected if (major === 3 && minor >= 12) { return false; } if (major === 3 && minor >= 9) { return true; } } } catch (error) { // Python3 not found } return false; } catch (error) { return false; } } async checkLocalServiceInstallation(provider) { try { let service; if (provider === 'thorsten') { service = new ThorstenVoiceService(this.configManager.getCacheDir()); } console.log(chalk.gray(` Checking ${provider} installation...`)); const isAvailable = await service.isAvailable(); if (isAvailable) { console.log(chalk.green(`โœ… ${provider} is already installed and working`)); } return isAvailable; } catch (error) { console.log(chalk.red(`โŒ Error checking ${provider}: ${error.message}`)); return false; } } async showLocalServiceInstallation(serviceName) { console.log(chalk.yellow(`\n๐Ÿ†“ ${serviceName} Setup Required`)); console.log(chalk.gray('โ”Œโ”€ First time setup (one-time) โ”€โ”')); console.log(chalk.gray(`โ”‚ โš ๏ธ ${serviceName} runs locally โ”‚`)); console.log(chalk.gray('โ”‚ ๐Ÿ“ฆ Size: ~500MB - 2GB download โ”‚')); console.log(chalk.gray('โ”‚ ๐Ÿ–ฅ๏ธ Automatic installation available โ”‚')); console.log(chalk.gray('โ”‚ ๐Ÿ’พ Installs once, runs forever โ”‚')); console.log(chalk.gray('โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜\n')); const installationMethods = [ { name: '๐Ÿš€ Auto Install (recommended)', value: 'auto' }, { name: '๐Ÿค– Use OpenAI TTS instead', value: 'openai' }, { name: '๐Ÿ“‹ Show manual installation guide', value: 'manual' }, ]; const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'Choose installation method:', choices: installationMethods, }, ]); if (action === 'auto') { return await this.installLocalService(serviceName); } else if (action === 'manual') { this.showManualInstallation(serviceName); return false; } else { return false; // Use OpenAI instead } } async installLocalService(serviceName) { console.log(chalk.cyan(`\n๐Ÿ”ง Installing ${serviceName}...`)); try { let service; if (serviceName === 'Thorsten-Voice') { service = new ThorstenVoiceService(this.configManager.getCacheDir()); return await service.installThorsten(); } return false; } catch (error) { console.log(chalk.red(`\nโŒ Installation failed: ${error.message}`)); console.log(chalk.yellow('๐Ÿ’ก Please try the manual installation instead.')); this.showManualInstallation(serviceName); return false; } } showManualInstallation(serviceName) { if (serviceName === 'Thorsten-Voice') { const guide = new ThorstenVoiceService(this.configManager.getCacheDir()).getInstallationGuide(); console.log(chalk.cyan(`\n๐Ÿ“‹ ${guide.title}:`)); guide.steps.forEach(step => console.log(chalk.white(step))); console.log(chalk.gray('\nLinks:')); guide.links.forEach(link => console.log(chalk.gray(` ${link}`))); } else { console.log(chalk.cyan('\n๐Ÿ“‹ Manual Installation Guide:')); console.log(chalk.white('Please refer to the service documentation for installation instructions.')); } } /** * Converts text chunks to audio files and combines them. * Creates output directory, processes chunks, and handles final output options. * @param {Object} session - Processing session data * @param {string[]} chunks - Text chunks to convert * @param {Object} fileData - Original file metadata * @param {Object} settings - Conversion settings (voice, speed, model, outputOptions) * @throws {Error} When audio conversion fails */ async convertToAudio(session, chunks, fileData, settings) { const baseOutputDir = settings.outputDirectory || path.join(process.cwd(), 'audiobook_output'); // Only create subfolder if user wants it let outputDir; if (settings.createSubfolder !== false) { outputDir = path.join( baseOutputDir, `${path.basename(session.filePath, path.extname(session.filePath))}_${session.id}` ); } else { outputDir = baseOutputDir; } await fs.ensureDir(outputDir); await this.progressManager.updateProgress(session.id, { outputDir }); console.log(chalk.cyan('\n๐ŸŽ™๏ธ Starting audio conversion...')); console.log(chalk.gray(`Output directory: ${outputDir}\n`)); try { // Process chunks const audioFiles = await this.ttsService.processTextChunks( chunks, { voice: settings.voice, model: settings.model, speed: settings.speed, outputDir, }, (progress) => { this.progressManager.updateProgress(session.id, { currentChunk: progress.current, filePath: progress.filePath, }); } ); // Handle output options if (settings.outputOptions === 'single' || settings.outputOptions === 'both') { // Create a clean filename const baseFileName = path.basename(session.filePath, path.extname(session.filePath)); const audioFileName = settings.createSubfolder === false ? `${baseFileName}_audiobook.mp3` : `${baseFileName}_audiobook.mp3`; const finalOutputPath = path.join(outputDir, audioFileName); console.log(chalk.cyan('\n๐Ÿ”— Combining audio files...')); await this.ttsService.concatenateAudioFiles(audioFiles, finalOutputPath); await this.progressManager.updateProgress(session.id, { finalOutputPath, status: 'completed', }); console.log(chalk.green('\n๐ŸŽ‰ Audiobook creation completed!')); console.log(chalk.white(`๐Ÿ“ Single file: ${finalOutputPath}`)); if (settings.outputOptions === 'single') { // Clean up individual chunk files await this.cleanupChunkFiles(audioFiles); } } if (settings.outputOptions === 'separate' || settings.outputOptions === 'both') { console.log(chalk.green('\n๐Ÿ“š Individual chapter files available:')); audioFiles.forEach((file, index) => { console.log(chalk.white(` Chapter ${index + 1}: ${file}`)); }); } // Update session with output directory session.outputDir = outputDir; // Display final summary await this.displayCompletionSummary(session, fileData, audioFiles.length); } catch (error) { await this.progressManager.updateProgress(session.id, { status: 'failed', error: error.message, }); throw error; } } /** * Resumes a previously interrupted conversion session. * Continues from the last completed chunk and combines all audio files. * @param {Object} session - Session data to resume * @param {Object} [additionalData] - Optional pre-loaded chunks and fileData * @param {string[]} [additionalData.chunks] - Pre-processed text chunks * @param {Object} [additionalData.fileData] - Pre-loaded file metadata * @throws {Error} When session resume fails */ async resumeSession(session, additionalData = null) { console.log(chalk.cyan(`\n๐Ÿ”„ Resuming session: ${session.fileName}`)); try { // Re-initialize services const apiKey = await this.configManager.ensureApiKey(); this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir()); let chunks, fileData; if (additionalData) { chunks = additionalData.chunks; fileData = additionalData.fileData; } else { // Re-read file data fileData = await this.fileHandler.readFile(session.filePath); chunks = this.fileHandler.splitTextIntoChunks(fileData.content); } // Determine remaining chunks const remainingChunks = chunks.slice(session.progress.completedChunks); if (remainingChunks.length === 0) { console.log(chalk.green('โœ… Session already completed!')); return; } console.log( chalk.yellow(`Resuming from chunk ${session.progress.completedChunks + 1}/${chunks.length}`) ); console.log(chalk.gray(`Remaining: ${remainingChunks.length} chunks\n`)); // Continue conversion const baseOutputDir = session.options.outputDirectory || path.join(process.cwd(), 'audiobook_output'); const outputDir = session.outputDir || path.join( baseOutputDir, `${path.basename(session.filePath, path.extname(session.filePath))}_${session.id}` ); await fs.ensureDir(outputDir); // Process remaining chunks const remainingAudioFiles = await this.ttsService.processTextChunks( remainingChunks, { voice: session.options.voice, model: session.options.model, speed: session.options.speed, outputDir, }, (progress) => { const actualChunkNumber = session.progress.completedChunks + progress.current; this.progressManager.updateProgress(session.id, { currentChunk: actualChunkNumber, filePath: progress.filePath, }); } ); // Combine all audio files (existing + new) const allAudioFiles = []; // Add existing files for (let i = 1; i <= session.progress.completedChunks; i++) { const fileName = `chunk_${i.toString().padStart(3, '0')}.mp3`; allAudioFiles.push(path.join(outputDir, fileName)); } // Add new files allAudioFiles.push(...remainingAudioFiles); // Create final output if (session.options.outputOptions !== 'separate') { const finalOutputPath = path.join( outputDir, `${path.basename(session.filePath, path.extname(session.filePath))}_audiobook.mp3` ); await this.ttsService.concatenateAudioFiles(allAudioFiles, finalOutputPath); await this.progressManager.updateProgress(session.id, { finalOutputPath, status: 'completed', }); console.log(chalk.green('\n๐ŸŽ‰ Audiobook resumed and completed!')); console.log(chalk.white(`๐Ÿ“ Final file: ${finalOutputPath}`)); } await this.displayCompletionSummary(session, fileData, allAudioFiles.length); } catch (error) { await this.progressManager.updateProgress(session.id, { status: 'failed', error: error.message, }); console.log(chalk.red(`โŒ Resume failed: ${error.message}`)); } } async cleanupChunkFiles(audioFiles) { try { for (const file of audioFiles) { await fs.remove(file); } } catch (error) { console.log(chalk.yellow(`โš ๏ธ Could not clean up chunk files: ${error.message}`)); } } /** * Displays a summary of the completed conversion. * Shows statistics, costs, and offers to open the output folder. * @param {Object} session - Completed session data * @param {Object} fileData - Original file metadata * @param {number} audioFileCount - Number of audio files created * @throws {Error} When summary display fails */ async displayCompletionSummary(session, fileData, audioFileCount) { console.log(chalk.green('\n๐ŸŽŠ Conversion Summary:')); console.log(chalk.white(` ๐Ÿ“– Source: ${session.fileName}`)); console.log(chalk.white(` ๐ŸŽค Voice: ${session.options.voice}`)); console.log(chalk.white(` ๐Ÿค– Model: ${session.options.model}`)); console.log(chalk.white(` โšก Speed: ${session.options.speed}x`)); console.log(chalk.white(` ๐Ÿ“Š Chunks processed: ${audioFileCount}`)); console.log( chalk.white( ` ๐Ÿ’ฐ Estimated cost: $${this.fileHandler.calculateCost(fileData.content, session.options.model).estimatedCost.toFixed(2)}` ) ); console.log(chalk.white(` ๐Ÿ“ Output location: ${session.outputDir}`)); if (session.finalOutputPath) { console.log(chalk.cyan('\n๐ŸŽง Your audiobook is ready to enjoy!')); // Ask if user wants to open output folder const { openFolder } = await inquirer.prompt([ { type: 'confirm', name: 'openFolder', message: '๐Ÿ“‚ Open output folder?', default: true, }, ]); if (openFolder) { await this.openOutputFolder(session.outputDir); } } } /** * Opens the output folder in the system file manager. * Cross-platform support for macOS, Windows, and Linux. * @param {string} outputDir - Path to the output directory */ async openOutputFolder(outputDir) { try { const { exec } = require('child_process'); const platform = process.platform; let command; switch (platform) { case 'darwin': // macOS command = `open "${outputDir}"`; break; case 'win32': // Windows command = `explorer "${outputDir}"`; break; case 'linux': // Linux command = `xdg-open "${outputDir}"`; break; default: console.log(chalk.yellow(`๐Ÿ’ก Output folder: ${outputDir}`)); return; } exec(command, (error) => { if (error) { console.log(chalk.yellow(`โš ๏ธ Could not open folder automatically: ${outputDir}`)); } else { console.log(chalk.green('๐Ÿ“‚ Output folder opened!')); } }); } catch (error) { console.log(chalk.yellow(`๐Ÿ’ก Output folder: ${outputDir}`)); } } async previewVoicesOnly() { try { const apiKey = await this.configManager.ensureApiKey(); this.ttsService = new TTSService(apiKey, this.configManager.getCacheDir()); this.voicePreview = new VoicePreview(this.ttsService); await this.voicePreview.showVoiceSelection(); } catch (error) { console.log(chalk.red(`โŒ Error: ${error.message}`)); } } async showSessionHistory() { const stats = await this.progressManager.getSessionStats(); const recentSessions = await this.progressManager.getRecentSessions(10); console.log(chalk.cyan('\n๐Ÿ“Š Session Statistics:')); console.log(chalk.white(` Total sessions: ${stats.total}`)); console.log(chalk.white(` Completed: ${stats.completed}`)); console.log(chalk.white(` In progress: ${stats.inProgress}`)); console.log(chalk.white(` Failed: ${stats.failed}`)); console.log(chalk.white(` Total chunks processed: ${stats.totalProcessedChunks}`)); if (recentSessions.length > 0) { console.log(chalk.cyan('\n๐Ÿ“‹ Recent Sessions:')); recentSessions.forEach((session, index) => { const status = this.getStatusEmoji(session.status); const progress = session.progress.totalChunks > 0 ? `${session.progress.completedChunks}/${session.progress.totalChunks}` : 'Not started'; console.log( chalk.white( ` ${index + 1}. ${status} ${session.fileName} - ${progress} - ${this.progressManager.getTimeAgo(session.updatedAt)}` ) ); }); } const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'Session management:', choices: [ { name: '๐Ÿ”™ Back to main menu', value: 'back' }, { name: '๐Ÿงน Clear all sessions', value: 'clear' }, ], }, ]); if (action === 'clear') { await this.progressManager.clearOldSessions(); } } getStatusEmoji(status) { switch (status) { case 'completed': return 'โœ…'; case 'processing': return '๐Ÿ”„'; case 'failed': return 'โŒ'; default: return 'โณ'; } } /** * Estimates processing time based on chunk count */ estimateProcessingTime(chunkCount) { const timePerChunk = 3; // seconds per chunk (average) const totalSeconds = chunkCount * timePerChunk; if (totalSeconds < 60) { return `~${totalSeconds} seconds`; } else if (totalSeconds < 3600) { return `~${Math.round(totalSeconds / 60)} minutes`; } else { const hours = Math.floor(totalSeconds / 3600); const minutes = Math.round((totalSeconds % 3600) / 60); return `~${hours}h ${minutes}m`; } } } module.exports = AudiobookMaker;