#!/bin/bash # File creator module for Transmission RSS Manager Installation function create_config_files() { echo -e "${YELLOW}Creating configuration files...${NC}" # Create initial config.json echo "Creating config.json..." mkdir -p "$CONFIG_DIR" cat > $CONFIG_DIR/config.json << EOF { "version": "2.0.3", "installPath": "$INSTALL_DIR", "transmissionConfig": { "host": "$TRANSMISSION_HOST", "port": $TRANSMISSION_PORT, "username": "$TRANSMISSION_USER", "password": "$TRANSMISSION_PASS", "path": "$TRANSMISSION_RPC_PATH" }, "remoteConfig": { "isRemote": $TRANSMISSION_REMOTE, "directoryMapping": $TRANSMISSION_DIR_MAPPING }, "destinationPaths": { "movies": "$MEDIA_DIR/movies", "tvShows": "$MEDIA_DIR/tvshows", "music": "$MEDIA_DIR/music", "books": "$MEDIA_DIR/books", "magazines": "$MEDIA_DIR/magazines", "software": "$MEDIA_DIR/software" }, "seedingRequirements": { "minRatio": 1.0, "minTimeMinutes": 60, "checkIntervalSeconds": 300 }, "processingOptions": { "enableBookSorting": $ENABLE_BOOK_SORTING, "extractArchives": true, "deleteArchives": true, "createCategoryFolders": true, "ignoreSample": true, "ignoreExtras": true, "renameFiles": true, "autoReplaceUpgrades": true, "removeDuplicates": true, "keepOnlyBestVersion": true }, "rssFeeds": [], "rssUpdateIntervalMinutes": 60, "autoProcessing": false, "securitySettings": { "authEnabled": false, "httpsEnabled": false, "sslCertPath": "", "sslKeyPath": "", "users": [] }, "port": $PORT, "logLevel": "info" } EOF # Create package.json echo "Creating package.json..." cat > $INSTALL_DIR/package.json << EOF { "name": "transmission-rss-manager", "version": "2.0.0", "description": "Enhanced Transmission RSS Manager with post-processing capabilities", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "express": "^4.18.2", "body-parser": "^1.20.2", "transmission-promise": "^1.1.5", "adm-zip": "^0.5.10", "node-fetch": "^2.6.9", "xml2js": "^0.5.0", "cors": "^2.8.5", "bcrypt": "^5.1.0", "jsonwebtoken": "^9.0.0", "morgan": "^1.10.0" } } EOF # Create server.js echo "Creating server.js..." cp "${SCRIPT_DIR}/server.js" "$INSTALL_DIR/server.js" || { # If the file doesn't exist in the script directory, create it from scratch cat > $INSTALL_DIR/server.js << 'EOF' // server.js - Main application server file const express = require('express'); const bodyParser = require('body-parser'); const path = require('path'); const fs = require('fs').promises; const cors = require('cors'); const Transmission = require('transmission'); // Import custom modules const PostProcessor = require('./modules/postProcessor'); const RssFeedManager = require('./modules/rssFeedManager'); // Initialize Express app const app = express(); const PORT = process.env.PORT || 3000; // Load configuration let config = { transmissionConfig: { host: 'localhost', port: 9091, username: '', password: '', path: '/transmission/rpc' }, remoteConfig: { isRemote: false, directoryMapping: {} }, destinationPaths: { movies: '/mnt/media/movies', tvShows: '/mnt/media/tvshows', music: '/mnt/media/music', books: '/mnt/media/books', magazines: '/mnt/media/magazines', software: '/mnt/media/software' }, seedingRequirements: { minRatio: 1.0, minTimeMinutes: 60, checkIntervalSeconds: 300 }, processingOptions: { enableBookSorting: true, extractArchives: true, deleteArchives: true, createCategoryFolders: true, ignoreSample: true, ignoreExtras: true, renameFiles: true, autoReplaceUpgrades: true, removeDuplicates: true, keepOnlyBestVersion: true }, rssFeeds: [], rssUpdateIntervalMinutes: 60, autoProcessing: false }; // Service instances let transmissionClient = null; let postProcessor = null; let rssFeedManager = null; // Save config function async function saveConfig() { try { await fs.writeFile( path.join(__dirname, 'config.json'), JSON.stringify(config, null, 2), 'utf8' ); console.log('Configuration saved'); return true; } catch (err) { console.error('Error saving config:', err.message); return false; } } // Load config function async function loadConfig() { try { const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8'); const loadedConfig = JSON.parse(data); config = { ...config, ...loadedConfig }; console.log('Configuration loaded'); return true; } catch (err) { console.error('Error loading config, using defaults:', err.message); // Save default config await saveConfig(); return false; } } // Initialize Transmission client function initTransmission() { transmissionClient = new Transmission({ host: config.transmissionConfig.host, port: config.transmissionConfig.port, username: config.transmissionConfig.username, password: config.transmissionConfig.password, url: config.transmissionConfig.path }); console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`); return transmissionClient; } // Initialize post processor function initPostProcessor() { if (postProcessor) { postProcessor.stop(); } postProcessor = new PostProcessor({ transmissionConfig: config.transmissionConfig, remoteConfig: config.remoteConfig, destinationPaths: config.destinationPaths, seedingRequirements: config.seedingRequirements, processingOptions: config.processingOptions, downloadDir: config.downloadDir }); if (config.autoProcessing) { postProcessor.start(); console.log('Post-processor started automatically'); } else { console.log('Post-processor initialized (not auto-started)'); } return postProcessor; } // Initialize RSS feed manager function initRssFeedManager() { if (rssFeedManager) { rssFeedManager.stop(); } rssFeedManager = new RssFeedManager({ feeds: config.rssFeeds, updateIntervalMinutes: config.rssUpdateIntervalMinutes }); rssFeedManager.loadItems() .then(() => { if (config.rssFeeds && config.rssFeeds.length > 0) { rssFeedManager.start(); console.log('RSS feed manager started'); } else { console.log('RSS feed manager initialized (no feeds configured)'); } }) .catch(err => { console.error('Error initializing RSS feed manager:', err); }); return rssFeedManager; } // Enable CORS app.use(cors()); // Parse JSON bodies app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); // API routes //============================== // Server status API app.get('/api/status', (req, res) => { res.json({ status: 'running', version: '1.2.0', transmissionConnected: !!transmissionClient, postProcessorActive: postProcessor && postProcessor.processingIntervalId !== null, rssFeedManagerActive: rssFeedManager && rssFeedManager.updateIntervalId !== null, config: { autoProcessing: config.autoProcessing, rssEnabled: config.rssFeeds && config.rssFeeds.length > 0 } }); }); // Get configuration app.get('/api/config', (req, res) => { // Don't send password in response const safeConfig = { ...config }; if (safeConfig.transmissionConfig) { safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' }; } res.json(safeConfig); }); // Update configuration app.post('/api/config', async (req, res) => { try { // Create a deep copy of the old config const oldConfig = JSON.parse(JSON.stringify(config)); // Update config object, preserving password if not provided config = { ...config, ...req.body, transmissionConfig: { ...config.transmissionConfig, ...req.body.transmissionConfig, password: req.body.transmissionConfig?.password || config.transmissionConfig.password }, remoteConfig: { ...config.remoteConfig, ...req.body.remoteConfig }, destinationPaths: { ...config.destinationPaths, ...req.body.destinationPaths }, seedingRequirements: { ...config.seedingRequirements, ...req.body.seedingRequirements }, processingOptions: { ...config.processingOptions, ...req.body.processingOptions } }; // Save the updated config await saveConfig(); // Check if key services need reinitialization let changes = { transmissionChanged: JSON.stringify(oldConfig.transmissionConfig) !== JSON.stringify(config.transmissionConfig), postProcessorChanged: JSON.stringify(oldConfig.remoteConfig) !== JSON.stringify(config.remoteConfig) || JSON.stringify(oldConfig.destinationPaths) !== JSON.stringify(config.destinationPaths) || JSON.stringify(oldConfig.seedingRequirements) !== JSON.stringify(config.seedingRequirements) || JSON.stringify(oldConfig.processingOptions) !== JSON.stringify(config.processingOptions), rssFeedsChanged: JSON.stringify(oldConfig.rssFeeds) !== JSON.stringify(config.rssFeeds) || oldConfig.rssUpdateIntervalMinutes !== config.rssUpdateIntervalMinutes, autoProcessingChanged: oldConfig.autoProcessing !== config.autoProcessing }; // Reinitialize services as needed if (changes.transmissionChanged) { initTransmission(); } if (changes.postProcessorChanged || changes.transmissionChanged) { initPostProcessor(); } if (changes.rssFeedsChanged) { initRssFeedManager(); } if (changes.autoProcessingChanged) { if (config.autoProcessing && postProcessor) { postProcessor.start(); } else if (!config.autoProcessing && postProcessor) { postProcessor.stop(); } } res.json({ success: true, message: 'Configuration updated successfully', changes }); } catch (error) { console.error('Error updating configuration:', error); res.status(500).json({ success: false, message: `Failed to update configuration: ${error.message}` }); } }); // Transmission API routes //============================== // Test Transmission connection app.post('/api/transmission/test', (req, res) => { const { host, port, username, password } = req.body; // Create a test client with provided credentials const testClient = new Transmission({ host: host || config.transmissionConfig.host, port: port || config.transmissionConfig.port, username: username || config.transmissionConfig.username, password: password || config.transmissionConfig.password, url: config.transmissionConfig.path }); // Test connection testClient.sessionStats((err, result) => { if (err) { return res.json({ success: false, message: `Connection failed: ${err.message}` }); } // Connection successful res.json({ success: true, message: "Connected to Transmission successfully!", data: { version: result.version || "Unknown", rpcVersion: result.rpcVersion || "Unknown" } }); }); }); // Get torrents from Transmission app.get('/api/transmission/torrents', (req, res) => { if (!transmissionClient) { return res.status(400).json({ success: false, message: 'Transmission client not initialized' }); } transmissionClient.get((err, result) => { if (err) { return res.status(500).json({ success: false, message: `Error getting torrents: ${err.message}` }); } res.json({ success: true, data: result.torrents || [] }); }); }); // Add torrent to Transmission app.post('/api/transmission/add', (req, res) => { if (!transmissionClient) { return res.status(400).json({ success: false, message: 'Transmission client not initialized' }); } const { url } = req.body; if (!url) { return res.status(400).json({ success: false, message: 'URL is required' }); } transmissionClient.addUrl(url, (err, result) => { if (err) { return res.status(500).json({ success: false, message: `Error adding torrent: ${err.message}` }); } res.json({ success: true, data: result }); }); }); // Remove torrent from Transmission app.post('/api/transmission/remove', (req, res) => { if (!transmissionClient) { return res.status(400).json({ success: false, message: 'Transmission client not initialized' }); } const { ids, deleteLocalData } = req.body; if (!ids || !Array.isArray(ids) && typeof ids !== 'number') { return res.status(400).json({ success: false, message: 'Valid torrent ID(s) required' }); } transmissionClient.remove(ids, !!deleteLocalData, (err, result) => { if (err) { return res.status(500).json({ success: false, message: `Error removing torrent: ${err.message}` }); } res.json({ success: true, data: result }); }); }); // Start torrents app.post('/api/transmission/start', (req, res) => { if (!transmissionClient) { return res.status(400).json({ success: false, message: 'Transmission client not initialized' }); } const { ids } = req.body; if (!ids) { return res.status(400).json({ success: false, message: 'Torrent ID(s) required' }); } transmissionClient.start(ids, (err, result) => { if (err) { return res.status(500).json({ success: false, message: `Error starting torrent: ${err.message}` }); } res.json({ success: true, data: result }); }); }); // Stop torrents app.post('/api/transmission/stop', (req, res) => { if (!transmissionClient) { return res.status(400).json({ success: false, message: 'Transmission client not initialized' }); } const { ids } = req.body; if (!ids) { return res.status(400).json({ success: false, message: 'Torrent ID(s) required' }); } transmissionClient.stop(ids, (err, result) => { if (err) { return res.status(500).json({ success: false, message: `Error stopping torrent: ${err.message}` }); } res.json({ success: true, data: result }); }); }); // RSS Feed Manager API routes //============================== // Get all feeds app.get('/api/rss/feeds', (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } res.json({ success: true, data: rssFeedManager.getAllFeeds() }); }); // Add a new feed app.post('/api/rss/feeds', async (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } const feed = req.body; if (!feed || !feed.url) { return res.status(400).json({ success: false, message: 'Feed URL is required' }); } try { const newFeed = rssFeedManager.addFeed(feed); // Update the config with the new feed config.rssFeeds = rssFeedManager.getAllFeeds(); await saveConfig(); res.json({ success: true, data: newFeed }); } catch (error) { res.status(500).json({ success: false, message: `Error adding feed: ${error.message}` }); } }); // Update a feed app.put('/api/rss/feeds/:id', async (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } const { id } = req.params; const updates = req.body; if (!id || !updates) { return res.status(400).json({ success: false, message: 'Feed ID and updates are required' }); } try { const success = rssFeedManager.updateFeedConfig(id, updates); if (!success) { return res.status(404).json({ success: false, message: `Feed with ID ${id} not found` }); } // Update the config with the updated feeds config.rssFeeds = rssFeedManager.getAllFeeds(); await saveConfig(); res.json({ success: true, message: 'Feed updated successfully' }); } catch (error) { res.status(500).json({ success: false, message: `Error updating feed: ${error.message}` }); } }); // Delete a feed app.delete('/api/rss/feeds/:id', async (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } const { id } = req.params; if (!id) { return res.status(400).json({ success: false, message: 'Feed ID is required' }); } try { const success = rssFeedManager.removeFeed(id); if (!success) { return res.status(404).json({ success: false, message: `Feed with ID ${id} not found` }); } // Update the config with the remaining feeds config.rssFeeds = rssFeedManager.getAllFeeds(); await saveConfig(); res.json({ success: true, message: 'Feed removed successfully' }); } catch (error) { res.status(500).json({ success: false, message: `Error removing feed: ${error.message}` }); } }); // Get feed items app.get('/api/rss/items', (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } const { filter } = req.query; let items; if (filter === 'undownloaded') { items = rssFeedManager.getUndownloadedItems(); } else { items = rssFeedManager.getAllItems(); } res.json({ success: true, data: items }); }); // Filter feed items app.post('/api/rss/filter', (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } const filters = req.body; const filteredItems = rssFeedManager.filterItems(filters); res.json({ success: true, data: filteredItems }); }); // Fetch and update RSS feed app.post('/api/rss/update', async (req, res) => { if (!rssFeedManager) { return res.status(400).json({ success: false, message: 'RSS feed manager not initialized' }); } try { const result = await rssFeedManager.updateAllFeeds(); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, message: `Error updating feeds: ${error.message}` }); } }); // Download RSS item app.post('/api/rss/download', async (req, res) => { if (!rssFeedManager || !transmissionClient) { return res.status(400).json({ success: false, message: 'RSS feed manager or Transmission client not initialized' }); } const { itemId } = req.body; if (!itemId) { return res.status(400).json({ success: false, message: 'Item ID is required' }); } try { const items = rssFeedManager.getAllItems(); const item = items.find(i => i.id === itemId); if (!item) { return res.status(404).json({ success: false, message: `Item with ID ${itemId} not found` }); } const result = await rssFeedManager.downloadItem(item, transmissionClient); res.json({ success: result.success, message: result.success ? 'Item added to Transmission' : result.message, data: result.result }); } catch (error) { res.status(500).json({ success: false, message: `Error downloading item: ${error.message}` }); } }); // Post-Processor API routes //============================== // Start post-processor app.post('/api/post-processor/start', (req, res) => { if (!postProcessor) { return res.status(400).json({ success: false, message: 'Post-processor not initialized' }); } try { postProcessor.start(); // Update config config.autoProcessing = true; saveConfig(); res.json({ success: true, message: 'Post-processor started' }); } catch (error) { res.status(500).json({ success: false, message: `Error starting post-processor: ${error.message}` }); } }); // Stop post-processor app.post('/api/post-processor/stop', (req, res) => { if (!postProcessor) { return res.status(400).json({ success: false, message: 'Post-processor not initialized' }); } try { postProcessor.stop(); // Update config config.autoProcessing = false; saveConfig(); res.json({ success: true, message: 'Post-processor stopped' }); } catch (error) { res.status(500).json({ success: false, message: `Error stopping post-processor: ${error.message}` }); } }); // Get media library app.get('/api/media/library', (req, res) => { if (!postProcessor) { return res.status(400).json({ success: false, message: 'Post-processor not initialized' }); } const { query } = req.query; let library; if (query) { library = postProcessor.searchLibrary(query); } else { library = postProcessor.getLibrary(); } res.json({ success: true, data: library }); }); // Get library statistics app.get('/api/media/stats', (req, res) => { if (!postProcessor) { return res.status(400).json({ success: false, message: 'Post-processor not initialized' }); } const stats = postProcessor.getLibraryStats(); res.json({ success: true, data: stats }); }); // Serve static files app.use(express.static(path.join(__dirname, 'public'))); // Catch-all route for SPA app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // Initialize application async function init() { console.log('Initializing application...'); // Load configuration await loadConfig(); // Initialize services initTransmission(); initPostProcessor(); initRssFeedManager(); // Start the server app.listen(PORT, () => { console.log(`Transmission RSS Manager running on port ${PORT}`); }); } // Start the application init().catch(err => { console.error('Failed to initialize application:', err); }); // Handle graceful shutdown process.on('SIGINT', async () => { console.log('Shutting down...'); if (postProcessor) { postProcessor.stop(); } if (rssFeedManager) { rssFeedManager.stop(); await rssFeedManager.saveItems(); await rssFeedManager.saveConfig(); } await saveConfig(); console.log('Shutdown complete'); process.exit(0); }); EOF } # Create enhanced UI JavaScript echo "Creating enhanced-ui.js..." cp "${SCRIPT_DIR}/public/js/enhanced-ui.js" "$INSTALL_DIR/public/js/enhanced-ui.js" || { cat > $INSTALL_DIR/public/js/enhanced-ui.js << 'EOF' // RSS Feed Management Functions function addFeed() { // Create a modal dialog for adding a feed const modal = document.createElement('div'); modal.style.position = 'fixed'; modal.style.top = '0'; modal.style.left = '0'; modal.style.width = '100%'; modal.style.height = '100%'; modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; modal.style.display = 'flex'; modal.style.justifyContent = 'center'; modal.style.alignItems = 'center'; modal.style.zIndex = '1000'; // Create the modal content const modalContent = document.createElement('div'); modalContent.style.backgroundColor = 'white'; modalContent.style.padding = '20px'; modalContent.style.borderRadius = '8px'; modalContent.style.width = '500px'; modalContent.style.maxWidth = '90%'; modalContent.innerHTML = `

