@tiahui/anitorrent-cli
Version:
CLI tool for video management with PeerTube and Storj S3
1,467 lines (1,335 loc) • 87.4 kB
JavaScript
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('