From d1483ce58151ad2e8d78e9e92e168a8d8b94041c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 26 Feb 2025 14:15:12 +0100 Subject: [PATCH] split install up into smaller files due to size of file --- README.md | 0 install-script.sh | 3094 +++---------------------------- main-installer.sh | 62 + modules/config-module.sh | 136 ++ modules/dependencies-module.sh | 60 + modules/file-creator-module.sh | 1699 +++++++++++++++++ modules/rss-feed-manager.js | 456 +++++ modules/service-setup-module.sh | 83 + modules/utils-module.sh | 144 ++ public/index.html | 1386 ++++++++++++++ 10 files changed, 4284 insertions(+), 2836 deletions(-) mode change 100644 => 100755 README.md create mode 100755 main-installer.sh create mode 100644 modules/config-module.sh create mode 100644 modules/dependencies-module.sh create mode 100644 modules/file-creator-module.sh create mode 100644 modules/rss-feed-manager.js create mode 100644 modules/service-setup-module.sh create mode 100644 modules/utils-module.sh create mode 100644 public/index.html diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/install-script.sh b/install-script.sh index f1454a6..f7d8ee5 100755 --- a/install-script.sh +++ b/install-script.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Enhanced Transmission RSS Manager Installation Script for Ubuntu -# Includes support for book/magazine sorting and all UI enhancements +# Transmission RSS Manager Installer Script +# Main entry point for the installation # Text formatting BOLD='\033[1m' @@ -25,7 +25,19 @@ fi # Get current directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -# Configuration variables +# Create modules directory if it doesn't exist +mkdir -p "${SCRIPT_DIR}/modules" + +# Check if modules exist, if not, extract them +if [ ! -f "${SCRIPT_DIR}/modules/config.sh" ]; then + echo -e "${YELLOW}Creating module files...${NC}" + + # Create config module + cat > "${SCRIPT_DIR}/modules/config.sh" << 'EOL' +#!/bin/bash +# Configuration module for Transmission RSS Manager Installation + +# Configuration variables with defaults INSTALL_DIR="/opt/transmission-rss-manager" SERVICE_NAME="transmission-rss-manager" USER=$(logname || echo $SUDO_USER) @@ -41,2858 +53,268 @@ 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 +# Media path defaults +MEDIA_DIR="/mnt/media" +ENABLE_BOOK_SORTING=true -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 +function gather_configuration() { + echo -e "${BOLD}Installation Configuration:${NC}" + echo -e "Please provide the following configuration parameters:" 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." + + 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 - - # 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 < "${SCRIPT_DIR}/modules/utils.sh" << 'EOL' +#!/bin/bash +# Utilities module for Transmission RSS Manager Installation + +# Function to log a message with timestamp +function log() { + local level=$1 + local message=$2 + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') - # Set Transmission download dir for configuration - TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR -else - read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir - TRANSMISSION_DOWNLOAD_DIR=${input_trans_dir:-$TRANSMISSION_DOWNLOAD_DIR} -fi + case $level in + "INFO") + echo -e "${timestamp} ${GREEN}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message" + ;; + "ERROR") + echo -e "${timestamp} ${RED}[ERROR]${NC} $message" + ;; + *) + echo -e "${timestamp} [LOG] $message" + ;; + esac +} -echo -echo -e "${BOLD}Media Destination Configuration:${NC}" +# Function to check if a command exists +function command_exists() { + command -v "$1" &> /dev/null +} -read -p "Media destination base directory [/mnt/media]: " MEDIA_DIR -MEDIA_DIR=${MEDIA_DIR:-"/mnt/media"} +# Function to backup a file before modifying it +function backup_file() { + local file=$1 + if [ -f "$file" ]; then + local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" + cp "$file" "$backup" + log "INFO" "Created backup of $file at $backup" + fi +} -# Ask about enabling book/magazine sorting -echo -echo -e "${BOLD}Content Type Configuration:${NC}" -read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting -ENABLE_BOOK_SORTING=true -if [[ $input_book_sorting =~ ^[Nn]$ ]]; then - ENABLE_BOOK_SORTING=false -fi +# Function to create a directory if it doesn't exist +function create_dir_if_not_exists() { + local dir=$1 + local owner=$2 + + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + log "INFO" "Created directory: $dir" + + if [ -n "$owner" ]; then + chown -R "$owner" "$dir" + log "INFO" "Set ownership of $dir to $owner" + fi + fi +} -echo -echo -e "${YELLOW}Installing dependencies...${NC}" - -# Update package index -apt-get update - -# Install Node.js and npm if not already installed -if ! command -v node &> /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 +# Function to finalize the setup (permissions, etc.) +function finalize_setup() { + log "INFO" "Setting up final permissions and configurations..." + + # Set proper ownership for the installation directory + chown -R $USER:$USER $INSTALL_DIR + + # Create media directories with correct permissions + create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER" + + # Create book/magazine directories if enabled + if [ "$ENABLE_BOOK_SORTING" = true ]; then + create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER" + fi + + # Install NPM packages + log "INFO" "Installing NPM packages..." + cd $INSTALL_DIR && npm install + + # Start the service + log "INFO" "Starting the service..." + systemctl daemon-reload + systemctl enable $SERVICE_NAME + systemctl start $SERVICE_NAME + + # Check if service started successfully + sleep 2 + if systemctl is-active --quiet $SERVICE_NAME; then + log "INFO" "Service started successfully!" + else + log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME" + fi + + # Create default configuration if it doesn't exist + if [ ! -f "$INSTALL_DIR/config.json" ]; then + log "INFO" "Creating default configuration file..." + cat > $INSTALL_DIR/config.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" + "transmissionConfig": { + "host": "${TRANSMISSION_HOST}", + "port": ${TRANSMISSION_PORT}, + "username": "${TRANSMISSION_USER}", + "password": "${TRANSMISSION_PASS}", + "path": "${TRANSMISSION_RPC_PATH}" }, - "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" - } + "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 } 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 - }); + chown $USER:$USER $INSTALL_DIR/config.json + fi - 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 + log "INFO" "Setup finalized!" +} \ No newline at end of file diff --git a/main-installer.sh b/main-installer.sh new file mode 100755 index 0000000..708caf8 --- /dev/null +++ b/main-installer.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Transmission RSS Manager Modular Installer +# Main installer script that coordinates the installation process + +# 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 )" + +# Source the module files +source "${SCRIPT_DIR}/modules/config.sh" +source "${SCRIPT_DIR}/modules/utils.sh" +source "${SCRIPT_DIR}/modules/dependencies.sh" +source "${SCRIPT_DIR}/modules/file_creator.sh" +source "${SCRIPT_DIR}/modules/service_setup.sh" + +# Execute the installation steps in sequence +echo -e "${YELLOW}Starting installation process...${NC}" + +# Step 1: Gather configuration from user +gather_configuration + +# Step 2: Install dependencies +install_dependencies + +# Step 3: Create installation directories +create_directories + +# Step 4: Create configuration files and scripts +create_config_files + +# Step 5: Create service files and install the service +setup_service + +# Step 6: Final setup and permissions +finalize_setup + +echo -e "${GREEN}Installation completed successfully!${NC}" +echo -e "You can access the RSS Manager at ${BOLD}http://localhost:${PORT}${NC} or ${BOLD}http://your-server-ip:${PORT}${NC}" +echo +echo -e "The service is ${BOLD}automatically started${NC} and will ${BOLD}start on boot${NC}." +echo -e "To manually control the service, use: ${BOLD}sudo systemctl [start|stop|restart] ${SERVICE_NAME}${NC}" +echo +echo -e "${BOLD}Thank you for installing Transmission RSS Manager Enhanced Edition!${NC}" diff --git a/modules/config-module.sh b/modules/config-module.sh new file mode 100644 index 0000000..0fc8701 --- /dev/null +++ b/modules/config-module.sh @@ -0,0 +1,136 @@ +#!/bin/bash +# Configuration module for Transmission RSS Manager Installation + +# Configuration variables with defaults +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="{}" + +# Media path defaults +MEDIA_DIR="/mnt/media" +ENABLE_BOOK_SORTING=true + +function gather_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 < /etc/apt/sources.list.d/nodesource.list + apt-get update + apt-get install -y nodejs + else + echo "Node.js is already installed." + fi + + # Install additional dependencies + echo "Installing additional dependencies..." + apt-get install -y unrar unzip p7zip-full nginx + + # Check if all dependencies were installed successfully + local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") + local missing_deps=() + + for dep in "${dependencies[@]}"; do + if ! command_exists "$dep"; then + missing_deps+=("$dep") + fi + done + + if [ ${#missing_deps[@]} -eq 0 ]; then + echo -e "${GREEN}All dependencies installed successfully.${NC}" + else + echo -e "${RED}Failed to install some dependencies: ${missing_deps[*]}${NC}" + echo -e "${YELLOW}Please install them manually and rerun this script.${NC}" + exit 1 + fi +} + +function create_directories() { + echo -e "${YELLOW}Creating installation directories...${NC}" + + # Create main installation directory + mkdir -p $INSTALL_DIR + mkdir -p $INSTALL_DIR/logs + mkdir -p $INSTALL_DIR/public/js + mkdir -p $INSTALL_DIR/public/css + mkdir -p $INSTALL_DIR/modules + + # Create directory for file storage + mkdir -p $INSTALL_DIR/data + + echo -e "${GREEN}Directories created successfully.${NC}" +} diff --git a/modules/file-creator-module.sh b/modules/file-creator-module.sh new file mode 100644 index 0000000..3d262b7 --- /dev/null +++ b/modules/file-creator-module.sh @@ -0,0 +1,1699 @@ +#!/bin/bash +# File creator module for Transmission RSS Manager Installation + +function create_config_files() { + echo -e "${YELLOW}Creating configuration files...${NC}" + + # Create package.json + echo "Creating package.json..." + 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 "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; \ No newline at end of file diff --git a/modules/rss-feed-manager.js b/modules/rss-feed-manager.js new file mode 100644 index 0000000..f15db0d --- /dev/null +++ b/modules/rss-feed-manager.js @@ -0,0 +1,456 @@ +// 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 }); + this.dataPath = path.join(__dirname, '..', 'data'); + } + + 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})`); + + try { + 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 + }; + } catch (error) { + console.error(`Error updating feed ${feed.url}:`, error); + throw error; + } + } + + 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: 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: 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 doesn't typically include file size + downloaded: false, + ignored: false, + added: new Date().toISOString() + }; + } + + processNewItems(rssItems, feed) { + const newItems = []; + + for (const item of rssItems) { + // Check if item already exists in our list + const existingItem = this.items.find(i => i.id === item.id); + + if (!existingItem) { + // Add new item to our list + this.items.push(item); + newItems.push(item); + + // Auto-download if enabled and matches filters + if (feed.autoDownload && this.matchesFilters(item, feed.filters)) { + this.queueItemForDownload(item); + } + } + } + + return newItems; + } + + matchesFilters(item, filters) { + if (!filters || filters.length === 0) { + return true; + } + + // Check if the item matches any of the filters + return filters.some(filter => { + // Title check + if (filter.title && !item.title.toLowerCase().includes(filter.title.toLowerCase())) { + return false; + } + + // Category check + if (filter.category && !item.category.toLowerCase().includes(filter.category.toLowerCase())) { + return false; + } + + // Size check + if (filter.minSize && item.size < filter.minSize) { + return false; + } + + if (filter.maxSize && item.size > filter.maxSize) { + return false; + } + + // All checks passed + return true; + }); + } + + queueItemForDownload(item) { + // Mark the item as queued for download + console.log(`Auto-downloading item: ${item.title}`); + + // This would be implemented to add to Transmission + // But we need a reference to the Transmission client + // In a real implementation, this might publish to a queue that's consumed elsewhere + item.downloadQueued = true; + } + + async saveItems() { + try { + // Create data directory if it doesn't exist + await fs.mkdir(this.dataPath, { recursive: true }); + + // Save items to file + await fs.writeFile( + path.join(this.dataPath, 'rss-items.json'), + JSON.stringify(this.items, null, 2), + 'utf8' + ); + + console.log(`Saved ${this.items.length} RSS items to disk`); + return true; + } catch (error) { + console.error('Error saving RSS items:', error); + return false; + } + } + + async saveConfig() { + try { + // Create data directory if it doesn't exist + await fs.mkdir(this.dataPath, { recursive: true }); + + // Save feeds to file + await fs.writeFile( + path.join(this.dataPath, 'rss-feeds.json'), + JSON.stringify(this.feeds, null, 2), + 'utf8' + ); + + console.log(`Saved ${this.feeds.length} RSS feeds to disk`); + return true; + } catch (error) { + console.error('Error saving RSS feeds:', error); + return false; + } + } + + async loadItems() { + try { + const filePath = path.join(this.dataPath, 'rss-items.json'); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + console.log('No saved RSS items found'); + return false; + } + + // Load items from file + const data = await fs.readFile(filePath, 'utf8'); + this.items = JSON.parse(data); + + console.log(`Loaded ${this.items.length} RSS items from disk`); + return true; + } catch (error) { + console.error('Error loading RSS items:', error); + return false; + } + } + + // Public API methods + + getAllFeeds() { + return this.feeds; + } + + addFeed(feedData) { + // Generate an ID for the feed + const id = crypto.randomBytes(8).toString('hex'); + + const newFeed = { + id, + name: feedData.name, + url: feedData.url, + autoDownload: feedData.autoDownload || false, + filters: feedData.filters || [], + added: new Date().toISOString() + }; + + this.feeds.push(newFeed); + + console.log(`Added new feed: ${newFeed.name} (${newFeed.url})`); + + return newFeed; + } + + updateFeedConfig(feedId, updates) { + const feedIndex = this.feeds.findIndex(f => f.id === feedId); + + if (feedIndex === -1) { + return false; + } + + // Update the feed, preserving the id and added date + this.feeds[feedIndex] = { + ...this.feeds[feedIndex], + ...updates, + id: feedId, + added: this.feeds[feedIndex].added + }; + + console.log(`Updated feed: ${this.feeds[feedIndex].name}`); + + return true; + } + + removeFeed(feedId) { + const initialLength = this.feeds.length; + this.feeds = this.feeds.filter(f => f.id !== feedId); + + return this.feeds.length !== initialLength; + } + + getAllItems() { + return this.items; + } + + getUndownloadedItems() { + return this.items.filter(item => !item.downloaded && !item.ignored); + } + + filterItems(filters) { + return this.items.filter(item => this.matchesFilters(item, [filters])); + } + + async downloadItem(item, transmissionClient) { + if (!item || !item.torrentLink) { + return { + success: false, + message: 'Invalid item or missing torrent link' + }; + } + + if (!transmissionClient) { + return { + success: false, + message: 'Transmission client not available' + }; + } + + return new Promise((resolve) => { + transmissionClient.addUrl(item.torrentLink, (err, result) => { + if (err) { + console.error(`Error adding torrent for ${item.title}:`, err); + resolve({ + success: false, + message: `Error adding torrent: ${err.message}`, + result: null + }); + return; + } + + // Mark the item as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + + // Save the updated items + this.saveItems().catch(err => { + console.error('Error saving items after download:', err); + }); + + console.log(`Successfully added torrent for item: ${item.title}`); + + resolve({ + success: true, + message: 'Torrent added successfully', + result + }); + }); + }); + } +} + +module.exports = RssFeedManager; \ No newline at end of file diff --git a/modules/service-setup-module.sh b/modules/service-setup-module.sh new file mode 100644 index 0000000..e8483cc --- /dev/null +++ b/modules/service-setup-module.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Service setup module for Transmission RSS Manager Installation + +# Setup systemd service +function setup_service() { + echo -e "${YELLOW}Setting up systemd service...${NC}" + + # Create systemd service file + cat > /etc/systemd/system/$SERVICE_NAME.service << EOF +[Unit] +Description=Transmission RSS Manager +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$INSTALL_DIR +ExecStart=/usr/bin/node $INSTALL_DIR/server.js +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +Environment=PORT=$PORT +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +EOF + + # Create nginx configuration for proxy + echo -e "${YELLOW}Setting up Nginx reverse proxy...${NC}" + + # Check if default nginx file exists, back it up if it does + if [ -f /etc/nginx/sites-enabled/default ]; then + mv /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.bak + echo "Backed up default nginx configuration." + fi + + # Create nginx configuration + cat > /etc/nginx/sites-available/$SERVICE_NAME << EOF +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:$PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } +} +EOF + + # Create symbolic link to enable the site + ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/ + + # Test nginx configuration + nginx -t + + if [ $? -eq 0 ]; then + # Reload nginx + systemctl reload nginx + echo -e "${GREEN}Nginx configuration has been set up successfully.${NC}" + else + echo -e "${RED}Nginx configuration test failed. Please check the configuration manually.${NC}" + echo -e "${YELLOW}You may need to correct the configuration before the web interface will be accessible.${NC}" + fi + + # Reload systemd + systemctl daemon-reload + + # Enable the service to start on boot + systemctl enable $SERVICE_NAME + + echo -e "${GREEN}Systemd service has been created and enabled.${NC}" + echo -e "${YELLOW}The service will start automatically after installation.${NC}" +} \ No newline at end of file diff --git a/modules/utils-module.sh b/modules/utils-module.sh new file mode 100644 index 0000000..15e35c5 --- /dev/null +++ b/modules/utils-module.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Utilities module for Transmission RSS Manager Installation + +# Function to log a message with timestamp +function log() { + local level=$1 + local message=$2 + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + case $level in + "INFO") + echo -e "${timestamp} ${GREEN}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message" + ;; + "ERROR") + echo -e "${timestamp} ${RED}[ERROR]${NC} $message" + ;; + *) + echo -e "${timestamp} [LOG] $message" + ;; + esac +} + +# Function to check if a command exists +function command_exists() { + command -v "$1" &> /dev/null +} + +# Function to backup a file before modifying it +function backup_file() { + local file=$1 + if [ -f "$file" ]; then + local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" + cp "$file" "$backup" + log "INFO" "Created backup of $file at $backup" + fi +} + +# Function to create a directory if it doesn't exist +function create_dir_if_not_exists() { + local dir=$1 + local owner=$2 + + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + log "INFO" "Created directory: $dir" + + if [ -n "$owner" ]; then + chown -R "$owner" "$dir" + log "INFO" "Set ownership of $dir to $owner" + fi + fi +} + +# Function to finalize the setup (permissions, etc.) +function finalize_setup() { + log "INFO" "Setting up final permissions and configurations..." + + # Set proper ownership for the installation directory + chown -R $USER:$USER $INSTALL_DIR + + # Create media directories with correct permissions + create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER" + + # Create book/magazine directories if enabled + if [ "$ENABLE_BOOK_SORTING" = true ]; then + create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER" + fi + + # Install NPM packages + log "INFO" "Installing NPM packages..." + cd $INSTALL_DIR && npm install + + # Start the service + log "INFO" "Starting the service..." + systemctl daemon-reload + systemctl enable $SERVICE_NAME + systemctl start $SERVICE_NAME + + # Check if service started successfully + sleep 2 + if systemctl is-active --quiet $SERVICE_NAME; then + log "INFO" "Service started successfully!" + else + log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME" + fi + + # Create default configuration if it doesn't exist + if [ ! -f "$INSTALL_DIR/config.json" ]; then + log "INFO" "Creating default configuration file..." + cat > $INSTALL_DIR/config.json << EOF +{ + "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 +} +EOF + chown $USER:$USER $INSTALL_DIR/config.json + fi + + log "INFO" "Setup finalized!" +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5b4bc8a --- /dev/null +++ b/public/index.html @@ -0,0 +1,1386 @@ + + + + + + Transmission RSS Manager + + + +
+

