UNPKG

@tiahui/anitorrent-cli

Version:

CLI tool for video management with PeerTube and Storj S3

960 lines (802 loc) 34.3 kB
const { Command } = require('commander'); const chalk = require('chalk'); const ora = require('ora'); const Table = require('cli-table3'); const ConfigManager = require('../utils/config'); const { logger } = require('../utils/logger'); const PostgreSQLService = require('../services/postgresql-service'); const AniTorrentService = require('../services/anitorrent-service'); const AniZipService = require('../services/anizip-service'); const PeerTubeService = require('../services/peertube-service'); const episodesCommand = new Command('episodes'); episodesCommand.description('Manage anime episodes in database'); episodesCommand .command('list') .description('List all episodes for an anime') .requiredOption('--anilist-id <id>', 'AniList ID of the anime') .option('--format <format>', 'Output format: table, json', 'table') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitorrent config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const dbService = new PostgreSQLService(dbConfig); const spinner = ora('Fetching episodes from database...').start(); try { const episodes = await dbService.getAnimeEpisodes(parseInt(options.anilistId)); spinner.stop(); if (episodes.length === 0) { logger.warning(`No episodes found for anime ID: ${options.anilistId}`); return; } if (options.format === 'json') { console.log(JSON.stringify(episodes, null, 2)); return; } const table = new Table({ head: [ chalk.cyan('Episode'), chalk.cyan('PeerTube ID'), chalk.cyan('Short UUID'), chalk.cyan('Ready'), chalk.cyan('Duration'), chalk.cyan('Created') ], colWidths: [10, 12, 25, 8, 10, 20] }); episodes.forEach(episode => { const readyStatus = episode.isReady ? chalk.green('✓') : chalk.red('✗'); const duration = episode.duration ? `${Math.floor(episode.duration / 60)}:${(episode.duration % 60).toString().padStart(2, '0')}` : 'N/A'; const createdAt = new Date(episode.createdAt).toLocaleDateString(); table.push([ episode.episodeNumber, episode.peertubeId || 'N/A', episode.shortUUID || 'N/A', readyStatus, duration, createdAt ]); }); console.log(`\n${chalk.bold(`Episodes for Anime ID: ${options.anilistId}`)}`); console.log(`${chalk.gray(`Total episodes: ${episodes.length}`)}\n`); console.log(table.toString()); const readyCount = episodes.filter(ep => ep.isReady).length; console.log(`\n${chalk.green(`Ready: ${readyCount}`)} | ${chalk.red(`Not Ready: ${episodes.length - readyCount}`)}`); } catch (error) { spinner.fail('Failed to fetch episodes'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to list episodes: ${error.message}`); process.exit(1); } }); episodesCommand .command('get') .description('Get specific episode details') .requiredOption('--anilist-id <id>', 'AniList ID of the anime') .requiredOption('--episode <number>', 'Episode number') .option('--format <format>', 'Output format: table, json', 'table') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitorrent config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const dbService = new PostgreSQLService(dbConfig); const spinner = ora('Fetching episode from database...').start(); try { const episode = await dbService.getEpisodeByNumber(parseInt(options.anilistId), parseInt(options.episode)); spinner.stop(); if (!episode) { logger.warning(`Episode ${options.episode} not found for anime ID: ${options.anilistId}`); return; } if (options.format === 'json') { console.log(JSON.stringify(episode, null, 2)); return; } const table = new Table({ head: [chalk.cyan('Property'), chalk.cyan('Value')], colWidths: [20, 60] }); const readyStatus = episode.isReady ? chalk.green('Ready') : chalk.red('Not Ready'); const duration = episode.duration ? `${Math.floor(episode.duration / 60)}:${(episode.duration % 60).toString().padStart(2, '0')}` : 'N/A'; table.push( ['ID', episode.id], ['AniList ID', episode.idAnilist], ['Episode Number', episode.episodeNumber], ['PeerTube ID', episode.peertubeId || 'N/A'], ['UUID', episode.uuid || 'N/A'], ['Short UUID', episode.shortUUID || 'N/A'], ['Password', episode.password ? '***' : 'None'], ['Title', episode.title ? JSON.stringify(episode.title) : 'None'], ['Embed URL', episode.embedUrl || 'N/A'], ['Thumbnail URL', episode.thumbnailUrl || 'N/A'], ['Description', episode.description || 'None'], ['Duration', duration], ['Status', readyStatus], ['Created', new Date(episode.createdAt).toLocaleString()], ['Updated', new Date(episode.updatedAt).toLocaleString()] ); console.log(`\n${chalk.bold(`Episode ${episode.episodeNumber} - Anime ID: ${episode.idAnilist}`)}\n`); console.log(table.toString()); } catch (error) { spinner.fail('Failed to fetch episode'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to get episode: ${error.message}`); process.exit(1); } }); episodesCommand .command('stats') .description('Show statistics for anime episodes') .requiredOption('--anilist-id <id>', 'AniList ID of the anime') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitorrent config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const dbService = new PostgreSQLService(dbConfig); const spinner = ora('Calculating statistics...').start(); try { const episodes = await dbService.getAnimeEpisodes(parseInt(options.anilistId)); spinner.stop(); if (episodes.length === 0) { logger.warning(`No episodes found for anime ID: ${options.anilistId}`); return; } const readyCount = episodes.filter(ep => ep.isReady).length; const notReadyCount = episodes.length - readyCount; const totalDuration = episodes.reduce((sum, ep) => sum + (ep.duration || 0), 0); const avgDuration = totalDuration / episodes.length; const table = new Table({ head: [chalk.cyan('Statistic'), chalk.cyan('Value')], colWidths: [25, 25] }); table.push( ['Total Episodes', episodes.length], ['Ready Episodes', chalk.green(readyCount)], ['Not Ready Episodes', chalk.red(notReadyCount)], ['Completion Rate', `${((readyCount / episodes.length) * 100).toFixed(1)}%`], ['Total Duration', `${Math.floor(totalDuration / 60)}:${(totalDuration % 60).toString().padStart(2, '0')}`], ['Average Duration', `${Math.floor(avgDuration / 60)}:${(avgDuration % 60).toString().padStart(2, '0')}`] ); console.log(`\n${chalk.bold(`Statistics for Anime ID: ${options.anilistId}`)}\n`); console.log(table.toString()); } catch (error) { spinner.fail('Failed to calculate statistics'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to get statistics: ${error.message}`); process.exit(1); } }); episodesCommand .command('check-subs') .description('Check episodes without Spanish Latino subtitles') .option('--anilist-id <id>', 'AniList ID of specific anime (if not provided, checks latest episodes)') .option('--format <format>', 'Output format: table, json', 'table') .option('--limit <number>', 'Maximum number of episodes to check', '100') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitorrent config setup" first.'); process.exit(1); } if (!config.get('ANITORRENT_API_KEY')) { logger.error('AniTorrent API key not found. Please run "anitorrent config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const dbService = new PostgreSQLService(dbConfig); const aniTorrentService = new AniTorrentService(config); const spinner = ora('Checking episodes for Spanish Latino subtitles...').start(); try { const limit = parseInt(options.limit); let allEpisodes; let isSpecificAnime = false; if (options.anilistId) { allEpisodes = await dbService.getAnimeEpisodes(parseInt(options.anilistId)); isSpecificAnime = true; if (allEpisodes.length === 0) { spinner.fail('No episodes found'); logger.warning(`No episodes found for anime ID: ${options.anilistId}`); return; } } else { allEpisodes = await dbService.getLatestEpisodes(limit * 2); if (allEpisodes.length === 0) { spinner.fail('No episodes found'); logger.warning('No episodes found in database'); return; } spinner.text = `Checking latest ${Math.min(limit, allEpisodes.length)} episodes for Spanish Latino subtitles...`; } const episodes = allEpisodes.slice(0, limit); if (isSpecificAnime && allEpisodes.length > limit) { spinner.text = `Checking first ${limit} of ${allEpisodes.length} episodes for Spanish Latino subtitles...`; } const episodesWithoutLatino = []; let checkedCount = 0; let processedCount = 0; const totalToProcess = episodes.length; for (const episode of episodes) { processedCount++; const percentage = Math.round((processedCount / totalToProcess) * 100); spinner.text = `Checking episode ${processedCount}/${totalToProcess} (${percentage}%) - Episode ${episode.episodeNumber} for Spanish Latino subtitles...`; if (!episode.shortUUID) { episodesWithoutLatino.push({ ...episode, hasLatino: false, totalSubs: 0, reason: 'No shortUUID', animeTitle: episode.animeTitle || 'Unknown Anime' }); continue; } try { const subtitles = await aniTorrentService.getSubtitles(episode.shortUUID); const hasLatino = subtitles.some(sub => sub.language === 'default'); if (!hasLatino) { episodesWithoutLatino.push({ ...episode, hasLatino: false, totalSubs: subtitles.length, reason: 'No Latino subs', animeTitle: episode.animeTitle || 'Unknown Anime' }); } checkedCount++; } catch (error) { episodesWithoutLatino.push({ ...episode, hasLatino: false, totalSubs: 0, reason: `API Error: ${error.message}`, animeTitle: episode.animeTitle || 'Unknown Anime' }); } } spinner.succeed(`Completed checking ${processedCount} episodes for Spanish Latino subtitles`); if (options.format === 'json') { console.log(JSON.stringify({ mode: options.anilistId ? 'specific_anime' : 'latest_episodes', anilistId: options.anilistId ? parseInt(options.anilistId) : null, totalEpisodes: allEpisodes.length, episodesToCheck: episodes.length, checkedEpisodes: checkedCount, episodesWithoutLatino: episodesWithoutLatino.length, episodes: episodesWithoutLatino }, null, 2)); return; } if (episodesWithoutLatino.length === 0) { console.log(`\n${chalk.bold.green('✓')} All checked episodes have Spanish Latino subtitles!`); if (options.anilistId) { const anime = await dbService.getAnimeById(parseInt(options.anilistId)); let animeTitle = 'Unknown Anime'; if (anime && anime.title) { try { const titleObj = typeof anime.title === 'string' ? JSON.parse(anime.title) : anime.title; animeTitle = titleObj.english || titleObj.romaji || titleObj.native || 'Unknown Anime'; } catch (error) { animeTitle = anime.title.toString(); } } console.log(`${chalk.gray(`Anime: ${animeTitle}`)}`); } else { console.log(`${chalk.gray(`Checked latest episodes from database`)}`); } console.log(`${chalk.gray(`Episodes checked: ${checkedCount}${allEpisodes.length > episodes.length ? ` of ${allEpisodes.length} total` : ''}`)}\n`); return; } const table = new Table({ head: [ chalk.cyan('Episode'), chalk.cyan('AniList ID'), chalk.cyan('Anime Title'), chalk.cyan('Latino Subs'), chalk.cyan('Total Subs'), chalk.cyan('Status') ], colWidths: [10, 12, 30, 12, 12, 20] }); episodesWithoutLatino.forEach(episode => { const latinoStatus = episode.hasLatino ? chalk.green('✓') : chalk.red('✗'); const subsCount = episode.totalSubs > 0 ? episode.totalSubs.toString() : '0'; let displayTitle = 'Unknown Anime'; if (options.anilistId) { if (episode.animeTitle) { try { const titleObj = typeof episode.animeTitle === 'string' ? JSON.parse(episode.animeTitle) : episode.animeTitle; displayTitle = titleObj.english || titleObj.romaji || titleObj.native || 'Unknown Anime'; } catch (error) { displayTitle = episode.animeTitle.toString(); } } } else { if (episode.animeTitle) { try { const titleObj = typeof episode.animeTitle === 'string' ? JSON.parse(episode.animeTitle) : episode.animeTitle; displayTitle = titleObj.english || titleObj.romaji || titleObj.native || 'Unknown Anime'; } catch (error) { displayTitle = episode.animeTitle.toString(); } } } table.push([ episode.episodeNumber, episode.idAnilist, displayTitle.length > 25 ? displayTitle.substring(0, 22) + '...' : displayTitle, latinoStatus, subsCount, episode.reason || 'OK' ]); }); console.log(`\n${chalk.bold(`Episodes without Spanish Latino subtitles`)}`); if (options.anilistId) { const anime = await dbService.getAnimeById(parseInt(options.anilistId)); let animeTitle = 'Unknown Anime'; if (anime && anime.title) { try { const titleObj = typeof anime.title === 'string' ? JSON.parse(anime.title) : anime.title; animeTitle = titleObj.english || titleObj.romaji || titleObj.native || 'Unknown Anime'; } catch (error) { animeTitle = anime.title.toString(); } } console.log(`${chalk.gray(`Anime: ${animeTitle}`)}`); } else { console.log(`${chalk.gray(`Latest episodes from database`)}`); } console.log(`${chalk.gray(`Total episodes: ${allEpisodes.length} | Checked: ${checkedCount} | Missing Latino subs: ${episodesWithoutLatino.length}`)}\n`); console.log(table.toString()); const episodesWithLatino = checkedCount - episodesWithoutLatino.length; const summary = `\n${chalk.red(`Episodes missing Latino subs: ${episodesWithoutLatino.length}`)} | ${chalk.green(`Episodes with Latino subs: ${episodesWithLatino}`)}`; if (allEpisodes.length > episodes.length) { console.log(summary); if (options.anilistId) { console.log(`${chalk.yellow(`Note: Only checked first ${episodes.length} of ${allEpisodes.length} total episodes`)}`); } else { console.log(`${chalk.yellow(`Note: Checked ${episodes.length} latest episodes`)}`); } } else { console.log(summary); } } catch (error) { spinner.fail('Failed to check subtitles'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to check subtitles: ${error.message}`); process.exit(1); } }); episodesCommand .command('update-thumbnails') .description('Update episode thumbnails using ani.zip API') .option('--limit <number>', 'Number of episodes to process', '50') .option('--format <format>', 'Output format: table, json', 'table') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitorrent config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const dbService = new PostgreSQLService(dbConfig); const aniZipService = new AniZipService(); const limit = parseInt(options.limit); const spinner = ora(`Fetching ${limit} episodes for thumbnail update...`).start(); try { const episodesByAnilist = await dbService.getEpisodesForThumbnailUpdate(limit); const anilistIds = Object.keys(episodesByAnilist); if (anilistIds.length === 0) { spinner.fail('No episodes found'); logger.warning('No episodes found in database'); return; } spinner.succeed(`Found ${Object.values(episodesByAnilist).flat().length} episodes from ${anilistIds.length} anime series`); const results = []; let processedAnime = 0; let updatedEpisodes = 0; let skippedEpisodes = 0; let errorCount = 0; for (const anilistId of anilistIds) { processedAnime++; const episodes = episodesByAnilist[anilistId]; const percentage = Math.round((processedAnime / anilistIds.length) * 100); const processingSpinner = ora(`Processing anime ${processedAnime}/${anilistIds.length} (${percentage}%) - AniList ID: ${anilistId}...`).start(); try { const mappings = await aniZipService.getAnimeMappings(anilistId); if (!mappings) { processingSpinner.text = `Skipping anime ${anilistId} - No mappings found in ani.zip`; processingSpinner.succeed(); episodes.forEach(episode => { results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'skipped', reason: 'No ani.zip mappings', oldThumbnail: episode.thumbnailUrl, newThumbnail: null }); skippedEpisodes++; }); continue; } for (const episode of episodes) { const imageUrl = aniZipService.getEpisodeImageUrl(mappings, episode.episodeNumber); if (!imageUrl) { results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'skipped', reason: 'No image in ani.zip', oldThumbnail: episode.thumbnailUrl, newThumbnail: null }); skippedEpisodes++; continue; } if (episode.thumbnailUrl === imageUrl) { results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'skipped', reason: 'Same URL', oldThumbnail: episode.thumbnailUrl, newThumbnail: imageUrl }); skippedEpisodes++; continue; } try { await dbService.updateEpisode(episode.id, { thumbnailUrl: imageUrl }); results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'updated', reason: 'Success', oldThumbnail: episode.thumbnailUrl, newThumbnail: imageUrl }); updatedEpisodes++; } catch (error) { results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'error', reason: `DB Error: ${error.message}`, oldThumbnail: episode.thumbnailUrl, newThumbnail: imageUrl }); errorCount++; } } processingSpinner.succeed(`Processed anime ${anilistId} - ${episodes.length} episodes`); } catch (error) { processingSpinner.fail(`Failed to process anime ${anilistId}`); episodes.forEach(episode => { results.push({ id: episode.id, anilistId: parseInt(anilistId), episodeNumber: episode.episodeNumber, status: 'error', reason: `API Error: ${error.message}`, oldThumbnail: episode.thumbnailUrl, newThumbnail: null }); errorCount++; }); } } if (options.format === 'json') { console.log(JSON.stringify({ totalEpisodes: results.length, updatedEpisodes, skippedEpisodes, errorCount, processedAnime: anilistIds.length, results }, null, 2)); return; } console.log(`\n${chalk.bold('Thumbnail Update Results')}\n`); if (results.length === 0) { console.log(chalk.yellow('No episodes processed')); return; } const table = new Table({ head: [ chalk.cyan('AniList ID'), chalk.cyan('Episode'), chalk.cyan('Status'), chalk.cyan('Reason'), chalk.cyan('Updated') ], colWidths: [12, 10, 12, 25, 10] }); results.forEach(result => { let statusColor = chalk.gray; let statusIcon = '○'; if (result.status === 'updated') { statusColor = chalk.green; statusIcon = '✓'; } else if (result.status === 'error') { statusColor = chalk.red; statusIcon = '✗'; } else { statusColor = chalk.yellow; statusIcon = '-'; } const hasUpdate = result.newThumbnail && result.oldThumbnail !== result.newThumbnail; table.push([ result.anilistId, result.episodeNumber, statusColor(`${statusIcon} ${result.status}`), result.reason.length > 22 ? result.reason.substring(0, 19) + '...' : result.reason, hasUpdate ? chalk.green('Yes') : chalk.gray('No') ]); }); console.log(table.toString()); const summary = [ chalk.green(`Updated: ${updatedEpisodes}`), chalk.yellow(`Skipped: ${skippedEpisodes}`), chalk.red(`Errors: ${errorCount}`) ].join(' | '); console.log(`\n${summary}`); console.log(`${chalk.gray(`Total episodes: ${results.length} | Anime processed: ${anilistIds.length}`)}`); } catch (error) { spinner.fail('Failed to update thumbnails'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to update thumbnails: ${error.message}`); process.exit(1); } }); episodesCommand .command('update-video-data') .description('Update episodes with hlsUrl and videoFiles from PeerTube') .option('--limit <number>', 'Maximum number of episodes to update (0 for all)', '0') .option('--dry-run', 'Show what would be updated without making changes') .action(async (options) => { try { const config = new ConfigManager(); if (!config.get('DB_HOST')) { logger.error('Database configuration not found. Please run "anitlan config setup" first.'); process.exit(1); } if (!config.get('PEERTUBE_API_URL')) { logger.error('PeerTube configuration not found. Please run "anitlan config setup" first.'); process.exit(1); } const dbConfig = config.getDatabaseConfig(); const peertubeConfig = config.getPeerTubeConfig(); const dbService = new PostgreSQLService(dbConfig); const peertubeService = new PeerTubeService(peertubeConfig); const limit = parseInt(options.limit); const isDryRun = options.dryRun || false; const spinner = ora('Fetching episodes that need video data update...').start(); try { let allEpisodes = await dbService.getAllEpisodesWithPeertubeId(); if (allEpisodes.length === 0) { spinner.succeed('All episodes already have video data'); logger.info('No episodes need updating'); return; } const totalEpisodes = allEpisodes.length; const episodes = limit > 0 ? allEpisodes.slice(0, limit) : allEpisodes; spinner.succeed(`Found ${totalEpisodes} episodes needing update${limit > 0 ? ` (processing ${episodes.length})` : ''}`); if (isDryRun) { console.log(`\n${chalk.yellow('DRY RUN MODE - No changes will be made')}\n`); } let processedCount = 0; let updatedCount = 0; let skippedCount = 0; let errorCount = 0; const results = []; const progressSpinner = ora( `Updating episodes... (0/${episodes.length} - 0%)` ).start(); for (const episode of episodes) { processedCount++; const percentage = Math.round((processedCount / episodes.length) * 100); progressSpinner.text = `Updating episodes... (${processedCount}/${episodes.length} - ${percentage}%) - Episode ${episode.episodeNumber} (AniList: ${episode.idAnilist})`; try { const video = await peertubeService.getVideoById(episode.peertubeId); if (!video) { results.push({ id: episode.id, anilistId: episode.idAnilist, episodeNumber: episode.episodeNumber, peertubeId: episode.peertubeId, status: 'skipped', reason: 'Video not found in PeerTube' }); skippedCount++; continue; } const streamingPlaylist = video.streamingPlaylists?.[0]; if (!streamingPlaylist) { results.push({ id: episode.id, anilistId: episode.idAnilist, episodeNumber: episode.episodeNumber, peertubeId: episode.peertubeId, status: 'skipped', reason: 'No streaming playlist' }); skippedCount++; continue; } // Extract path without domain from hlsUrl const hlsUrlFull = streamingPlaylist.playlistUrl; let hlsUrl = hlsUrlFull; try { const url = new URL(hlsUrlFull); hlsUrl = url.pathname; } catch (e) { // If URL parsing fails, use as is } // Extract path without domain from videoFiles URLs const videoFiles = streamingPlaylist.files?.map((file) => { let fileUrl = file.fileUrl; try { const url = new URL(file.fileUrl); fileUrl = url.pathname; } catch (e) { // If URL parsing fails, use as is } return { quality: file.resolution.label, url: fileUrl, size: file.size, resolution: { width: file.width, height: file.height, }, }; }) || []; if (!isDryRun) { await dbService.updateEpisode(episode.id, { hlsUrl: hlsUrl, videoFiles: JSON.stringify(videoFiles) }); } results.push({ id: episode.id, anilistId: episode.idAnilist, episodeNumber: episode.episodeNumber, peertubeId: episode.peertubeId, status: 'updated', reason: 'Success', hlsUrl: hlsUrl, videoFilesCount: videoFiles.length }); updatedCount++; } catch (error) { results.push({ id: episode.id, anilistId: episode.idAnilist, episodeNumber: episode.episodeNumber, peertubeId: episode.peertubeId, status: 'error', reason: error.message }); errorCount++; } } progressSpinner.succeed(`Completed updating ${processedCount} episodes`); console.log(`\n${chalk.bold('Update Summary')}\n`); const table = new Table({ head: [ chalk.cyan('Status'), chalk.cyan('Count'), chalk.cyan('Percentage') ], colWidths: [15, 10, 15] }); table.push( [chalk.green('Updated'), updatedCount, `${((updatedCount / episodes.length) * 100).toFixed(1)}%`], [chalk.yellow('Skipped'), skippedCount, `${((skippedCount / episodes.length) * 100).toFixed(1)}%`], [chalk.red('Errors'), errorCount, `${((errorCount / episodes.length) * 100).toFixed(1)}%`] ); console.log(table.toString()); console.log(`\n${chalk.gray(`Total processed: ${processedCount} of ${totalEpisodes} episodes`)}`); if (limit > 0 && totalEpisodes > limit) { console.log(chalk.yellow(`\nNote: Only processed ${limit} episodes. ${totalEpisodes - limit} episodes remaining.`)); console.log(chalk.gray(`Run without --limit to process all episodes.`)); } if (isDryRun) { console.log(`\n${chalk.yellow('This was a dry run. No changes were made to the database.')}`); console.log(chalk.gray('Run without --dry-run to apply the changes.')); } // Show skipped episodes details const skippedEpisodes = results.filter(r => r.status === 'skipped'); if (skippedEpisodes.length > 0) { console.log(`\n${chalk.bold.yellow('Skipped Episodes:')}\n`); const skipTable = new Table({ head: [ chalk.cyan('Episode'), chalk.cyan('AniList ID'), chalk.cyan('PeerTube ID'), chalk.cyan('Reason') ], colWidths: [10, 12, 15, 80] }); skippedEpisodes.forEach(ep => { skipTable.push([ ep.episodeNumber, ep.anilistId, ep.peertubeId, ep.reason ]); }); console.log(skipTable.toString()); } // Show error episodes details const errorEpisodes = results.filter(r => r.status === 'error'); if (errorEpisodes.length > 0) { console.log(`\n${chalk.bold.red('Failed Episodes:')}\n`); const errorTable = new Table({ head: [ chalk.cyan('Episode'), chalk.cyan('AniList ID'), chalk.cyan('PeerTube ID'), chalk.cyan('Error') ], colWidths: [10, 12, 15, 80] }); errorEpisodes.forEach(ep => { errorTable.push([ ep.episodeNumber, ep.anilistId, ep.peertubeId, ep.reason ]); }); console.log(errorTable.toString()); } } catch (error) { spinner.fail('Failed to update episodes'); throw error; } finally { await dbService.close(); } } catch (error) { logger.error(`Failed to update video data: ${error.message}`); process.exit(1); } }); module.exports = episodesCommand;