#!/bin/bash # Enhanced Transmission RSS Manager Installation Script for Ubuntu # Includes support for book/magazine sorting and all UI enhancements # Text formatting BOLD='\033[1m' GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' NC='\033[0m' # No Color # Print header echo -e "${BOLD}==================================================${NC}" echo -e "${BOLD} Transmission RSS Manager Installer ${NC}" echo -e "${BOLD} Version 1.2.0 - Enhanced Edition ${NC}" echo -e "${BOLD}==================================================${NC}" echo # Check if script is run with sudo if [ "$EUID" -ne 0 ]; then echo -e "${RED}Please run as root (use sudo)${NC}" exit 1 fi # Get current directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" # Configuration variables INSTALL_DIR="/opt/transmission-rss-manager" SERVICE_NAME="transmission-rss-manager" USER=$(logname || echo $SUDO_USER) PORT=3000 # Transmission configuration variables TRANSMISSION_REMOTE=false TRANSMISSION_HOST="localhost" TRANSMISSION_PORT=9091 TRANSMISSION_USER="" TRANSMISSION_PASS="" TRANSMISSION_RPC_PATH="/transmission/rpc" TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads" TRANSMISSION_DIR_MAPPING="{}" # Ask user for configuration echo -e "${BOLD}Installation Configuration:${NC}" echo -e "Please provide the following configuration parameters:" echo read -p "Installation directory [$INSTALL_DIR]: " input_install_dir INSTALL_DIR=${input_install_dir:-$INSTALL_DIR} read -p "Web interface port [$PORT]: " input_port PORT=${input_port:-$PORT} read -p "Run as user [$USER]: " input_user USER=${input_user:-$USER} echo echo -e "${BOLD}Transmission Configuration:${NC}" echo -e "Configure connection to your Transmission client:" echo read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote if [[ $input_remote =~ ^[Yy]$ ]]; then TRANSMISSION_REMOTE=true read -p "Remote Transmission host [localhost]: " input_trans_host TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST} read -p "Remote Transmission port [9091]: " input_trans_port TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT} read -p "Remote Transmission username []: " input_trans_user TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} read -p "Remote Transmission password []: " input_trans_pass TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS} read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH} # Configure directory mapping for remote setup echo echo -e "${YELLOW}Directory Mapping Configuration${NC}" echo -e "When using a remote Transmission server, you need to map paths between servers." echo -e "For each directory on the remote server, specify the corresponding local directory." echo # Get remote download directory read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} # Get local directory that corresponds to remote download directory read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} # Create mapping JSON TRANSMISSION_DIR_MAPPING=$(cat < /dev/null; then echo "Installing Node.js and npm..." apt-get install -y ca-certificates curl gnupg mkdir -p /etc/apt/keyrings curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list apt-get update apt-get install -y nodejs fi # Install additional dependencies echo "Installing additional dependencies..." apt-get install -y unrar unzip p7zip-full nginx # Create installation directory echo -e "${YELLOW}Creating installation directory...${NC}" mkdir -p $INSTALL_DIR mkdir -p $INSTALL_DIR/logs mkdir -p $INSTALL_DIR/public/js mkdir -p $INSTALL_DIR/public/css # Create package.json echo -e "${YELLOW}Creating package.json...${NC}" cat > $INSTALL_DIR/package.json << EOF { "name": "transmission-rss-manager", "version": "1.2.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": "^0.4.10", "adm-zip": "^0.5.10", "node-fetch": "^2.6.9", "xml2js": "^0.5.0", "cors": "^2.8.5" } } EOF # Create server.js echo -e "${YELLOW}Creating server.js...${NC}" 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('./postProcessor'); const RssFeedManager = require('./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.js echo -e "${YELLOW}Creating enhanced-ui.js...${NC}" 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); }); } function saveFeed(feed) { statusMessage.textContent = "Adding RSS feed..."; statusMessage.className = "status-message"; fetch("/api/rss/feeds", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(feed) }) .then(response => response.json()) .then(data => { if (data.success) { statusMessage.textContent = "RSS feed added successfully!"; statusMessage.classList.add("status-success"); loadRssData(); } else { statusMessage.textContent = "Error adding RSS feed: " + data.message; statusMessage.classList.add("status-error"); } }) .catch(error => { statusMessage.textContent = "Error: " + error.message; statusMessage.classList.add("status-error"); console.error("Error adding RSS feed:", error); }); } function editFeed(id) { // First, get the feed data fetch(`/api/rss/feeds`) .then(response => response.json()) .then(data => { if (!data.success) { alert("Error loading feed: " + data.message); return; } const feed = data.data.find(f => f.id === id); if (!feed) { alert("Feed not found!"); return; } // Create a modal dialog for editing the feed (similar to addFeed) 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%'; // Get filter values if available const filter = feed.filters && feed.filters.length > 0 ? feed.filters[0] : {}; const title = filter.title || ''; const category = filter.category || ''; const minSize = filter.minSize ? Math.round(filter.minSize / (1024 * 1024)) : ''; const maxSize = filter.maxSize ? Math.round(filter.maxSize / (1024 * 1024)) : ''; modalContent.innerHTML = `

