UNPKG

@tiahui/anitorrent-cli

Version:

CLI tool for video management with PeerTube and Storj S3

1,467 lines (1,335 loc) 87.4 kB
const { Command } = require('commander'); const ora = require('ora'); const ConfigManager = require('../utils/config'); const { Logger } = require('../utils/logger'); const Validators = require('../utils/validators'); const SubtitleService = require('../services/subtitle-service'); const TranslationService = require('../services/translation-service'); const anitomy = require('anitomyscript'); const subtitlesCommand = new Command('subtitle'); subtitlesCommand.description('Subtitle extraction and management'); subtitlesCommand .command('list') .description('List subtitle tracks from a video file') .argument('<file>', 'video file path') .option('--debug, -d', 'debug output') .option('--quiet, -q', 'quiet mode') .action(async (file, options) => { const isDebug = options.debug || false; const logger = new Logger({ verbose: isDebug, quiet: options.quiet || false, }); try { const pathValidation = await Validators.validateFilePath(file); const videoFile = pathValidation.resolvedPath; const fs = require('fs').promises; try { const stats = await fs.stat(videoFile); if (!stats.isFile()) { logger.error(`Path "${file}" is not a file`); process.exit(1); } } catch (error) { logger.error(`File not found: "${file}"`); if (pathValidation.originalPath !== pathValidation.resolvedPath) { logger.error(`Resolved path: "${pathValidation.resolvedPath}"`); } process.exit(1); } const subtitleService = new SubtitleService(); logger.header('Subtitle Track Information'); logger.info(`File: ${videoFile}`); logger.separator(); const spinner = ora('Analyzing subtitle tracks...').start(); const subtitleTracks = await subtitleService.listSubtitleTracks( videoFile ); spinner.succeed(`Found ${subtitleTracks.length} subtitle tracks`); if (subtitleTracks.length === 0) { logger.warning('No subtitle tracks found in the video file'); return; } subtitleTracks.forEach((track, index) => { logger.info(`Track ${track.trackNumber}:`); logger.info( ` Language: ${track.language}${ track.languageDetail ? ` (${track.languageDetail})` : '' }`, 1 ); logger.info(` Title: ${track.title}`, 1); logger.info(` Codec: ${track.codec}`, 1); if (track.forced !== undefined) { logger.info(` Forced: ${track.forced ? 'Yes' : 'No'}`, 1); } if (track.default !== undefined) { logger.info(` Default: ${track.default ? 'Yes' : 'No'}`, 1); } if (isLogs) { logger.info(` Source: ${track.source}`); logger.info(` Stream Index: ${track.index}`); if (track.mkvTrackId !== undefined) { logger.info(` MKV Track ID: ${track.mkvTrackId}`); } if (track.originalTrackName) { logger.info(` Original Track Name: ${track.originalTrackName}`); } if (track.properties) { logger.info(` MKV Properties:`); Object.entries(track.properties).forEach(([key, value]) => { logger.info(` ${key}: ${value}`); }); } if (track.allTags) { logger.info(` FFprobe Tags:`); Object.entries(track.allTags).forEach(([key, value]) => { logger.info(` ${key}: ${value}`); }); } if (track.disposition) { const dispositionFlags = Object.entries(track.disposition) .filter(([key, value]) => value === 1) .map(([key]) => key); if (dispositionFlags.length > 0) { logger.info(` Disposition: ${dispositionFlags.join(', ')}`); } } } if (index < subtitleTracks.length - 1) { logger.separator(); } }); } catch (error) { logger.error(`Failed to list subtitle tracks: ${error.message}`); process.exit(1); } }); subtitlesCommand .command('extract') .description('Extract subtitles from videos or playlists') .argument( '[input]', 'video file path or PeerTube playlist ID (auto-detected)' ) .option( '--folder <path>', 'folder path to search for videos (default: current directory)' ) .option('--sub-folders', 'search for video files in subfolders as well') .option( '--track <number>', 'subtitle track number (if not specified, auto-finds Spanish Latino)' ) .option( '--subtitle-suffix <suffix>', 'custom suffix for the extracted subtitle file (can only be used with --track)' ) .option('--all', 'extract all subtitle tracks') .option('--translate', 'also create AI-translated version to Spanish') .option( '--translate-prompt <path>', 'custom system prompt file for translation' ) .option( '--offset <ms>', 'adjust subtitle timing by specified milliseconds (e.g., 4970 for +4.970s)', parseInt ) .option('--logs', 'detailed output') .action(async (input, options, cmd) => { const isLogs = options.logs || false; const logger = new Logger({ verbose: false, quiet: options.quiet || false, }); const detectInputType = async (input) => { if (!input) return { type: 'folder', value: null }; const fs = require('fs').promises; const path = require('path'); try { const pathValidation = await Validators.validateFilePath(input); const resolvedPath = pathValidation.resolvedPath; const stats = await fs.stat(resolvedPath); if (stats.isFile()) { const ext = path.extname(resolvedPath).toLowerCase(); const videoExtensions = [ '.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.ts', '.mts', ]; if (videoExtensions.includes(ext)) { return { type: 'video', value: resolvedPath }; } else { throw new Error(`File "${input}" is not a supported video format`); } } else if (stats.isDirectory()) { return { type: 'folder', value: resolvedPath }; } } catch (error) { if (error.code === 'ENOENT') { const trimmedInput = input.trim(); if (trimmedInput && trimmedInput.length > 0) { return { type: 'playlist', value: trimmedInput }; } } throw error; } return { type: 'unknown', value: input }; }; const applyOffsetToFile = async (filePath, offsetMs) => { if (!offsetMs || offsetMs === 0) return { success: true, offsetApplied: false }; const subtitleService = new SubtitleService(); try { const result = await subtitleService.adjustSubtitleTiming( filePath, offsetMs, filePath ); return { success: result.success, offsetApplied: true, error: result.error, }; } catch (error) { return { success: false, offsetApplied: false, error: error.message, }; } }; try { let subtitleTrack = null; if (options.track !== undefined) { subtitleTrack = parseInt(options.track); if (!Validators.isValidSubtitleTrack(subtitleTrack)) { logger.error('Invalid subtitle track number'); process.exit(1); } } if (options.subtitleSuffix && options.track === undefined) { logger.error('--subtitle-suffix can only be used with --track option'); process.exit(1); } if (options.subtitleSuffix && options.all) { logger.error('--subtitle-suffix cannot be used with --all option'); process.exit(1); } const subtitleService = new SubtitleService(); let translationConfig = null; if (options.translate) { const config = new ConfigManager(); translationConfig = config.getTranslationConfig(); if (!translationConfig.apiKey) { logger.error('Claude API key not configured. Translation disabled.'); logger.info('Run "anitorrent config setup" to set up configuration'); translationConfig = null; } } let folderPath = '.'; if (options.folder) { const pathValidation = await Validators.validateFilePath( options.folder ); folderPath = pathValidation.resolvedPath; const fs = require('fs').promises; try { const stats = await fs.stat(folderPath); if (!stats.isDirectory()) { logger.error(`Path "${options.folder}" is not a directory`); process.exit(1); } } catch (error) { logger.error(`Directory not found: "${options.folder}"`); if (pathValidation.originalPath !== pathValidation.resolvedPath) { logger.error(`Resolved path: "${pathValidation.resolvedPath}"`); } process.exit(1); } } const inputType = await detectInputType(input); if (inputType.type === 'video') { const videoFile = inputType.value; if (options.all) { logger.header('Extract All Subtitle Tracks'); logger.info(`File: ${videoFile}`); if (translationConfig) { logger.info('Translation: Enabled'); } if (options.offset) { logger.info( `Timing offset: ${options.offset}ms (${ options.offset >= 0 ? 'forward' : 'backward' })` ); } logger.separator(); const spinner = ora('Extracting all subtitle tracks...').start(); let results; if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extracting subtitle tracks...'; } }; results = await subtitleService.extractAllSubtitleTracksWithTranslation( videoFile, folderPath, translationConfig, onProgress ); } else { results = await subtitleService.extractAllSubtitleTracks( videoFile, folderPath ); } spinner.succeed(`Extraction completed`); if (options.offset) { const offsetSpinner = ora( 'Applying timing offset to extracted files...' ).start(); let offsetSuccessful = 0; let offsetFailed = 0; for (const result of results) { if (result.success && result.outputFile) { const offsetResult = await applyOffsetToFile( result.outputFile, options.offset ); if (offsetResult.success) { offsetSuccessful++; result.offsetApplied = true; } else { offsetFailed++; result.offsetError = offsetResult.error; } } } if (offsetFailed === 0) { offsetSpinner.succeed( `Timing offset applied to ${offsetSuccessful} files` ); } else { offsetSpinner.warn( `Timing offset: ${offsetSuccessful} successful, ${offsetFailed} failed` ); } } const successful = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; logger.success( `Extraction completed: ${successful} successful, ${failed} failed` ); if (options.offset) { const offsetSuccessful = results.filter( (r) => r.success && r.offsetApplied ).length; const offsetFailed = results.filter( (r) => r.success && r.offsetError ).length; if (offsetSuccessful > 0 || offsetFailed > 0) { logger.info( `Timing offset (${options.offset}ms): ${offsetSuccessful} applied, ${offsetFailed} failed` ); } } if (isLogs) { results.forEach((result) => { if (result.success) { logger.info( ` ✓ Track ${result.track.trackNumber} (${result.track.language}) → ${result.outputFile}` ); if (result.offsetApplied) { logger.info( ` ✓ Timing offset applied: ${options.offset}ms` ); } else if (result.offsetError) { logger.info( ` ✗ Timing offset failed: ${result.offsetError}` ); } if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } else { logger.info( ` ✗ Track ${result.track.trackNumber}: ${result.error}` ); } }); } } else { logger.header('Single Subtitle Track Extraction'); logger.info(`File: ${videoFile}`); if (subtitleTrack !== null) { logger.info(`Track: ${subtitleTrack}`); if (options.subtitleSuffix) { logger.info(`Subtitle suffix: ${options.subtitleSuffix}`); } } else { logger.info('Track: Auto-detect Spanish Latino'); } if (translationConfig) { logger.info('Translation: Enabled'); } if (options.offset) { logger.info( `Timing offset: ${options.offset}ms (${ options.offset >= 0 ? 'forward' : 'backward' })` ); } logger.separator(); let targetTrack = subtitleTrack; if (targetTrack === null) { const tracks = await subtitleService.listSubtitleTracks(videoFile); targetTrack = subtitleService.findDefaultSpanishTrack(tracks); if (targetTrack === -1) { targetTrack = 0; } logger.info(`Auto-detected track: ${targetTrack}`); } const tracks = await subtitleService.listSubtitleTracks(videoFile); const spanishTracks = tracks.filter( (t) => t.language === 'spa' || t.language === 'es' ); const nameWithoutExt = require('path').parse(videoFile).name; let outputFile; if (targetTrack < tracks.length) { const track = tracks[targetTrack]; logger.info(`Track: ${JSON.stringify(track)}`); const langSuffix = subtitleService.getLanguageSuffix( track, spanishTracks.length === 1, options.subtitleSuffix ); outputFile = langSuffix ? `${nameWithoutExt}_${langSuffix}.ass` : `${nameWithoutExt}.ass`; } else { outputFile = `${nameWithoutExt}.ass`; } const spinner = ora('Extracting subtitle track...').start(); let result; if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extraction completed'; } }; result = await subtitleService.extractAndTranslateSubtitles( videoFile, outputFile, targetTrack, folderPath, translationConfig, onProgress ); } else { result = await subtitleService.extractSubtitles( videoFile, outputFile, targetTrack, folderPath ); } if (result.success) { if (options.offset) { spinner.text = 'Applying timing offset...'; const offsetResult = await applyOffsetToFile( result.outputPath, options.offset ); if (offsetResult.success) { spinner.succeed( `Subtitle extracted with timing offset applied: ${result.outputPath}` ); if (offsetResult.offsetApplied) { logger.info(`Timing adjusted by ${options.offset}ms`); } } else { spinner.succeed(`Subtitle extracted to: ${result.outputPath}`); logger.warning( `Failed to apply timing offset: ${offsetResult.error}` ); } } else { spinner.succeed(`Subtitle extracted to: ${result.outputPath}`); } if (result.translationResult) { logger.success( `Translation created: ${result.translationResult.outputPath}` ); } else if (result.translationError) { logger.warning(`Translation failed: ${result.translationError}`); } } else { spinner.fail(`Extraction failed: ${result.error}`); process.exit(1); } } } else if (inputType.type === 'folder') { const targetDir = inputType.value || folderPath; logger.header( `${ options.subFolders ? 'Directory Tree' : 'Folder-based' } Subtitle Extraction` ); logger.info( `Directory: ${targetDir === '.' ? 'Current directory' : targetDir}` ); if (options.subFolders) { logger.info('Including subfolders: Yes'); } if (options.all) { logger.info('Mode: Extract all subtitle tracks'); } else if (subtitleTrack !== null) { logger.info(`Subtitle track: ${subtitleTrack}`); } else { logger.info('Subtitle track: Auto-detect Spanish Latino'); } if (translationConfig) { logger.info('Translation: Enabled'); } if (options.offset) { logger.info( `Timing offset: ${options.offset}ms (${ options.offset >= 0 ? 'forward' : 'backward' })` ); } logger.separator(); const spinner = ora('Finding local video files...').start(); const localFiles = await subtitleService.getLocalVideoFiles( targetDir, options.subFolders ); spinner.succeed(`Found ${localFiles.length} local video files`); if (localFiles.length === 0) { logger.warning(`No video files found in directory: ${targetDir}`); return; } logger.info('Extracting subtitles...'); let results; if (options.all) { if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extracting subtitles...'; } }; results = await subtitleService.extractAllSubtitlesFromFolder( targetDir, options.subFolders ); } else { results = await subtitleService.extractAllSubtitlesFromFolder( targetDir, options.subFolders ); } } else { if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extracting subtitles...'; } }; results = await subtitleService.extractAllLocalSubtitlesWithTranslation( subtitleTrack, targetDir, translationConfig, onProgress, options.subFolders ); } else { results = await subtitleService.extractAllLocalSubtitles( subtitleTrack, targetDir, options.subFolders ); } } if (options.offset) { const offsetSpinner = ora( 'Applying timing offset to extracted files...' ).start(); let offsetSuccessful = 0; let offsetFailed = 0; for (const result of results) { if (result.success && result.outputFile) { const outputPath = require('path').join( targetDir, result.outputFile ); const offsetResult = await applyOffsetToFile( outputPath, options.offset ); if (offsetResult.success) { offsetSuccessful++; result.offsetApplied = true; } else { offsetFailed++; result.offsetError = offsetResult.error; } } } if (offsetFailed === 0) { offsetSpinner.succeed( `Timing offset applied to ${offsetSuccessful} files` ); } else { offsetSpinner.warn( `Timing offset: ${offsetSuccessful} successful, ${offsetFailed} failed` ); } } const successful = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; logger.separator(); logger.success( `Extraction completed: ${successful} successful, ${failed} failed` ); if (options.offset) { const offsetSuccessful = results.filter( (r) => r.success && r.offsetApplied ).length; const offsetFailed = results.filter( (r) => r.success && r.offsetError ).length; if (offsetSuccessful > 0 || offsetFailed > 0) { logger.info( `Timing offset (${options.offset}ms): ${offsetSuccessful} applied, ${offsetFailed} failed` ); } } if (isLogs) { results.forEach((result) => { if (result.success) { if (result.track) { logger.info( ` ✓ ${result.filename} Track ${result.track.trackNumber} → ${result.outputFile}` ); if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } else if (result.trackUsed !== undefined) { const trackInfo = result.trackInfo ? ` (${result.trackInfo.language}${ result.trackInfo.languageDetail ? ' ' + result.trackInfo.languageDetail : '' })` : ''; logger.info( ` ✓ ${result.filename} Track ${result.trackUsed}${trackInfo} → ${result.outputFile}` ); if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } else { logger.info(` ✓ ${result.filename} → ${result.outputFile}`); if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } } else { logger.info(` ✗ ${result.filename}: ${result.error}`); } }); } } else if (inputType.type === 'playlist') { const playlistId = inputType.value; logger.header('Playlist-based Subtitle Extraction'); logger.info(`Playlist ID: ${playlistId}`); logger.info( `Directory: ${folderPath === '.' ? 'Current directory' : folderPath}` ); logger.info(`Subtitle track: ${subtitleTrack}`); if (options.offset) { logger.info( `Timing offset: ${options.offset}ms (${ options.offset >= 0 ? 'forward' : 'backward' })` ); } if (isLogs) { logger.info(`Logs: Detailed logging is active`); } logger.separator(); const config = new ConfigManager(); const peertubeConfig = config.getPeerTubeConfig(); const spinner = ora('Fetching playlist videos...').start(); try { const { matches, results } = await subtitleService.extractFromPlaylist( playlistId, subtitleTrack, peertubeConfig.apiUrl, folderPath, options.offset || 0, options.subFolders ); spinner.succeed(`Found ${matches.length} matches`); logger.info('Matches found:'); matches.forEach((match, index) => { logger.info( `${index + 1}. ${match.localFile} ↔ ${ match.peertubeVideo.video.name }`, 1 ); }); logger.separator(); const successful = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; logger.success( `Extraction completed: ${successful} successful, ${failed} failed` ); if (options.offset) { const offsetSuccessful = results.filter( (r) => r.success && r.offsetApplied ).length; const offsetFailed = results.filter( (r) => r.success && r.offsetError ).length; if (offsetSuccessful > 0 || offsetFailed > 0) { logger.info( `Timing offset (${options.offset}ms): ${offsetSuccessful} applied, ${offsetFailed} failed` ); } } if (isLogs) { logger.info('Detailed extraction results:'); results.forEach((result) => { if (result.success) { logger.info( ` ✓ ${result.match.localFile} → ${result.outputFile}` ); if (result.offsetApplied) { logger.info( ` ✓ Timing offset applied: ${options.offset}ms` ); } else if (result.offsetError) { logger.info( ` ✗ Timing offset failed: ${result.offsetError}` ); } } else { logger.info(` ✗ ${result.match.localFile}: ${result.error}`); } }); } } catch (error) { spinner.fail(`Failed to process playlist: ${error.message}`); process.exit(1); } } else { if (!input) { logger.header( `${ options.subFolders ? 'Directory Tree' : 'Current Directory' } Subtitle Extraction` ); logger.info( `Directory: ${ folderPath === '.' ? 'Current directory' : folderPath }` ); if (options.subFolders) { logger.info('Including subfolders: Yes'); } if (options.all) { logger.info('Mode: Extract all subtitle tracks'); } else if (subtitleTrack !== null) { logger.info(`Subtitle track: ${subtitleTrack}`); } else { logger.info('Subtitle track: Auto-detect Spanish Latino'); } if (translationConfig) { logger.info('Translation: Enabled'); } if (options.offset) { logger.info( `Timing offset: ${options.offset}ms (${ options.offset >= 0 ? 'forward' : 'backward' })` ); } logger.separator(); const spinner = ora('Finding local video files...').start(); const localFiles = await subtitleService.getLocalVideoFiles( folderPath, options.subFolders ); spinner.succeed(`Found ${localFiles.length} local video files`); if (localFiles.length === 0) { logger.warning(`No video files found in directory: ${folderPath}`); return; } logger.info('Extracting subtitles...'); let results; if (options.all) { if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extracting subtitles...'; } }; results = await subtitleService.extractAllSubtitlesFromFolder( folderPath, options.subFolders ); } else { results = await subtitleService.extractAllSubtitlesFromFolder( folderPath, options.subFolders ); } } else { if (translationConfig) { const onProgress = (progress) => { if (progress.type === 'translation_start') { spinner.text = `Translating ${require('path').basename( progress.file )}...`; } else if (progress.type === 'translation_complete') { spinner.text = 'Extracting subtitles...'; } }; results = await subtitleService.extractAllLocalSubtitlesWithTranslation( subtitleTrack, folderPath, translationConfig, onProgress, options.subFolders ); } else { results = await subtitleService.extractAllLocalSubtitles( subtitleTrack, folderPath, options.subFolders ); } } if (options.offset) { const offsetSpinner = ora( 'Applying timing offset to extracted files...' ).start(); let offsetSuccessful = 0; let offsetFailed = 0; for (const result of results) { if (result.success && result.outputFile) { const outputPath = require('path').join( folderPath, result.outputFile ); const offsetResult = await applyOffsetToFile( outputPath, options.offset ); if (offsetResult.success) { offsetSuccessful++; result.offsetApplied = true; } else { offsetFailed++; result.offsetError = offsetResult.error; } } } if (offsetFailed === 0) { offsetSpinner.succeed( `Timing offset applied to ${offsetSuccessful} files` ); } else { offsetSpinner.warn( `Timing offset: ${offsetSuccessful} successful, ${offsetFailed} failed` ); } } const successful = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; logger.separator(); logger.success( `Extraction completed: ${successful} successful, ${failed} failed` ); if (options.offset) { const offsetSuccessful = results.filter( (r) => r.success && r.offsetApplied ).length; const offsetFailed = results.filter( (r) => r.success && r.offsetError ).length; if (offsetSuccessful > 0 || offsetFailed > 0) { logger.info( `Timing offset (${options.offset}ms): ${offsetSuccessful} applied, ${offsetFailed} failed` ); } } if (isLogs) { results.forEach((result) => { if (result.success) { if (result.track) { logger.info( ` ✓ ${result.filename} Track ${result.track.trackNumber} → ${result.outputFile}` ); if (result.offsetApplied) { logger.info( ` ✓ Timing offset applied: ${options.offset}ms` ); } else if (result.offsetError) { logger.info( ` ✗ Timing offset failed: ${result.offsetError}` ); } if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } else if (result.trackUsed !== undefined) { const trackInfo = result.trackInfo ? ` (${result.trackInfo.language}${ result.trackInfo.languageDetail ? ' ' + result.trackInfo.languageDetail : '' })` : ''; logger.info( ` ✓ ${result.filename} Track ${result.trackUsed}${trackInfo} → ${result.outputFile}` ); if (result.offsetApplied) { logger.info( ` ✓ Timing offset applied: ${options.offset}ms` ); } else if (result.offsetError) { logger.info( ` ✗ Timing offset failed: ${result.offsetError}` ); } if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } else { logger.info(` ✓ ${result.filename} → ${result.outputFile}`); if (result.offsetApplied) { logger.info( ` ✓ Timing offset applied: ${options.offset}ms` ); } else if (result.offsetError) { logger.info( ` ✗ Timing offset failed: ${result.offsetError}` ); } if (result.translationResult) { logger.info( ` ✓ Translation → ${require('path').basename( result.translationResult.outputPath )}` ); } else if (result.translationError) { logger.info( ` ✗ Translation failed: ${result.translationError}` ); } } } else { logger.info(` ✗ ${result.filename}: ${result.error}`); } }); } } else { logger.error(`Unable to determine input type: "${input}"`); logger.info('Input can be:'); logger.info(' - A video file path (e.g., "./video.mkv")'); logger.info(' - A directory path (e.g., "./videos/")'); logger.info( ' - A PeerTube playlist ID (e.g., "123" or "tgjYS5VH2vJFkp3fCVcmP5")' ); process.exit(1); } } } catch (error) { console.log(error); logger.error(`Subtitle extraction failed: ${error.message}`); process.exit(1); } }); subtitlesCommand .command('translate') .description('Translate subtitle files using AI') .argument( '[file]', 'subtitle file path (.ass format) - if not provided, translates all .ass files in current directory' ) .option( '--output <path>', 'output file path (default: adds _translated suffix)' ) .option('--prompt <path>', 'custom system prompt file path') .option( '--max-dialogs <number>', 'maximum number of dialogs to translate', parseInt ) .option('--logs', 'detailed output') .option('--quiet, -q', 'quiet mode') .action(async (file, options) => { const isLogs = options.logs || false; const logger = new Logger({ verbose: false, quiet: options.quiet || false, }); try { const config = new ConfigManager(); const translationConfig = config.getTranslationConfig(); if (!translationConfig.apiKey) { logger.error( 'Claude API key not configured. Please set CLAUDE_API_KEY in your config or environment variables.' ); logger.info('Run "anitorrent config setup" to set up configuration'); process.exit(1); } const fs = require('fs').promises; const path = require('path'); if (file) { const pathValidation = await Validators.validateFilePath(file); const subtitleFile = pathValidation.resolvedPath; try { const stats = await fs.stat(subtitleFile); if (!stats.isFile()) { logger.error(`Path "${file}" is not a file`); process.exit(1); } } catch (error) { logger.error(`File not found: "${file}"`); if (pathValidation.originalPath !== pathValidation.resolvedPath) { logger.error(`Resolved path: "${pathValidation.resolvedPath}"`); } process.exit(1); } if (!subtitleFile.toLowerCase().endsWith('.ass')) { logger.error( 'Only .ass subtitle files are supported for translation' ); process.exit(1); } logger.header('AI Subtitle Translation'); logger.info(`File: ${subtitleFile}`); if (options.output) { logger.info(`Output: ${options.output}`); } if (options.prompt) { logger.info(`Custom prompt: ${options.prompt}`); } if (options.maxDialogs) { logger.info(`Max dialogs: ${options.maxDialogs}`); } logger.separator(); const translationService = new TranslationService(translationConfig); let currentGroup = 0; let totalGroups = 0; let spinner = ora('Initializing translation...').start(); const onProgress = (progress) => { switch (progress.type) { case 'start': totalGroups = progress.totalGroups; spinner.succeed( `Found ${progress.totalDialogs} dialog lines in ${totalGroups} groups` ); spinner = ora(`Translating group 1/${totalGroups}...`).start(); break; case 'progress': currentGroup = progress.currentGroup; spinner.text = `Translating group ${currentGroup}/${totalGroups}...`; break; case 'error': if (!logger.quiet) { logger.warning(`Translation warning: ${progress.message}`); } break; case 'complete': spinner.succeed( `Translation completed: ${progress.translatedCount} lines translated` ); break; } }; const translationOptions = { outputPath: options.output, customPromptPath: options.prompt, maxDialogs: options.maxDialogs, onProgress, }; const result = await translationService.translateSubtitles( subtitleFile, translationOptions ); if (result.success) { logger.separator(); logger.success(`Translation completed successfully!`); logger.info(`Original file: ${subtitleFile}`); logger.info(`Translated file: ${result.outputPath}`); logger.info( `Lines translated: ${result.translatedCount}/${result.originalCount}` ); } else { logger.error('Translation failed'); process.exit(1); } } else { const currentDir = process.cwd(); logger.header('Batch AI Subtitle Translation'); logger.info(`Directory: ${currentDir}`); if (options.prompt) { logger.info(`Custom prompt: ${options.prompt}`); } if (options.maxDialogs) { logger.info(`Max dialogs: ${options.maxDialogs}`); } logger.separator(); const spinner = ora('Finding .ass subtitle files...').start(); try { const files = await fs.readdir(currentDir); const allAssFiles = files.filter((file) => file.toLowerCase().endsWith('.ass') ); const assFiles = allAssFiles.filter((file) => { const fileName = file.toLowerCase(); if (fileName.includes('_translated')) { return false; } const baseName = path.parse(file).name; const translatedVersion = `${baseName}_translated.ass`; if ( allAssFiles.some( (f) => f.toLowerCase() === translatedVersion.toLowerCase() ) ) { return false; } return true; }); spinner.succeed( `Found ${assFiles.length} .ass files to translate (${ allAssFiles.length - assFiles.length } files ignored)` ); if (assFiles.length === 0) { logger.warning('No .ass subtitle files found in current directory'); return; } logger.info('Files to translate:'); assFiles.forEach((file, index) => { logger.info(`${index + 1}. ${file}`, 1); }); logger.separator(); const translationService = new TranslationService(translationConfig); const results = []; for (let i = 0; i < assFiles.length; i++) { const assFile = assFiles[i]; const fullPath = path.join(currentDir, assFile); logger.info(`Translating ${i + 1}/${assFiles.length}: ${assFile}`); let currentGroup = 0; let totalGroups = 0; let fileSpinner = ora('Initializing translation...').start(); const onProgress = (progress) => { switch (progress.type) { case 'start': totalGroups = progress.totalGroups; fileSpinner.succeed( `Found ${progress.totalDialogs} dialog lines in ${totalGroups} groups` ); fileSpinner = ora( `Translating group 1/${totalGroups}...` ).start(); break; case 'progress': currentGroup = progress.currentGroup; fileSpinner.text = `Translating group ${currentGroup}/${totalGroups}...`; break; case 'error': if (!logger.quiet) { logger.warning(`Translation warning: ${progress.message}`); } break; case 'complete': fileSpinner.succeed( `Translation completed: ${progress.translatedCount} lines translated` ); break; } }; const translationOptions = { customPromptPath: options.prompt, maxDialogs: options.maxDialogs, onProgress, }; try { const result = await translationService.translateSubtitles( fullPath, translationOptions ); if (result.success) { logger.success( `✓ ${assFile} → ${path.basename(result.outputPath)}` ); results.push({ file: assFile, success: true, result }); } else { logger.error(`✗ ${assFile}: Translation failed`); results.push({ file: assFile, success: false, error: 'Translation failed', }); } } catch (error) { fileSpinner.fail(`Translation failed: ${error.message}`); logger.error(`✗ ${assFile}: ${error.message}`); results.push({ file: assFile, success: false, error: error.message, }); } if (i < assFiles.length - 1) { logger.separator(); } } logger.separator(); const successful = results.filter((r) => r.success).length; const failed = results.filter((r) => !r.success).length; logger.success( `Batch translation completed: ${successful} successful, ${failed} failed` ); if (isLogs && failed > 0) { logger.info('Failed translations:'); results .filter((r) => !r.success) .forEach((result) => { logger.info(` ✗ ${result.file}: ${result.error}`); }); } } catch (error) { spinner.fail(`Failed to read directory: ${error.message}`); process.exit(1); } } } catch (error) { logger.error(`Translation failed: ${error.message}`); process.exit(1); } }); subtitlesCommand .command('rename') .description('Rename subtitle files with various naming patterns') .argument( '[pattern]', 'renaming pattern, directory path, or PeerTube playlist ID (default: current directory)' ) .option('--include-translated', 'also rename files with _translated suffix') .option( '--anitomy', 'use anitomy parsing to generate anime-style names (Title_S01E01)' ) .option('--prefix <text>', 'add prefix to all filenames') .option('--suffix <text>', 'add suffix to all filenames (before extension)') .option( '--replace <from,to>', 'replace text in filenames (format: "old,new")' ) .option( '--playlist', 'treat pattern as PeerTube playlist ID and rename using shortUUID' ) .option( '--folder <path>', 'folder path to search for videos when using playlist mode (default: current directory)' ) .option('--sub-folders', 'search for subtitle files in subfolders as well') .option( '--auto-translate', 'automatically translate selected Latino subtitles' ) .option('--dry-run', 'show what would be renamed without actually renaming') .option('