517 lines
18 KiB
JavaScript
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; |