/** * Post-Processor Module * Handles the organization and processing of completed downloads */ const fs = require('fs').promises; const path = require('path'); const util = require('util'); const exec = util.promisify(require('child_process').exec); const crypto = require('crypto'); class PostProcessor { constructor(config, transmissionClient) { if (!config) { throw new Error('Configuration is required for Post Processor'); } if (!transmissionClient) { throw new Error('Transmission client is required for Post Processor'); } this.config = config; this.transmissionClient = transmissionClient; this.isProcessing = false; this.processingQueue = []; this.processIntervalId = null; this.checkIntervalSeconds = config.seedingRequirements?.checkIntervalSeconds || 300; this.destinationPaths = config.destinationPaths || {}; this.processingOptions = config.processingOptions || {}; } /** * Start the post-processor * @returns {boolean} Whether the processor started successfully */ start() { if (this.processIntervalId) { console.log('Post-processor is already running'); return false; } console.log(`Starting post-processor, check interval: ${this.checkIntervalSeconds} seconds`); // Run immediately this.checkCompletedDownloads(); // Then set up interval this.processIntervalId = setInterval(() => { this.checkCompletedDownloads(); }, this.checkIntervalSeconds * 1000); return true; } /** * Stop the post-processor * @returns {boolean} Whether the processor stopped successfully */ stop() { if (!this.processIntervalId) { console.log('Post-processor is not running'); return false; } clearInterval(this.processIntervalId); this.processIntervalId = null; console.log('Post-processor stopped'); return true; } /** * Check for completed downloads that meet seeding requirements */ async checkCompletedDownloads() { if (this.isProcessing) { console.log('Post-processor is already running a processing cycle, skipping'); return; } this.isProcessing = true; try { console.log('Checking for completed downloads...'); // Get all torrents const torrentsResult = await this.transmissionClient.getTorrents(); if (!torrentsResult.success) { console.error('Failed to get torrents from Transmission:', torrentsResult.error); this.isProcessing = false; return; } const torrents = torrentsResult.torrents; // Filter completed torrents const completedTorrents = torrents.filter(torrent => torrent.percentDone === 1 && // Fully downloaded torrent.status !== 0 && // Not stopped torrent.doneDate > 0 // Has a completion date ); console.log(`Found ${completedTorrents.length} completed torrents`); // Check each completed torrent against requirements for (const torrent of completedTorrents) { // Skip already processed torrents if (this.processingQueue.includes(torrent.id)) { continue; } // Check if it meets seeding requirements const reqResult = await this.transmissionClient.verifyTorrentSeedingRequirements( torrent.id, this.config.seedingRequirements || {} ); if (!reqResult.success) { console.error(`Error checking requirements for ${torrent.name}:`, reqResult.error); continue; } if (reqResult.requirementsMet) { console.log(`Torrent ${torrent.name} has met seeding requirements, queuing for processing`); // Add to processing queue this.processingQueue.push(torrent.id); // Process the torrent await this.processTorrent(reqResult.torrent); // Remove from queue after processing this.processingQueue = this.processingQueue.filter(id => id !== torrent.id); } else { const { currentRatio, currentSeedingTimeMinutes } = reqResult; const { minRatio, minTimeMinutes } = this.config.seedingRequirements || { minRatio: 1.0, minTimeMinutes: 60 }; console.log(`Torrent ${torrent.name} has not met seeding requirements yet:`); console.log(`- Ratio: ${currentRatio.toFixed(2)} / ${minRatio} (${reqResult.ratioMet ? 'Met' : 'Not Met'})`); console.log(`- Time: ${Math.floor(currentSeedingTimeMinutes)} / ${minTimeMinutes} minutes (${reqResult.timeMet ? 'Met' : 'Not Met'})`); } } } catch (error) { console.error('Error in post-processor cycle:', error); } finally { this.isProcessing = false; } } /** * Process a completed torrent * @param {Object} torrent - Torrent object */ async processTorrent(torrent) { console.log(`Processing torrent: ${torrent.name}`); try { // Get detailed info with file analysis const details = await this.transmissionClient.getTorrentDetails(torrent.id); if (!details.success) { console.error(`Failed to get details for torrent ${torrent.name}:`, details.error); return; } torrent = details.torrent; const mediaInfo = torrent.mediaInfo || { type: 'unknown' }; console.log(`Detected media type: ${mediaInfo.type}`); // Determine destination path based on content type let destinationDir = this.getDestinationPath(mediaInfo.type); if (!destinationDir) { console.error(`No destination directory configured for media type: ${mediaInfo.type}`); return; } // Create the destination directory if it doesn't exist await this.createDirectoryIfNotExists(destinationDir); // If we're creating category folders, add category-specific subdirectory if (this.processingOptions.createCategoryFolders) { const categoryFolder = this.getCategoryFolder(torrent, mediaInfo); if (categoryFolder) { destinationDir = path.join(destinationDir, categoryFolder); await this.createDirectoryIfNotExists(destinationDir); } } console.log(`Processing to destination: ${destinationDir}`); // Process files based on content type if (mediaInfo.type === 'archive' && this.processingOptions.extractArchives) { await this.processArchives(torrent, mediaInfo, destinationDir); } else { await this.processStandardFiles(torrent, mediaInfo, destinationDir); } console.log(`Finished processing torrent: ${torrent.name}`); } catch (error) { console.error(`Error processing torrent ${torrent.name}:`, error); } } /** * Get the appropriate destination path for a media type * @param {string} mediaType - Type of media * @returns {string} Destination path */ getDestinationPath(mediaType) { switch (mediaType) { case 'movie': return this.destinationPaths.movies; case 'tvshow': return this.destinationPaths.tvShows; case 'audio': return this.destinationPaths.music; case 'book': return this.destinationPaths.books; case 'magazine': return this.destinationPaths.magazines; default: return this.destinationPaths.software; } } /** * Generate a category folder name based on the content * @param {Object} torrent - Torrent object * @param {Object} mediaInfo - Media information * @returns {string} Folder name */ getCategoryFolder(torrent, mediaInfo) { const name = torrent.name; switch (mediaInfo.type) { case 'movie': { // For movies, use the first letter of the title const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase(); return firstLetter || '#'; } case 'tvshow': { // For TV shows, extract the show name const showName = name.replace(/[sS]\d{2}[eE]\d{2}.*$/, '').trim(); return showName; } case 'audio': { // For music, try to extract artist name const artistMatch = name.match(/^(.*?)\s*-\s*/); return artistMatch ? artistMatch[1].trim() : 'Unsorted'; } case 'book': { // For books, use the first letter of title or author names const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase(); return firstLetter || '#'; } case 'magazine': { // For magazines, use the magazine name if possible const magazineMatch = name.match(/^(.*?)\s*(?:Issue|Vol|Volume)/i); return magazineMatch ? magazineMatch[1].trim() : 'Unsorted'; } default: return null; } } /** * Process archive files (extract them) * @param {Object} torrent - Torrent object * @param {Object} mediaInfo - Media information * @param {string} destinationDir - Destination directory */ async processArchives(torrent, mediaInfo, destinationDir) { console.log(`Processing archives in ${torrent.name}`); const archiveFiles = mediaInfo.archiveFiles; const torrentDir = torrent.downloadDir; for (const file of archiveFiles) { const filePath = path.join(torrentDir, file.name); try { // Create a unique extraction directory const extractionDirName = path.basename(file.name, path.extname(file.name)); const extractionDir = path.join(destinationDir, extractionDirName); await this.createDirectoryIfNotExists(extractionDir); console.log(`Extracting ${filePath} to ${extractionDir}`); // Extract the archive based on type if (/\.zip$/i.test(file.name)) { await exec(`unzip -o "${filePath}" -d "${extractionDir}"`); } else if (/\.rar$/i.test(file.name)) { await exec(`unrar x -o+ "${filePath}" "${extractionDir}"`); } else if (/\.7z$/i.test(file.name)) { await exec(`7z x "${filePath}" -o"${extractionDir}"`); } else if (/\.tar(\.(gz|bz2|xz))?$/i.test(file.name)) { await exec(`tar -xf "${filePath}" -C "${extractionDir}"`); } else { console.log(`Unknown archive format for ${file.name}, skipping extraction`); continue; } console.log(`Successfully extracted ${file.name}`); // Delete archive if option is enabled if (this.processingOptions.deleteArchives) { try { console.log(`Deleting archive after extraction: ${filePath}`); await fs.unlink(filePath); } catch (deleteError) { console.error(`Failed to delete archive ${filePath}:`, deleteError); } } } catch (error) { console.error(`Error extracting archive ${filePath}:`, error); } } } /** * Process standard (non-archive) files * @param {Object} torrent - Torrent object * @param {Object} mediaInfo - Media information * @param {string} destinationDir - Destination directory */ async processStandardFiles(torrent, mediaInfo, destinationDir) { console.log(`Processing standard files in ${torrent.name}`); const torrentDir = torrent.downloadDir; const allFiles = []; // Collect all files based on media type switch (mediaInfo.type) { case 'movie': case 'tvshow': allFiles.push(...mediaInfo.videoFiles); break; case 'audio': allFiles.push(...mediaInfo.audioFiles); break; case 'book': case 'magazine': allFiles.push(...mediaInfo.documentFiles); break; default: // For unknown/software, add all files except samples if enabled for (const type of Object.keys(mediaInfo)) { if (Array.isArray(mediaInfo[type])) { allFiles.push(...mediaInfo[type]); } } } // Filter out sample files if option is enabled let filesToProcess = allFiles; if (this.processingOptions.ignoreSample) { filesToProcess = allFiles.filter(file => !file.isSample); console.log(`Filtered out ${allFiles.length - filesToProcess.length} sample files`); } // Process each file for (const file of filesToProcess) { const sourceFilePath = path.join(torrentDir, file.name); let destFileName = file.name; // Generate a better filename if rename option is enabled if (this.processingOptions.renameFiles) { destFileName = this.generateBetterFilename(file.name, mediaInfo.type); } const destFilePath = path.join(destinationDir, destFileName); try { // Check if destination file already exists with the same name const fileExists = await this.fileExists(destFilePath); if (fileExists) { if (this.processingOptions.autoReplaceUpgrades) { // Compare file sizes to see if the new one is larger (potentially higher quality) const existingStats = await fs.stat(destFilePath); if (file.size > existingStats.size) { console.log(`Replacing existing file with larger version: ${destFilePath}`); await fs.copyFile(sourceFilePath, destFilePath); } else { console.log(`Skipping ${file.name}, existing file is same or better quality`); } } else { // Generate a unique filename const uniqueDestFilePath = this.makeFilenameUnique(destFilePath); console.log(`Copying ${file.name} to ${uniqueDestFilePath}`); await fs.copyFile(sourceFilePath, uniqueDestFilePath); } } else { // File doesn't exist, simple copy console.log(`Copying ${file.name} to ${destFilePath}`); await fs.copyFile(sourceFilePath, destFilePath); } } catch (error) { console.error(`Error processing file ${file.name}:`, error); } } } /** * Generate a better filename based on content type * @param {string} originalFilename - Original filename * @param {string} mediaType - Media type * @returns {string} Improved filename */ generateBetterFilename(originalFilename, mediaType) { // Get the file extension const ext = path.extname(originalFilename); const basename = path.basename(originalFilename, ext); // Clean up common issues in filenames let cleanName = basename .replace(/\[.*?\]|\(.*?\)/g, '') // Remove content in brackets/parentheses .replace(/\._/g, '.') // Remove underscore after dots .replace(/\./g, ' ') // Replace dots with spaces .replace(/_/g, ' ') // Replace underscores with spaces .replace(/\s{2,}/g, ' ') // Replace multiple spaces with a single one .trim(); // Media type specific formatting switch (mediaType) { case 'movie': // Keep (year) format for movies if present const yearMatch = basename.match(/\(*(19|20)\d{2}\)*$/); if (yearMatch) { const year = yearMatch[0].replace(/[()]/g, ''); // Remove any year that might have been part of the clean name already cleanName = cleanName.replace(/(19|20)\d{2}/g, '').trim(); // Add the year in a consistent format cleanName = `${cleanName} (${year})`; } break; case 'tvshow': // Keep season and episode info for TV shows const episodeMatch = basename.match(/[sS](\d{1,2})[eE](\d{1,2})/); if (episodeMatch) { const seasonNum = parseInt(episodeMatch[1], 10); const episodeNum = parseInt(episodeMatch[2], 10); // First, remove any existing season/episode info from clean name cleanName = cleanName.replace(/[sS]\d{1,2}[eE]\d{1,2}/g, '').trim(); // Add back the season/episode in a consistent format cleanName = `${cleanName} S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; } break; case 'audio': // Try to organize as "Artist - Title" for music const musicMatch = basename.match(/^(.*?)\s*-\s*(.*?)$/); if (musicMatch && musicMatch[1] && musicMatch[2]) { const artist = musicMatch[1].trim(); const title = musicMatch[2].trim(); cleanName = `${artist} - ${title}`; } break; } return cleanName + ext; } /** * Make a filename unique by adding a suffix * @param {string} filepath - Original filepath * @returns {string} Unique filepath */ makeFilenameUnique(filepath) { const ext = path.extname(filepath); const basename = path.basename(filepath, ext); const dirname = path.dirname(filepath); // Add a timestamp to make it unique const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').substring(0, 15); return path.join(dirname, `${basename}_${timestamp}${ext}`); } /** * Create a directory if it doesn't exist * @param {string} dirPath - Directory path */ async createDirectoryIfNotExists(dirPath) { try { await fs.mkdir(dirPath, { recursive: true }); } catch (error) { // Ignore error if directory already exists if (error.code !== 'EEXIST') { throw error; } } } /** * Check if a file exists * @param {string} filePath - File path * @returns {Promise} Whether the file exists */ async fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } } module.exports = PostProcessor;