transmission-rss-manager/modules/post-processor.js
2025-03-04 22:28:11 +00:00

517 lines
18 KiB
JavaScript

/**
* 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<boolean>} Whether the file exists
*/
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
module.exports = PostProcessor;