UNPKG

@tiahui/anitorrent-cli

Version:

CLI tool for video management with PeerTube and Storj S3

732 lines (631 loc) 23.5 kB
const { Command } = require('commander'); const ora = require('ora'); const path = require('path'); const inquirer = require('inquirer'); const ConfigManager = require('../utils/config'); const { Logger } = require('../utils/logger'); const Validators = require('../utils/validators'); const S3Service = require('../services/s3-service'); const PeerTubeService = require('../services/peertube-service'); const AniTorrentService = require('../services/anitorrent-service'); const TorrentService = require('../services/torrent-service'); const SubtitleService = require('../services/subtitle-service'); const UploadService = require('../services/upload-service'); const anitomy = require('anitomyscript'); async function scanDirectoryForVideos(dir, recursive = false, logger) { const fs = require('fs').promises; const foundFiles = []; async function scanDir(currentDir, relativePath = '') { try { const entries = await fs.readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); const relativeFilePath = path.join(relativePath, entry.name); if (entry.isFile()) { if (Validators.isValidVideoFile(fullPath)) { const stats = await fs.stat(fullPath); foundFiles.push({ originalPath: relativeFilePath, resolvedPath: fullPath, fileName: entry.name, downloadedFromTorrent: false, size: stats.size, directory: relativePath || '.', }); } } else if (entry.isDirectory() && recursive) { await scanDir(fullPath, relativeFilePath); } } } catch (error) { // Skip verbose logging here since scanDirectoryForVideos doesn't have access to isLogs } } await scanDir(dir); return foundFiles; } async function scanDirectoryForSubtitles(dir, recursive = false, logger) { const fs = require('fs').promises; const foundFiles = []; async function scanDir(currentDir, relativePath = '') { try { const entries = await fs.readdir(currentDir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(currentDir, entry.name); const relativeFilePath = path.join(relativePath, entry.name); if (entry.isFile()) { if (Validators.isValidSubtitleFile(fullPath)) { const stats = await fs.stat(fullPath); foundFiles.push({ originalPath: relativeFilePath, resolvedPath: fullPath, fileName: entry.name, size: stats.size, directory: relativePath || '.', }); } } else if (entry.isDirectory() && recursive) { await scanDir(fullPath, relativeFilePath); } } } catch (error) { // Skip verbose logging here since scanDirectoryForSubtitles doesn't have access to isLogs } } await scanDir(dir); return foundFiles; } const uploadCommand = new Command('upload'); uploadCommand.description('File upload operations'); uploadCommand .command('r2') .description('Upload file to S3') .argument('<file>', 'file to upload (supports absolute and relative paths)') .option('--name <name>', 'custom name for uploaded file') .option('--timestamp', 'add timestamp to filename') .action(async (file, options) => { const isLogs = uploadCommand.parent?.opts()?.logs || false; const logger = new Logger({ verbose: false, quiet: uploadCommand.parent?.opts()?.quiet || false, }); try { const fileValidation = await Validators.validateFilePath(file); if (!fileValidation.exists) { logger.error(`File not found: "${fileValidation.originalPath}"`); if (fileValidation.originalPath !== fileValidation.resolvedPath) { logger.error(`Resolved path: "${fileValidation.resolvedPath}"`); } process.exit(1); } const resolvedFile = fileValidation.resolvedPath; if (isLogs) { logger.info(`Using file: ${resolvedFile}`); } const config = new ConfigManager(); config.validateRequired(); const r2Config = config.getR2Config(); const s3Service = new S3Service(r2Config); let uploadFileName = options.name; if (options.timestamp) { const ext = path.extname(resolvedFile); const nameWithoutExt = path.basename(options.name || resolvedFile, ext); const timestamp = Date.now(); uploadFileName = `${nameWithoutExt}_${timestamp}${ext}`; } else if (!uploadFileName) { uploadFileName = path.basename(resolvedFile); } const fs = require('fs').promises; const stats = await fs.stat(resolvedFile); const fileSize = Validators.formatFileSize(stats.size); logger.header('Upload to Cloudflare R2'); logger.info(`File: ${fileValidation.originalPath}`); logger.info(`Resolved path: ${resolvedFile}`); logger.info(`Size: ${fileSize}`); logger.info(`Upload name: ${uploadFileName}`); logger.separator(); const spinner = ora('Uploading to R2...').start(); try { const result = await s3Service.uploadFile( resolvedFile, `videos/${uploadFileName}`, true ); spinner.succeed('Upload completed successfully'); logger.success('Upload Details:'); logger.info(`Public URL: ${result.publicUrl}`, 1); logger.info(`ETag: ${result.ETag}`, 1); logger.info(`Location: ${result.Location}`, 1); } catch (error) { spinner.fail(`Upload failed: ${error.message}`); process.exit(1); } } catch (error) { logger.error(`Upload failed: ${error.message}`); process.exit(1); } }); uploadCommand .command('auto') .description('Upload to R2 and automatically import to PeerTube') .argument( '[file]', 'file to upload (supports absolute and relative paths) or torrent URL/magnet when using --torrent. If not specified, uploads all video files from current directory' ) .option('--torrent', 'download file from torrent URL or magnet link') .option( '--name <name>', 'custom name for the video (ignored when processing multiple files)' ) .option('--timestamp', 'add timestamp to filename') .option('--channel <id>', 'PeerTube channel ID') .option('--privacy <level>', 'privacy level (1-5)') .option('--password <password>', 'video password') .option('--wait <minutes>', 'max wait time for processing', '120') .option('--keep-r2', 'keep file in R2 after import') .option('--anime-id <id>', 'AniList anime ID for episode update') .option('--sub-folders', 'search for video files in subfolders as well') .option('--use-title', 'use the title of the video for the upload name') .option( '--track <number>', 'subtitle track number for extraction (if not specified, auto-finds Spanish Latino)' ) .option('--audio', 'extract and upload all audio tracks to R2') .action(async (file, options) => { const isLogs = uploadCommand.parent?.opts()?.logs || false; const logger = new Logger({ verbose: false, quiet: uploadCommand.parent?.opts()?.quiet || false, }); if (options.torrent && !file) { logger.error( 'You must specify a torrent URL/magnet when using --torrent option' ); process.exit(1); } let filesToProcess = []; let torrentService = null; try { if (!file) { const currentDir = process.cwd(); const searchSubfolders = options.subFolders; logger.header( `Scanning ${ searchSubfolders ? 'Directory Tree' : 'Current Directory' } for Video Files` ); logger.info(`Directory: ${currentDir}`); if (searchSubfolders) { logger.info('Including subfolders: Yes'); } logger.separator(); const scanSpinner = ora('Scanning for video files...').start(); filesToProcess = await scanDirectoryForVideos( currentDir, searchSubfolders, logger ); scanSpinner.succeed('Scan completed'); if (filesToProcess.length === 0) { logger.error( `No video files found in the ${ searchSubfolders ? 'directory tree' : 'current directory' }` ); process.exit(1); } logger.success(`Found ${filesToProcess.length} video file(s):`); const groupedFiles = {}; filesToProcess.forEach((fileInfo) => { if (!groupedFiles[fileInfo.directory]) { groupedFiles[fileInfo.directory] = []; } groupedFiles[fileInfo.directory].push(fileInfo); }); Object.keys(groupedFiles) .sort() .forEach((dir) => { logger.info(`📁 ${dir}:`, 1); groupedFiles[dir].forEach((fileInfo, index) => { const fileSize = Validators.formatFileSize(fileInfo.size); logger.info( ` ${index + 1}. ${fileInfo.fileName} (${fileSize})`, 2 ); }); }); logger.separator(); const totalSize = filesToProcess.reduce( (sum, file) => sum + file.size, 0 ); logger.info(`Total files: ${filesToProcess.length}`); logger.info(`Total size: ${Validators.formatFileSize(totalSize)}`); logger.separator(); const { proceed } = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: 'Do you want to proceed with uploading these files?', default: false, }, ]); if (!proceed) { logger.info('Upload cancelled by user'); process.exit(0); } logger.separator(); } else if (options.torrent) { logger.header('Torrent Download Process'); logger.info(`Torrent URL/Magnet: ${file}`); logger.separator(); const config = new ConfigManager(); config.validateRequired(); const uploadService = new UploadService(config, logger); try { const downloadResult = await uploadService.downloadFromTorrent( file, logger ); filesToProcess = [downloadResult.fileInfo]; torrentService = downloadResult.torrentService; } catch (error) { process.exit(1); } } else { const fileValidation = await Validators.validateFilePath(file); if (!fileValidation.exists) { logger.error(`File not found: "${fileValidation.originalPath}"`); if (fileValidation.originalPath !== fileValidation.resolvedPath) { logger.error(`Resolved path: "${fileValidation.resolvedPath}"`); } process.exit(1); } if (!Validators.isValidVideoFile(fileValidation.resolvedPath)) { logger.warning('File does not appear to be a video file'); } filesToProcess = [ { originalPath: fileValidation.originalPath, resolvedPath: fileValidation.resolvedPath, fileName: path.basename(fileValidation.resolvedPath), downloadedFromTorrent: false, }, ]; if (isLogs) { logger.info(`Using file: ${fileValidation.resolvedPath}`); } } const config = new ConfigManager(); config.validateRequired(); const r2Config = config.getR2Config(); const peertubeConfig = config.getPeerTubeConfig(); const defaults = config.getDefaults(); const channelId = options.channel ? parseInt(options.channel) : await config.getDefaultChannelId(); const privacy = options.privacy ? parseInt(options.privacy) : defaults.privacy; const videoPassword = options.password || defaults.videoPassword; const maxWaitMinutes = parseInt(options.wait); const keepR2File = options.keepR2; const animeId = options.animeId; 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 (!Validators.isValidChannelId(channelId)) { logger.error('Invalid channel ID'); process.exit(1); } if (!Validators.isValidPrivacyLevel(privacy)) { logger.error('Invalid privacy level (must be 1-5)'); process.exit(1); } logger.header( `Auto Upload & Import Process - ${filesToProcess.length} file(s)` ); logger.info(`Channel ID: ${channelId}`); logger.info(`Privacy: ${privacy}`); logger.info(`Keep R2 file: ${keepR2File ? 'Yes' : 'No'}`); logger.info(`Max wait time: ${maxWaitMinutes} minutes`); if (subtitleTrack !== null) { logger.info(`Subtitle track: ${subtitleTrack}`); } else { logger.info('Subtitle track: Auto-detect Spanish Latino'); } if (animeId) { logger.info(`Anime ID: ${animeId}`); } if (options.audio) { logger.info('Audio extraction: Enabled (all tracks to audios/ folder)'); } logger.separator(); const results = []; const errors = []; const uploadService = new UploadService(config, logger); for (let i = 0; i < filesToProcess.length; i++) { const fileInfo = filesToProcess[i]; const currentFile = i + 1; const totalFiles = filesToProcess.length; logger.header( `Processing File ${currentFile}/${totalFiles}: ${fileInfo.fileName}` ); try { const uploadOptions = { channelId, privacy, videoPassword, maxWaitMinutes, keepR2File, animeId, subtitleTrack, extractAudio: options.audio, customName: options.name && filesToProcess.length === 1 ? options.name : null, timestamp: options.timestamp, useTitle: options.useTitle, }; const result = await uploadService.processFileUpload( fileInfo, uploadOptions ); if (fileInfo.downloadedFromTorrent && torrentService) { await uploadService.cleanupTorrentFile(fileInfo, torrentService); } results.push(result); logger.success( `✅ File ${currentFile}/${totalFiles} completed successfully` ); } catch (error) { logger.error( `❌ File ${currentFile}/${totalFiles} failed: ${error.message}` ); errors.push({ fileName: fileInfo.fileName, error: error.message, }); if (fileInfo.downloadedFromTorrent && torrentService) { try { await uploadService.cleanupTorrentFile(fileInfo, torrentService); } catch (cleanupError) { logger.error( `Failed to cleanup torrent file: ${cleanupError.message}` ); } } } logger.separator(); } logger.header('Batch Process Summary'); logger.info(`Total files processed: ${filesToProcess.length}`); logger.info(`Successful uploads: ${results.length}`); logger.info(`Failed uploads: ${errors.length}`); logger.separator(); if (results.length > 0) { logger.success('Successfully processed files:'); results.forEach((result, index) => { logger.info(`${index + 1}. ${result.fileName}`, 1); if (result.video) { logger.info(` Video ID: ${result.video.id}`, 2); logger.info(` Watch URL: ${result.video.url}`, 2); logger.info( ` Embed URL: ${peertubeConfig.apiUrl.replace( '/api/v1', '' )}/videos/embed/${result.video.shortUUID}`, 2 ); } if (result.keepR2File) { logger.info(` R2 File: ${result.videoUrl}`, 2); } else { logger.info(` R2 File: Deleted`, 2); } }); logger.separator(); } if (errors.length > 0) { logger.error('Failed files:'); errors.forEach((error, index) => { logger.info(`${index + 1}. ${error.fileName}: ${error.error}`, 1); }); logger.separator(); if (results.length === 0) { process.exit(1); } } } catch (error) { logger.error(`Auto upload failed: ${error.message}`); if (torrentService) { try { filesToProcess.forEach(async (fileInfo) => { if (fileInfo.downloadedFromTorrent) { await torrentService.cleanupFile(fileInfo.resolvedPath); } }); torrentService.destroy(); } catch (cleanupError) { if (isLogs) { logger.info( `Failed to cleanup torrent files: ${cleanupError.message}` ); } } } process.exit(1); } }); uploadCommand .command('subtitles') .description('Upload all subtitle files (.ass) from current directory to R2') .option('--sub-folders', 'search for subtitle files in subfolders as well') .option('--timestamp', 'add timestamp to filenames') .action(async (options) => { const isLogs = uploadCommand.parent?.opts()?.logs || false; const logger = new Logger({ verbose: false, quiet: uploadCommand.parent?.opts()?.quiet || false, }); try { const currentDir = process.cwd(); const searchSubfolders = options.subFolders; logger.header( `Scanning ${ searchSubfolders ? 'Directory Tree' : 'Current Directory' } for Subtitle Files` ); logger.info(`Directory: ${currentDir}`); if (searchSubfolders) { logger.info('Including subfolders: Yes'); } logger.separator(); const scanSpinner = ora('Scanning for subtitle files...').start(); const filesToProcess = await scanDirectoryForSubtitles( currentDir, searchSubfolders, logger ); scanSpinner.succeed('Scan completed'); if (filesToProcess.length === 0) { logger.error( `No subtitle files found in the ${ searchSubfolders ? 'directory tree' : 'current directory' }` ); process.exit(1); } logger.success(`Found ${filesToProcess.length} subtitle file(s):`); const groupedFiles = {}; filesToProcess.forEach((fileInfo) => { if (!groupedFiles[fileInfo.directory]) { groupedFiles[fileInfo.directory] = []; } groupedFiles[fileInfo.directory].push(fileInfo); }); Object.keys(groupedFiles) .sort() .forEach((dir) => { logger.info(`📁 ${dir}:`, 1); groupedFiles[dir].forEach((fileInfo, index) => { const fileSize = Validators.formatFileSize(fileInfo.size); logger.info( ` ${index + 1}. ${fileInfo.fileName} (${fileSize})`, 2 ); }); }); logger.separator(); const totalSize = filesToProcess.reduce( (sum, file) => sum + file.size, 0 ); logger.info(`Total files: ${filesToProcess.length}`); logger.info(`Total size: ${Validators.formatFileSize(totalSize)}`); logger.separator(); const { proceed } = await inquirer.prompt([ { type: 'confirm', name: 'proceed', message: 'Do you want to proceed with uploading these subtitle files?', default: false, }, ]); if (!proceed) { logger.info('Upload cancelled by user'); process.exit(0); } logger.separator(); const config = new ConfigManager(); config.validateRequired(); const r2Config = config.getR2Config(); const s3Service = new S3Service(r2Config); const results = []; const errors = []; for (let i = 0; i < filesToProcess.length; i++) { const fileInfo = filesToProcess[i]; const currentFile = i + 1; const totalFiles = filesToProcess.length; logger.header( `Uploading File ${currentFile}/${totalFiles}: ${fileInfo.fileName}` ); try { let uploadFileName = fileInfo.fileName; if (options.timestamp) { const ext = path.extname(fileInfo.resolvedPath); const nameWithoutExt = path.basename(fileInfo.resolvedPath, ext); const timestamp = Date.now(); uploadFileName = `${nameWithoutExt}_${timestamp}${ext}`; } const fileSize = Validators.formatFileSize(fileInfo.size); logger.info(`File: ${fileInfo.originalPath}`); logger.info(`Size: ${fileSize}`); logger.info(`Upload name: ${uploadFileName}`); logger.separator(); const spinner = ora('Uploading to R2...').start(); const result = await s3Service.uploadFile( fileInfo.resolvedPath, `subtitles/${uploadFileName}`, true ); spinner.succeed('Upload completed'); logger.info(`Public URL: ${result.publicUrl}`, 1); logger.info(`ETag: ${result.ETag}`, 1); results.push({ fileName: fileInfo.fileName, uploadName: uploadFileName, success: true, publicUrl: result.publicUrl, size: fileInfo.size, }); logger.success( `✅ File ${currentFile}/${totalFiles} uploaded successfully` ); } catch (error) { logger.error( `❌ File ${currentFile}/${totalFiles} failed: ${error.message}` ); errors.push({ fileName: fileInfo.fileName, error: error.message, }); } logger.separator(); } logger.header('Subtitle Upload Summary'); logger.info(`Total files processed: ${filesToProcess.length}`); logger.info(`Successful uploads: ${results.length}`); logger.info(`Failed uploads: ${errors.length}`); logger.separator(); if (results.length > 0) { logger.success('Successfully uploaded files:'); results.forEach((result, index) => { logger.info(`${index + 1}. ${result.fileName}`, 1); logger.info(` Upload name: ${result.uploadName}`, 2); logger.info(` Public URL: ${result.publicUrl}`, 2); logger.info(` Size: ${Validators.formatFileSize(result.size)}`, 2); }); logger.separator(); } if (errors.length > 0) { logger.error('Failed files:'); errors.forEach((error, index) => { logger.info(`${index + 1}. ${error.fileName}: ${error.error}`, 1); }); logger.separator(); if (results.length === 0) { process.exit(1); } } } catch (error) { logger.error(`Subtitle upload failed: ${error.message}`); process.exit(1); } }); module.exports = uploadCommand;