Add RSS Feed

`; modal.appendChild(modalContent); document.body.appendChild(modal); // Toggle filter container based on auto-download checkbox document.getElementById('feed-auto-download').addEventListener('change', function() { document.getElementById('filter-container').style.display = this.checked ? 'block' : 'none'; }); // Cancel button document.getElementById('cancel-add-feed').addEventListener('click', function() { document.body.removeChild(modal); }); // Save button document.getElementById('save-add-feed').addEventListener('click', function() { const name = document.getElementById('feed-name').value.trim(); const url = document.getElementById('feed-url').value.trim(); const autoDownload = document.getElementById('feed-auto-download').checked; if (!name || !url) { alert('Name and URL are required!'); return; } // Create feed object const feed = { name, url, autoDownload }; // Add filters if auto-download is enabled if (autoDownload) { const title = document.getElementById('filter-title').value.trim(); const category = document.getElementById('filter-category').value.trim(); const minSize = document.getElementById('filter-min-size').value ? parseInt(document.getElementById('filter-min-size').value, 10) * 1024 * 1024 : null; const maxSize = document.getElementById('filter-max-size').value ? parseInt(document.getElementById('filter-max-size').value, 10) * 1024 * 1024 : null; feed.filters = [{ title, category, minSize, maxSize }]; } // Call API to add the feed saveFeed(feed); // Remove modal document.body.removeChild(modal); }); } // Rest of enhanced-ui.js content would be here EOF } # Create postProcessor module echo "Creating postProcessor.js..." cp "${SCRIPT_DIR}/modules/postProcessor.js" "$INSTALL_DIR/modules/postProcessor.js" || { cat > $INSTALL_DIR/modules/postProcessor.js << 'EOF' const path = require('path'); const fs = require('fs').promises; const Transmission = require('transmission'); const AdmZip = require('adm-zip'); const { exec } = require('child_process'); const util = require('util'); const execAsync = util.promisify(exec); class PostProcessor { constructor(config) { this.config = config; this.processingIntervalId = null; this.transmissionClient = null; this.library = { movies: [], tvShows: [], music: [], books: [], magazines: [], software: [] }; this.initTransmission(); } // Initialize Transmission client initTransmission() { this.transmissionClient = new Transmission({ host: this.config.transmissionConfig.host, port: this.config.transmissionConfig.port, username: this.config.transmissionConfig.username, password: this.config.transmissionConfig.password, url: this.config.transmissionConfig.path }); } // Start processing completed torrents start() { if (this.processingIntervalId) { return; } // Run the process immediately at startup this.processCompletedTorrents(); // Then set up the interval this.processingIntervalId = setInterval(() => { this.processCompletedTorrents(); }, this.config.seedingRequirements.checkIntervalSeconds * 1000); console.log(`Post-processor started, interval: ${this.config.seedingRequirements.checkIntervalSeconds} seconds`); } // Stop processing completed torrents stop() { if (this.processingIntervalId) { clearInterval(this.processingIntervalId); this.processingIntervalId = null; console.log('Post-processor stopped'); } } // Main process function for completed torrents async processCompletedTorrents() { try { // Get all torrents from Transmission const torrents = await this.getTransmissionTorrents(); // Filter torrents that meet seeding requirements const completedTorrents = this.filterCompletedTorrents(torrents); if (completedTorrents.length === 0) { return; } console.log(`Processing ${completedTorrents.length} completed torrents`); // Process each completed torrent for (const torrent of completedTorrents) { await this.processTorrent(torrent); } // Update the library await this.updateLibrary(); } catch (error) { console.error('Error processing completed torrents:', error); } } // Get torrents from Transmission getTransmissionTorrents() { return new Promise((resolve, reject) => { this.transmissionClient.get((err, result) => { if (err) { reject(err); } else { resolve(result.torrents || []); } }); }); } // Filter completed torrents based on seeding requirements filterCompletedTorrents(torrents) { return torrents.filter(torrent => { // Check if the torrent is 100% completed if (torrent.percentDone < 1.0) { return false; } // Check if seeding requirements are met const seedingRatioMet = torrent.uploadRatio >= this.config.seedingRequirements.minRatio; const seedingTimeMet = torrent.secondsSeeding >= (this.config.seedingRequirements.minTimeMinutes * 60); return seedingRatioMet && seedingTimeMet; }); } // Process a single torrent async processTorrent(torrent) { console.log(`Processing torrent: ${torrent.name}`); try { // Determine the category of the torrent const category = this.determineTorrentCategory(torrent); // Get the download directory const downloadDir = torrent.downloadDir || this.config.downloadDir; // Map remote paths to local paths if necessary const localPath = this.mapRemotePath(downloadDir, torrent.name); // Process the downloaded files await this.processDownloadedFiles(localPath, category); // Update torrent status in Transmission if (this.config.processingOptions.removeTorrentAfterProcessing) { await this.removeTorrentFromTransmission(torrent.id); } console.log(`Successfully processed torrent: ${torrent.name}`); return true; } catch (error) { console.error(`Error processing torrent ${torrent.name}:`, error); return false; } } // Determine category of a torrent based on its name and files determineTorrentCategory(torrent) { // Default category let category = 'other'; // Check name for category indicators const name = torrent.name.toLowerCase(); // Check for video file extensions in the files const hasVideoFiles = torrent.files && torrent.files.some(file => ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.flv'].some(ext => file.name.toLowerCase().endsWith(ext) ) ); // Movie patterns if (hasVideoFiles && ( name.includes('1080p') || name.includes('720p') || name.includes('2160p') || name.includes('bluray') || name.includes('bdrip') || name.includes('dvdrip') || name.includes('webrip') || /\(\d{4}\)/.test(name) || // Year in parentheses /\.\d{4}\./.test(name) // Year between dots )) { // Check if it's a TV show if ( /s\d{1,2}e\d{1,2}/i.test(name) || // S01E01 format /\d{1,2}x\d{1,2}/i.test(name) || // 1x01 format name.includes('season') || name.includes('episode') || name.includes('complete series') ) { category = 'tvShows'; } else { category = 'movies'; } } // Music patterns else if ( name.includes('mp3') || name.includes('flac') || name.includes('alac') || name.includes('wav') || name.includes('album') || name.includes('discography') ) { category = 'music'; } // Book patterns else if ( this.config.processingOptions.enableBookSorting && ( name.includes('epub') || name.includes('mobi') || name.includes('azw3') || name.includes('pdf') && !name.includes('magazine') && !name.includes('issue') || name.includes('book') || name.includes('ebook') ) ) { category = 'books'; } // Magazine patterns else if ( this.config.processingOptions.enableBookSorting && ( name.includes('magazine') || name.includes('issue') && name.includes('pdf') || /\b(vol|volume)\b.*\d+/.test(name) && name.includes('pdf') || /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) && name.includes('pdf') ) ) { category = 'magazines'; } // Generic PDF - can be book or magazine, check more closely else if ( this.config.processingOptions.enableBookSorting && name.includes('pdf') ) { // Check if it looks like a magazine - has dates, issues, volumes if ( /\b(issue|vol|volume)\b.*\d+/.test(name) || /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) || name.includes('magazine') ) { category = 'magazines'; } else { // Default to books for other PDFs category = 'books'; } } // Software patterns else if ( name.includes('windows') || name.includes('macos') || name.includes('linux') || name.includes('crack') || name.includes('keygen') || name.includes('iso') || name.includes('software') || name.includes('app') || name.includes('application') ) { category = 'software'; } return category; } // Map remote paths to local paths for remote Transmission setups mapRemotePath(remotePath, torrentName) { // If we're not working with a remote Transmission setup, return the path as-is if (!this.config.remoteConfig.isRemote) { return path.join(remotePath, torrentName); } // If we are working with a remote setup, map the remote path to a local path const mapping = this.config.remoteConfig.directoryMapping; // Find the appropriate mapping for (const [remote, local] of Object.entries(mapping)) { if (remotePath.startsWith(remote)) { // Replace the remote path with the local path return path.join(local, torrentName); } } // If no mapping found, use the remote path directly return path.join(remotePath, torrentName); } // Process downloaded files in a given path async processDownloadedFiles(sourcePath, category) { // Get the destination directory for this category const destinationDir = this.config.destinationPaths[category] || this.config.destinationPaths.other; try { // Check if the source path exists await fs.access(sourcePath); // Check if the source is a directory or a file const stats = await fs.stat(sourcePath); if (stats.isDirectory()) { // Process a directory await this.processDirectory(sourcePath, destinationDir, category); } else { // Process a single file await this.processFile(sourcePath, destinationDir, category); } } catch (error) { console.error(`Error processing ${sourcePath}:`, error); throw error; } } // Process a directory recursively async processDirectory(sourceDir, destDir, category) { try { // Read all files in the directory const files = await fs.readdir(sourceDir); // Process each file for (const file of files) { const filePath = path.join(sourceDir, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Recursively process subdirectories await this.processDirectory(filePath, destDir, category); } else { // Process files await this.processFile(filePath, destDir, category); } } } catch (error) { console.error(`Error processing directory ${sourceDir}:`, error); throw error; } } // Process a single file async processFile(filePath, destDir, category) { const fileName = path.basename(filePath); // Skip processing if it's a sample file and ignoreSample is enabled if (this.config.processingOptions.ignoreSample && this.isSampleFile(fileName)) { console.log(`Skipping sample file: ${fileName}`); return; } // Skip processing if it's an extras file and ignoreExtras is enabled if (this.config.processingOptions.ignoreExtras && this.isExtrasFile(fileName)) { console.log(`Skipping extras file: ${fileName}`); return; } // Handle archive files if (this.isArchiveFile(fileName) && this.config.processingOptions.extractArchives) { await this.extractArchive(filePath, destDir); // Delete the archive after extraction if configured if (this.config.processingOptions.deleteArchives) { await fs.unlink(filePath); console.log(`Deleted archive after extraction: ${fileName}`); } return; } // For regular files, copy to destination let destinationFile = path.join(destDir, fileName); // If renameFiles is enabled, rename based on category if (this.config.processingOptions.renameFiles) { destinationFile = this.getDestinationFileName(fileName, destDir, category); } // Create destination directory if it doesn't exist const destSubDir = path.dirname(destinationFile); await fs.mkdir(destSubDir, { recursive: true }); // Copy the file to destination await fs.copyFile(filePath, destinationFile); console.log(`Copied file to: ${destinationFile}`); // Add to library this.addToLibrary(destinationFile, category); } // Check if a file is an archive isArchiveFile(fileName) { const ext = path.extname(fileName).toLowerCase(); return ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'].includes(ext); } // Check if a file is a sample file isSampleFile(fileName) { return fileName.toLowerCase().includes('sample'); } // Check if a file is an extras file isExtrasFile(fileName) { const lowerName = fileName.toLowerCase(); return ['featurette', 'extra', 'bonus', 'deleted', 'interview'].some(term => lowerName.includes(term)); } // Extract an archive file async extractArchive(archivePath, destDir) { const ext = path.extname(archivePath).toLowerCase(); try { if (['.zip', '.rar', '.7z'].includes(ext)) { // Create extract directory const extractDir = path.join(destDir, path.basename(archivePath, ext)); await fs.mkdir(extractDir, { recursive: true }); if (ext === '.zip') { // Use AdmZip for .zip files const zip = new AdmZip(archivePath); zip.extractAllTo(extractDir, true); console.log(`Extracted zip to: ${extractDir}`); } else { // Use unrar or 7z for other archives const cmd = ext === '.rar' ? `unrar x -o+ "${archivePath}" "${extractDir}"` : `7z x "${archivePath}" -o"${extractDir}"`; await execAsync(cmd); console.log(`Extracted ${ext} to: ${extractDir}`); } } else { console.log(`Unsupported archive format: ${ext}`); } } catch (error) { console.error(`Error extracting archive ${archivePath}:`, error); throw error; } } // Get the destination filename based on category and content getDestinationFileName(fileName, destDir, category) { // Basic filename sanitization let cleanName = fileName.replace(/\.\w+$/, ''); // Remove extension cleanName = cleanName.replace(/[\/\\:\*\?"<>\|]/g, ''); // Remove invalid characters cleanName = cleanName.replace(/\.\d{4}\./g, ' '); // Clean up dots around year cleanName = cleanName.replace(/\./g, ' '); // Replace dots with spaces // Get file extension const ext = path.extname(fileName); // Format based on category switch (category) { case 'movies': // Extract year if possible const yearMatch = fileName.match(/\((\d{4})\)/) || fileName.match(/\.(\d{4})\./); const year = yearMatch ? yearMatch[1] : ''; // Remove quality tags const qualityTags = ['1080p', '720p', '2160p', 'bluray', 'bdrip', 'dvdrip', 'webrip', 'hdrip']; let cleanTitle = cleanName; qualityTags.forEach(tag => { cleanTitle = cleanTitle.replace(new RegExp(tag, 'i'), ''); }); // Format: Movie Title (Year).ext return path.join(destDir, `${cleanTitle.trim()}${year ? ` (${year})` : ''}${ext}`); case 'tvShows': // Try to extract show name and episode info let showName = cleanName; let episodeInfo = ''; // Look for S01E01 format const seasonEpMatch = cleanName.match(/S(\d{1,2})E(\d{1,2})/i); if (seasonEpMatch) { const parts = cleanName.split(/S\d{1,2}E\d{1,2}/i); showName = parts[0].trim(); episodeInfo = `S${seasonEpMatch[1].padStart(2, '0')}E${seasonEpMatch[2].padStart(2, '0')}`; } // Create show directory const showDir = path.join(destDir, showName); fs.mkdir(showDir, { recursive: true }); // Format: Show Name/Show Name - S01E01.ext return path.join(showDir, `${showName}${episodeInfo ? ` - ${episodeInfo}` : ''}${ext}`); case 'books': // Try to extract author and title let author = ''; let title = cleanName; // Look for common author patterns: "Author - Title" or "Author Name - Book Title" const authorMatch = cleanName.match(/^(.*?)\s+-\s+(.*?)$/); if (authorMatch) { author = authorMatch[1].trim(); title = authorMatch[2].trim(); } // Create author directory if we identified one let bookPath = destDir; if (author && this.config.processingOptions.createCategoryFolders) { bookPath = path.join(destDir, author); fs.mkdir(bookPath, { recursive: true }); } // Format: Author/Title.ext or just Title.ext if no author return path.join(bookPath, `${title}${ext}`); case 'magazines': // Try to extract magazine name and issue info let magazineName = cleanName; let issueInfo = ''; // Look for issue number patterns const issueMatch = cleanName.match(/issue\s+(\d+)/i) || cleanName.match(/(\w+)\s+(\d{4})/) || // Month Year cleanName.match(/(\d+)\s+(\w+)\s+(\d{4})/); // Day Month Year if (issueMatch) { // Try to separate magazine name from issue info const parts = cleanName.split(/issue|vol|volume|\d{4}/i)[0]; if (parts) { magazineName = parts.trim(); // Extract issue date/number from the full name issueInfo = cleanName.substring(magazineName.length).trim(); } } // Create magazine directory let magazinePath = destDir; if (this.config.processingOptions.createCategoryFolders) { magazinePath = path.join(destDir, magazineName); fs.mkdir(magazinePath, { recursive: true }); } // Format: Magazine Name/Magazine Name - Issue Info.ext return path.join(magazinePath, `${magazineName}${issueInfo ? ` - ${issueInfo}` : ''}${ext}`); default: // For other categories, just use the cleaned name return path.join(destDir, `${cleanName}${ext}`); } } // Add a file to the library addToLibrary(filePath, category) { if (!this.library[category]) { this.library[category] = []; } const fileName = path.basename(filePath); // Check if file is already in the library const existing = this.library[category].find(item => item.name === fileName); if (!existing) { this.library[category].push({ name: fileName, path: filePath, added: new Date().toISOString() }); } } // Update the library by scanning destination directories async updateLibrary() { try { // Scan the destination directories for (const [category, destDir] of Object.entries(this.config.destinationPaths)) { if (category === 'downloadDir') continue; // Initialize or clear the category in the library this.library[category] = []; try { // Check if directory exists before scanning await fs.access(destDir); // Scan the directory await this.scanDirectory(destDir, category); } catch (error) { console.log(`Directory ${destDir} does not exist or is not accessible`); } } console.log('Library updated'); } catch (error) { console.error('Error updating library:', error); } } // Scan a directory recursively async scanDirectory(directory, category) { try { const files = await fs.readdir(directory); for (const file of files) { const filePath = path.join(directory, file); const stats = await fs.stat(filePath); if (stats.isDirectory()) { await this.scanDirectory(filePath, category); } else { this.addToLibrary(filePath, category); } } } catch (error) { console.error(`Error scanning directory ${directory}:`, error); } } // Remove a torrent from Transmission removeTorrentFromTransmission(torrentId) { return new Promise((resolve, reject) => { this.transmissionClient.remove(torrentId, true, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); } // Public API methods // Get the entire media library getLibrary() { return this.library; } // Search the library for a query searchLibrary(query) { const results = {}; query = query.toLowerCase(); for (const [category, items] of Object.entries(this.library)) { results[category] = items.filter(item => item.name.toLowerCase().includes(query) ); } return results; } // Get library statistics getLibraryStats() { const stats = { totalItems: 0, categories: {} }; for (const [category, items] of Object.entries(this.library)) { stats.categories[category] = items.length; stats.totalItems += items.length; } return stats; } } module.exports = PostProcessor; EOF } echo "Configuration files created." }