Edit RSS Feed

Auto-Download Filters

`; 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-edit-feed').addEventListener('click', function() { document.body.removeChild(modal); }); // Save button document.getElementById('save-edit-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 updated feed object const updatedFeed = { 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; updatedFeed.filters = [{ title, category, minSize, maxSize }]; } // Call API to update the feed updateFeed(id, updatedFeed); // Remove modal document.body.removeChild(modal); }); }) .catch(error => { alert("Error loading feed: " + error.message); console.error("Error loading feed:", error); }); } function updateFeed(id, feed) { statusMessage.textContent = "Updating RSS feed..."; statusMessage.className = "status-message"; fetch(`/api/rss/feeds/${id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(feed) }) .then(response => response.json()) .then(data => { if (data.success) { statusMessage.textContent = "RSS feed updated successfully!"; statusMessage.classList.add("status-success"); loadRssData(); } else { statusMessage.textContent = "Error updating RSS feed: " + data.message; statusMessage.classList.add("status-error"); } }) .catch(error => { statusMessage.textContent = "Error: " + error.message; statusMessage.classList.add("status-error"); console.error("Error updating RSS feed:", error); }); } function deleteFeed(id) { statusMessage.textContent = "Deleting RSS feed..."; statusMessage.className = "status-message"; fetch(`/api/rss/feeds/${id}`, { method: "DELETE" }) .then(response => response.json()) .then(data => { if (data.success) { statusMessage.textContent = "RSS feed deleted successfully!"; statusMessage.classList.add("status-success"); loadRssData(); } else { statusMessage.textContent = "Error deleting RSS feed: " + data.message; statusMessage.classList.add("status-error"); } }) .catch(error => { statusMessage.textContent = "Error: " + error.message; statusMessage.classList.add("status-error"); console.error("Error deleting RSS feed:", error); }); } function downloadRssItem(id) { statusMessage.textContent = "Adding item to download queue..."; statusMessage.className = "status-message"; fetch("/api/rss/download", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId: id }) }) .then(response => response.json()) .then(data => { if (data.success) { statusMessage.textContent = "Item added to download queue!"; statusMessage.classList.add("status-success"); loadRssData(); } else { statusMessage.textContent = "Error adding item to download queue: " + data.message; statusMessage.classList.add("status-error"); } }) .catch(error => { statusMessage.textContent = "Error: " + error.message; statusMessage.classList.add("status-error"); console.error("Error downloading item:", error); }); } // Enhanced settings panel with book/magazine support function enhanceSettingsPage() { // Select the existing post-processing settings card const processingSettingsCard = document.querySelector('#settings-tab .card:nth-child(2)'); if (processingSettingsCard) { processingSettingsCard.innerHTML = `

Post-Processing Settings

Seeding Requirements

Content Categories

When enabled, books and magazines will be processed separately with specialized organization.

Media Paths

Archive Processing

File Organization

Quality Management