Transmission RSS Manager

+ +
+ +
+ + + +
+
+

Dashboard

+
+

Loading system status...

+
+ +

Quick Actions

+
+
+

RSS Manager

+

Manage your RSS feeds and automatic downloads

+ +
+ +
+

Post-Processing

+

Process completed downloads to your media library

+ + +
+ +
+

Add New Torrent

+

Add a torrent URL directly to Transmission

+
+ +
+ +
+
+
+
+ + +
+
+

RSS Feeds

+

Manage your RSS feeds and download filters

+ + + +
+

Loading feeds...

+
+
+ +
+

Available Items

+

Browse and download items from your RSS feeds

+ +
+ + +
+ +
+

Loading items...

+
+
+
+ + +
+
+

Active Torrents

+ +
+ +
+ +
+

Loading torrents...

+
+
+
+ + +
+
+

Media Library

+ +
+ + + + + + + +
+ +
+ +
+ +
+

Loading media library...

+
+
+
+ + +
+
+

Transmission Settings

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

Post-Processing Settings

+ +

Seeding Requirements

+
+ + +
+
+ + +
+
+ + +
+ +

Media Paths

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Archive Processing

+
+ +
+
+ +
+ +

File Organization

+
+ +
+
+ +
+
+ +
+
+ +
+ + + +
+ + +
+
+ +
+