`; console.log("Post-processing settings panel has been enhanced"); } else { console.error("Could not find post-processing settings card"); } // Add a new section for the Media Library tab to show magazines separately const mediaTab = document.getElementById('media-tab'); if (mediaTab) { // Update the filter options in the Media Library tab const filterDiv = mediaTab.querySelector('.mb-2'); if (filterDiv) { filterDiv.innerHTML = ` `; } } } // Enhanced version of loadSettingsData function function enhancedLoadSettingsData() { fetch("/api/config") .then(response => response.json()) .then(config => { // Transmission settings document.getElementById("transmission-host").value = config.transmissionConfig.host || ''; document.getElementById("transmission-port").value = config.transmissionConfig.port || ''; document.getElementById("transmission-user").value = config.transmissionConfig.username || ''; // Password is masked in the API response, so we leave it blank // Post-processing settings document.getElementById("seeding-ratio").value = config.seedingRequirements.minRatio || ''; document.getElementById("seeding-time").value = config.seedingRequirements.minTimeMinutes || ''; document.getElementById("check-interval").value = config.seedingRequirements.checkIntervalSeconds || ''; // Media paths document.getElementById("movies-path").value = config.destinationPaths.movies || ''; document.getElementById("tvshows-path").value = config.destinationPaths.tvShows || ''; document.getElementById("music-path").value = config.destinationPaths.music || ''; document.getElementById("books-path").value = config.destinationPaths.books || ''; document.getElementById("magazines-path").value = config.destinationPaths.magazines || ''; document.getElementById("software-path").value = config.destinationPaths.software || ''; // Book and magazine sorting document.getElementById("enable-book-sorting").checked = config.processingOptions.enableBookSorting || false; // Archive options document.getElementById("extract-archives").checked = config.processingOptions.extractArchives; document.getElementById("delete-archives").checked = config.processingOptions.deleteArchives; // File organization options document.getElementById("create-category-folders").checked = config.processingOptions.createCategoryFolders; document.getElementById("rename-files").checked = config.processingOptions.renameFiles; document.getElementById("ignore-sample").checked = config.processingOptions.ignoreSample; document.getElementById("ignore-extras").checked = config.processingOptions.ignoreExtras; // Quality management options document.getElementById("auto-replace-upgrades").checked = config.processingOptions.autoReplaceUpgrades; document.getElementById("remove-duplicates").checked = config.processingOptions.removeDuplicates; document.getElementById("keep-best-version").checked = config.processingOptions.keepOnlyBestVersion; // RSS settings document.getElementById("rss-interval").value = config.rssUpdateIntervalMinutes || ''; // Add event listeners for settings buttons document.getElementById("test-connection").addEventListener("click", function() { testTransmissionConnection(); }); document.getElementById("save-transmission-settings").addEventListener("click", function() { saveTransmissionSettings(); }); document.getElementById("save-processing-settings").addEventListener("click", function() { saveProcessingSettings(); }); document.getElementById("save-rss-settings").addEventListener("click", function() { saveRssSettings(); }); document.getElementById("start-processor").addEventListener("click", function() { startPostProcessor(); }); document.getElementById("stop-processor").addEventListener("click", function() { stopPostProcessor(); }); }) .catch(error => { console.error("Error loading configuration:", error); }); } // Enhanced saveProcessingSettings function that includes book/magazine settings function saveProcessingSettings() { const minRatio = parseFloat(document.getElementById("seeding-ratio").value); const minTimeMinutes = parseInt(document.getElementById("seeding-time").value, 10); const checkIntervalSeconds = parseInt(document.getElementById("check-interval").value, 10); // Media paths const moviesPath = document.getElementById("movies-path").value; const tvShowsPath = document.getElementById("tvshows-path").value; const musicPath = document.getElementById("music-path").value; const booksPath = document.getElementById("books-path").value; const magazinesPath = document.getElementById("magazines-path").value; const softwarePath = document.getElementById("software-path").value; // Book and magazine sorting const enableBookSorting = document.getElementById("enable-book-sorting").checked; // Archive options const extractArchives = document.getElementById("extract-archives").checked; const deleteArchives = document.getElementById("delete-archives").checked; // File organization options const createCategoryFolders = document.getElementById("create-category-folders").checked; const renameFiles = document.getElementById("rename-files").checked; const ignoreSample = document.getElementById("ignore-sample").checked; const ignoreExtras = document.getElementById("ignore-extras").checked; // Quality management options const autoReplaceUpgrades = document.getElementById("auto-replace-upgrades").checked; const removeDuplicates = document.getElementById("remove-duplicates").checked; const keepOnlyBestVersion = document.getElementById("keep-best-version").checked; statusMessage.textContent = "Saving processing settings..."; statusMessage.className = "status-message"; fetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ seedingRequirements: { minRatio, minTimeMinutes, checkIntervalSeconds }, destinationPaths: { movies: moviesPath, tvShows: tvShowsPath, music: musicPath, books: booksPath, magazines: magazinesPath, software: softwarePath }, processingOptions: { enableBookSorting, extractArchives, deleteArchives, createCategoryFolders, renameFiles, ignoreSample, ignoreExtras, autoReplaceUpgrades, removeDuplicates, keepOnlyBestVersion } }) }) .then(response => response.json()) .then(data => { if (data.success) { statusMessage.textContent = "Processing settings saved successfully!"; statusMessage.classList.add("status-success"); } else { statusMessage.textContent = "Error saving settings: " + data.message; statusMessage.classList.add("status-error"); } }) .catch(error => { statusMessage.textContent = "Error: " + error.message; statusMessage.classList.add("status-error"); console.error("Error saving settings:", error); }); } // Update getCategoryTitle function to include magazines function getCategoryTitle(category) { switch(category) { case 'movies': return 'Movies'; case 'tvShows': return 'TV Shows'; case 'music': return 'Music'; case 'books': return 'Books'; case 'magazines': return 'Magazines'; case 'software': return 'Software'; default: return category.charAt(0).toUpperCase() + category.slice(1); } } // Make sure to add this initialization code document.addEventListener("DOMContentLoaded", function() { // Add a hook to enhance the settings page after the page loads enhanceSettingsPage(); // Override the loadSettingsData function window.loadSettingsData = function() { enhancedLoadSettingsData(); }; }); EOF # Create the postProcessor.js file echo -e "${YELLOW}Creating postProcessor.js...${NC}" cat > $INSTALL_DIR/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(); } 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() { 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() { if (this.processingIntervalId) { clearInterval(this.processingIntervalId); this.processingIntervalId = null; console.log('Post-processor stopped'); } } 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); } } getTransmissionTorrents() { return new Promise((resolve, reject) => { this.transmissionClient.get((err, result) => { if (err) { reject(err); } else { resolve(result.torrents || []); } }); }); } 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; }); } 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; } } 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; } 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); } 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; } } 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; } } 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); } isArchiveFile(fileName) { const ext = path.extname(fileName).toLowerCase(); return ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'].includes(ext); } isSampleFile(fileName) { return fileName.toLowerCase().includes('sample'); } isExtrasFile(fileName) { const lowerName = fileName.toLowerCase(); return ['featurette', 'extra', 'bonus', 'deleted', 'interview'].some(term => lowerName.includes(term)); } 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; } } 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}`); } } addToLibrary(filePath, category) { if (!this.library[category]) { this.library[category] = []; } const fileName = path.basename(filePath); // Check if file is already in the library if (!this.library[category].includes(fileName)) { this.library[category].push({ name: fileName, path: filePath, added: new Date().toISOString() }); } } 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] = []; // Scan the directory await this.scanDirectory(destDir, category); } console.log('Library updated'); } catch (error) { console.error('Error updating library:', error); } } 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); } } removeTorrentFromTransmission(torrentId) { return new Promise((resolve, reject) => { this.transmissionClient.remove(torrentId, true, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); } // Public API methods getLibrary() { return this.library; } 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; } 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 # Create rssFeedManager.js file echo -e "${YELLOW}Creating rssFeedManager.js...${NC}" cat > $INSTALL_DIR/rssFeedManager.js << 'EOF' // rssFeedManager.js const fs = require('fs').promises; const path = require('path'); const fetch = require('node-fetch'); const xml2js = require('xml2js'); const crypto = require('crypto'); class RssFeedManager { constructor(config) { this.config = config; this.feeds = config.feeds || []; this.items = []; this.updateIntervalId = null; this.updateIntervalMinutes = config.updateIntervalMinutes || 60; this.parser = new xml2js.Parser({ explicitArray: false }); } async start() { if (this.updateIntervalId) { return; } // Run update immediately await this.updateAllFeeds(); // Then set up interval this.updateIntervalId = setInterval(async () => { await this.updateAllFeeds(); }, this.updateIntervalMinutes * 60 * 1000); console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`); } stop() { if (this.updateIntervalId) { clearInterval(this.updateIntervalId); this.updateIntervalId = null; console.log('RSS feed manager stopped'); return true; } return false; } async updateAllFeeds() { console.log('Updating all RSS feeds...'); const results = []; for (const feed of this.feeds) { try { const result = await this.updateFeed(feed); results.push({ feedId: feed.id, success: true, newItems: result.newItems }); } catch (error) { console.error(`Error updating feed ${feed.id} (${feed.url}):`, error.message); results.push({ feedId: feed.id, success: false, error: error.message }); } } // Save updated items await this.saveItems(); console.log('RSS feed update completed'); return results; } async updateFeed(feed) { console.log(`Updating feed: ${feed.name} (${feed.url})`); const response = await fetch(feed.url); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); } const xml = await response.text(); const result = await this.parseXml(xml); const rssItems = this.extractItems(result, feed); const newItems = this.processNewItems(rssItems, feed); console.log(`Found ${rssItems.length} items, ${newItems.length} new items in feed: ${feed.name}`); return { totalItems: rssItems.length, newItems: newItems.length }; } parseXml(xml) { return new Promise((resolve, reject) => { this.parser.parseString(xml, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); }); } extractItems(parsedXml, feed) { try { // Handle standard RSS 2.0 if (parsedXml.rss && parsedXml.rss.channel) { const channel = parsedXml.rss.channel; const items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean); return items.map(item => this.normalizeRssItem(item, feed)); } // Handle Atom if (parsedXml.feed && parsedXml.feed.entry) { const entries = Array.isArray(parsedXml.feed.entry) ? parsedXml.feed.entry : [parsedXml.feed.entry].filter(Boolean); return entries.map(entry => this.normalizeAtomItem(entry, feed)); } return []; } catch (error) { console.error('Error extracting items from XML:', error); return []; } } normalizeRssItem(item, feed) { // Create a unique ID for the item const idContent = `${feed.id}:${item.title}:${item.pubDate || ''}:${item.link || ''}`; const id = crypto.createHash('md5').update(idContent).digest('hex'); // Extract enclosure (torrent link) let torrentLink = item.link || ''; let fileSize = 0; if (item.enclosure) { torrentLink = item.enclosure.$ ? item.enclosure.$.url : item.enclosure.url || torrentLink; fileSize = item.enclosure.$ ? parseInt(item.enclosure.$.length || 0, 10) : parseInt(item.enclosure.length || 0, 10); } // Handle custom namespaces (common in torrent feeds) let category = ''; let size = fileSize; if (item.category) { category = Array.isArray(item.category) ? item.category[0] : item.category; } // Some feeds use torrent:contentLength if (item['torrent:contentLength']) { size = parseInt(item['torrent:contentLength'], 10); } return { id, feedId: feed.id, title: entry.title || 'Untitled', link: link, torrentLink: torrentLink, pubDate: entry.updated || entry.published || new Date().toISOString(), category: entry.category?.$.term || '', description: entry.summary || entry.content || '', size: 0, // Atom usually doesn't include size downloaded: false, ignored: false, added: new Date().toISOString() }; } processNewItems(rssItems, feed) { const newItems = []; for (const item of rssItems) { // Check if item already exists const existingItem = this.items.find(i => i.id === item.id); if (!existingItem) { // Add to items list this.items.push(item); newItems.push(item); // Check if we should auto-download this item based on feed filters if (feed.autoDownload && this.itemMatchesFilters(item, feed.filters)) { this.queueItemForDownload(item); } } } return newItems; } itemMatchesFilters(item, filters) { if (!filters || !Array.isArray(filters) || filters.length === 0) { return false; } for (const filter of filters) { let matches = true; // Match title if (filter.title) { const regex = new RegExp(filter.title, 'i'); if (!regex.test(item.title)) { matches = false; } } // Match category if (filter.category && item.category) { if (item.category.toLowerCase() !== filter.category.toLowerCase()) { matches = false; } } // Match min size if (filter.minSize && item.size) { if (item.size < filter.minSize) { matches = false; } } // Match max size if (filter.maxSize && item.size) { if (item.size > filter.maxSize) { matches = false; } } // If all conditions match, return true if (matches) { return true; } } return false; } queueItemForDownload(item) { console.log(`Auto-download queued for item: ${item.title}`); // In a real implementation, we would either: // 1. Call downloadItem right away // 2. Add to a queue that gets processed by another service // For now, just mark as queued item.queued = true; } async downloadItem(item, transmissionClient) { if (!item.torrentLink) { return { success: false, message: 'No torrent link available' }; } return new Promise((resolve, reject) => { transmissionClient.addUrl(item.torrentLink, (err, result) => { if (err) { console.error(`Error adding item to Transmission: ${err.message}`); reject({ success: false, message: `Error adding to Transmission: ${err.message}` }); } else { // Mark as downloaded item.downloaded = true; item.downloadDate = new Date().toISOString(); // Update the item in our items array const index = this.items.findIndex(i => i.id === item.id); if (index !== -1) { this.items[index] = item; } // Save items this.saveItems(); resolve({ success: true, message: 'Item added to Transmission', result }); } }); }); } // File operations async saveItems() { try { await fs.writeFile( path.join(__dirname, 'rss-items.json'), JSON.stringify(this.items, null, 2), 'utf8' ); return true; } catch (error) { console.error('Error saving RSS items:', error); return false; } } async loadItems() { try { const data = await fs.readFile(path.join(__dirname, 'rss-items.json'), 'utf8'); this.items = JSON.parse(data); console.log(`Loaded ${this.items.length} RSS items`); return true; } catch (error) { // If file doesn't exist yet, just return false if (error.code === 'ENOENT') { console.log('No saved RSS items found, starting fresh'); return false; } console.error('Error loading RSS items:', error); return false; } } async saveConfig() { try { // In a real implementation, this might save to a database or config file return true; } catch (error) { console.error('Error saving RSS config:', error); return false; } } // Public API methods getAllFeeds() { return this.feeds; } getFeed(id) { return this.feeds.find(feed => feed.id === id); } addFeed(feed) { // Generate an ID if not provided if (!feed.id) { feed.id = crypto.randomUUID(); } // Add creation timestamp feed.added = new Date().toISOString(); // Add to feeds array this.feeds.push(feed); return feed; } updateFeedConfig(id, updates) { const index = this.feeds.findIndex(feed => feed.id === id); if (index === -1) { return false; } // Update the feed this.feeds[index] = { ...this.feeds[index], ...updates, updated: new Date().toISOString() }; return true; } removeFeed(id) { const initialLength = this.feeds.length; this.feeds = this.feeds.filter(feed => feed.id !== id); // Also remove items from this feed this.items = this.items.filter(item => item.feedId !== id); return this.feeds.length < initialLength; } getAllItems() { return this.items; } getUndownloadedItems() { return this.items.filter(item => !item.downloaded && !item.ignored); } filterItems(filters) { if (!filters) { return this.items; } return this.items.filter(item => { let matches = true; if (filters.title) { const regex = new RegExp(filters.title, 'i'); if (!regex.test(item.title)) { matches = false; } } if (filters.feedId) { if (item.feedId !== filters.feedId) { matches = false; } } if (filters.downloaded !== undefined) { if (item.downloaded !== filters.downloaded) { matches = false; } } if (filters.ignored !== undefined) { if (item.ignored !== filters.ignored) { matches = false; } } return matches; }); } } module.exports = RssFeedManager;d.id, title: item.title || 'Untitled', link: item.link || '', torrentLink: torrentLink, pubDate: item.pubDate || new Date().toISOString(), category: category, description: item.description || '', size: size || 0, downloaded: false, ignored: false, added: new Date().toISOString() }; } normalizeAtomItem(entry, feed) { // Create a unique ID for the item const idContent = `${feed.id}:${entry.title}:${entry.updated || ''}:${entry.id || ''}`; const id = crypto.createHash('md5').update(idContent).digest('hex'); // Extract link let link = ''; let torrentLink = ''; if (entry.link) { if (Array.isArray(entry.link)) { const links = entry.link; link = links.find(l => l.$.rel === 'alternate')?.$.href || links[0]?.$.href || ''; torrentLink = links.find(l => l.$.type && l.$.type.includes('torrent'))?.$.href || link; } else { link = entry.link.$.href || ''; torrentLink = link; } } return { id, feedId: fee