RSS Settings

+ +
+ + +
+ + +
+
+
+ + + + +('Error loading status:', error); + }); + } + + function loadTorrentsData() { + fetch('/api/transmission/torrents') + .then(response => response.json()) + .then(data => { + if (!data.success) { + document.getElementById('torrents-container').innerHTML = ` +
+

${data.message}

+
+ `; + return; + } + + if (!data.data || data.data.length === 0) { + document.getElementById('torrents-container').innerHTML = ` +

No active torrents.

+ `; + return; + } + + let html = ` + + + + + + + + + + + + + `; + + data.data.forEach(torrent => { + const status = getTorrentStatus(torrent); + const progressPercent = Math.round(torrent.percentDone * 100); + const size = formatBytes(torrent.totalSize); + + html += ` + + + + + + + + + `; + }); + + html += ` + +
NameStatusProgressSizeRatioActions
${torrent.name}${status}${progressPercent}%${size}${torrent.uploadRatio.toFixed(2)} + ${torrent.status === 0 ? + `` : + `` + } + +
+ `; + + document.getElementById('torrents-container').innerHTML = html; + }) + .catch(error => { + document.getElementById('torrents-container').innerHTML = ` +
+

Error loading torrents: ${error.message}

+
+ `; + console.error('Error loading torrents:', error); + }); + } + + function getTorrentStatus(torrent) { + const statusMap = { + 0: 'Stopped', + 1: 'Check Waiting', + 2: 'Checking', + 3: 'Download Waiting', + 4: 'Downloading', + 5: 'Seed Waiting', + 6: 'Seeding' + }; + + return statusMap[torrent.status] || 'Unknown'; + } + + function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + } + + function loadRssData() { + // Load RSS feeds + fetch('/api/rss/feeds') + .then(response => response.json()) + .then(data => { + if (!data.success) { + document.getElementById('feeds-container').innerHTML = ` +
+

${data.message}

+
+ `; + return; + } + + if (!data.data || data.data.length === 0) { + document.getElementById('feeds-container').innerHTML = ` +

No RSS feeds configured. Click "Add New Feed" to add one.

+ `; + return; + } + + let html = ` + + + + + + + + + + + `; + + data.data.forEach(feed => { + html += ` + + + + + + + `; + }); + + html += ` + +
NameURLAuto-DownloadActions
${feed.name}${feed.url}${feed.autoDownload ? 'Yes' : 'No'} + + +
+ `; + + document.getElementById('feeds-container').innerHTML = html; + }) + .catch(error => { + document.getElementById('feeds-container').innerHTML = ` +
+

Error loading feeds: ${error.message}

+
+ `; + console.error('Error loading feeds:', error); + }); + + // Load RSS items + const itemFilter = document.querySelector('input[name="item-filter"]:checked').value; + + fetch(`/api/rss/items?filter=${itemFilter}`) + .then(response => response.json()) + .then(data => { + if (!data.success) { + document.getElementById('items-container').innerHTML = ` +
+

${data.message}

+
+ `; + return; + } + + if (!data.data || data.data.length === 0) { + document.getElementById('items-container').innerHTML = ` +

No items found.

+ `; + return; + } + + let html = ` + + + + + + + + + + + + `; + + data.data.forEach(item => { + const feed = data.data.find(f => f.id === item.feedId); + const feedName = feed ? feed.name : 'Unknown'; + const size = item.size ? formatBytes(item.size) : 'Unknown'; + const date = new Date(item.pubDate).toLocaleDateString(); + + html += ` + + + + + + + + `; + }); + + html += ` + +
TitleFeedSizeDateActions
${item.title}${feedName}${size}${date} + +
+ `; + + document.getElementById('items-container').innerHTML = html; + }) + .catch(error => { + document.getElementById('items-container').innerHTML = ` +
+

Error loading items: ${error.message}

+
+ `; + console.error \ No newline at end of file