diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..32832bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Node.js dependencies +node_modules/ +package-lock.json + +# Environment and configuration +.env +config.json + +# Log files +logs/ +*.log +npm-debug.log* + +# Temporary and build files +temp/ +dist/ +build/ +.DS_Store +.vscode/ +.idea/ + +# Data files +data/ +rss-items.json +rss-feeds.json + +# Authentication +*.pem +*.key +*.crt \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6ddd2ae --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# Transmission RSS Manager - Development Guide + +## Commands +- Install dependencies: `npm install` (needed for rss-feed-manager.js) +- Setup: `./main-installer.sh` (main installation script) +- Run application: `node modules/rss-feed-manager.js` + +## Code Style Guidelines + +### JavaScript +- Indentation: 2 spaces +- Naming: camelCase for variables/functions, PascalCase for classes +- Semicolons: required +- Imports: group standard libraries first, then custom modules +- Error handling: use try/catch with descriptive error messages +- Functions: prefer arrow functions for callbacks +- String formatting: use template literals (`${variable}`) + +### Bash Scripts +- Indentation: 2 spaces +- Function definition: use `function name() {}` +- Comments: add descriptive comments before functions +- Error handling: check return codes and provide meaningful feedback +- Organization: follow modular approach (each script handles specific tasks) + +### HTML/CSS +- Indentation: 4 spaces +- CSS: use variables for consistent styling +- Layout: ensure mobile-responsive design +- HTML: use semantic elements when appropriate + +## TODO List + +### Next Steps +- [ ] Test system with actual RSS feeds and torrents +- [ ] Implement automated testing for key components +- [ ] Add advanced content detection features +- [ ] Enhance UI with visual download statistics +- [ ] Add more notification options (email, messaging platforms) + +### Improvements +- [ ] Add user preference settings for automatic downloads +- [ ] Implement batch operations for torrent management +- [ ] Create detailed logging system with rotation +- [ ] Add support for multiple transmission instances +- [ ] Improve error recovery mechanisms +- [ ] Create a mobile-friendly responsive design +- [ ] Add dark mode support +- [ ] Implement content filtering based on regex patterns +- [ ] Add scheduling options for RSS checks +- [ ] Create dashboard with download metrics \ No newline at end of file diff --git a/README.md b/README.md index 27ba6b5..1265945 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Transmission RSS Manager -A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration and intelligent media organization. +A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration, intelligent media organization, and enhanced security features. ## Features @@ -10,6 +10,7 @@ A comprehensive web-based tool to automate and manage your Transmission torrent - 📖 **Book & Magazine Sorting**: Specialized processing for e-books and magazines with metadata extraction - 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories - 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping +- 🔒 **Enhanced Security**: Authentication, HTTPS support, and secure password storage - 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices ## Installation @@ -19,7 +20,14 @@ A comprehensive web-based tool to automate and manage your Transmission torrent - Ubuntu/Debian-based system (may work on other Linux distributions) - Node.js 14+ and npm - Transmission daemon installed and running -- Nginx (for reverse proxy) +- Nginx (for reverse proxy, optional) + +### System Requirements + +- Memory: 512MB minimum, 1GB recommended +- CPU: Any modern processor (1GHz+) +- Disk: At least 200MB for the application, plus storage space for your media +- Network: Internet connection for RSS feed fetching and torrent downloading ### Automatic Installation @@ -61,6 +69,16 @@ If you prefer to install manually: 4. Start the server: ```bash + # Using the convenience script (recommended) + ./scripts/test-and-start.sh + + # Or start with debug logging + ./scripts/test-and-start.sh --debug + + # Or run in foreground mode + ./scripts/test-and-start.sh --foreground + + # Or start directly node server.js ``` @@ -154,7 +172,7 @@ When enabled, the system can: ## Detailed Features -### Automatic Media Detection +### Automatic Media Detection and Processing The system uses sophisticated detection to categorize downloads: @@ -165,6 +183,27 @@ The system uses sophisticated detection to categorize downloads: - **Magazines**: Recognizes magazine naming patterns, issues, volumes, and publication dates - **Software**: Detects software installers, ISOs, and other program files +### Enhanced Post-Processing + +The post-processor automatically processes completed torrents that have met seeding requirements: + +- **Smart File Categorization**: Automatically detects media type based on content analysis +- **Intelligent Folder Organization**: Creates category-specific directories and file structures +- **Archive Extraction**: Automatically extracts compressed files (.zip, .rar, .7z, etc.) +- **File Renaming**: Cleans up filenames by removing dots, underscores, and other unwanted characters +- **Quality Management**: Optionally replace existing files with better quality versions +- **Seeding Requirements**: Configurable minimum ratio and seeding time before processing + +### Robust Transmission Integration + +The improved Transmission client integration provides: + +- **Enhanced Error Handling**: Automatic retry on connection failures +- **Media Information**: Deep analysis of torrent content for better categorization +- **Remote Support**: Comprehensive path mapping between remote and local systems +- **Torrent Management**: Complete control over torrents (add, start, stop, remove) +- **Performance Monitoring**: Track download/upload speeds and other performance metrics + ### RSS Feed Filtering Powerful filtering options for RSS feeds: @@ -186,8 +225,27 @@ Full support for remote Transmission instances: To update to the latest version: +### Using the Built-in Update Script + +If you've already installed Transmission RSS Manager, you can use the built-in update script: + ```bash -wget https://raw.githubusercontent.com/username/transmission-rss-manager/main/update.sh +cd /opt/transmission-rss-manager +sudo scripts/update.sh +``` + +Use the `--force` flag to force an update of dependencies even if no code changes are detected: + +```bash +sudo scripts/update.sh --force +``` + +### Manual Update + +Alternatively, you can download and run the update script: + +```bash +wget https://raw.githubusercontent.com/username/transmission-rss-manager/main/scripts/update.sh chmod +x update.sh sudo ./update.sh ``` @@ -196,18 +254,23 @@ sudo ./update.sh ``` transmission-rss-manager/ -├── server.js # Main application server -├── postProcessor.js # Media processing module -├── rssFeedManager.js # RSS feed management module -├── install.sh # Installation script -├── update.sh # Update script -├── config.json # Configuration file -├── public/ # Web interface files -│ ├── index.html # Main web interface -│ ├── js/ # JavaScript files -│ │ └── enhanced-ui.js # Enhanced UI functionality -│ └── css/ # CSS stylesheets -└── README.md # This file +├── server.js # Main application server +├── modules/ # Modular components +│ ├── post-processor.js # Media processing module +│ ├── rss-feed-manager.js # RSS feed management module +│ ├── transmission-client.js # Transmission API integration +│ └── config-module.sh # Installation configuration +├── install-script.sh # Initial installer that creates modules +├── main-installer.sh # Main installation script +├── config.json # Configuration file +├── public/ # Web interface files +│ ├── index.html # Main web interface +│ ├── js/ # JavaScript files +│ │ ├── app.js # Core application logic +│ │ └── utils.js # Utility functions +│ └── css/ # CSS stylesheets +│ └── styles.css # Main stylesheet +└── README.md # This file ``` ## Modules @@ -243,6 +306,33 @@ Set minimum seeding requirements before processing: } ``` +### Security Settings + +Enable authentication and HTTPS for secure access: + +```json +"securitySettings": { + "authEnabled": true, + "httpsEnabled": true, + "sslCertPath": "/path/to/ssl/cert.pem", + "sslKeyPath": "/path/to/ssl/key.pem", + "users": [ + { + "username": "admin", + "password": "your-hashed-password", + "role": "admin" + }, + { + "username": "user", + "password": "your-hashed-password", + "role": "user" + } + ] +} +``` + +*Note: Passwords are automatically hashed on first login if provided in plain text.* + ### Processing Options Customize how files are processed: diff --git a/install-script.sh b/install-script.sh index f7d8ee5..ea22a65 100755 --- a/install-script.sh +++ b/install-script.sh @@ -28,12 +28,22 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" # Create modules directory if it doesn't exist mkdir -p "${SCRIPT_DIR}/modules" +# Check for installation type +IS_UPDATE=false +if [ -f "${SCRIPT_DIR}/config.json" ]; then + IS_UPDATE=true + echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}" + echo -e "${GREEN}Your existing configuration will be preserved.${NC}" +else + echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}" +fi + # Check if modules exist, if not, extract them -if [ ! -f "${SCRIPT_DIR}/modules/config.sh" ]; then +if [ ! -f "${SCRIPT_DIR}/modules/config-module.sh" ]; then echo -e "${YELLOW}Creating module files...${NC}" # Create config module - cat > "${SCRIPT_DIR}/modules/config.sh" << 'EOL' + cat > "${SCRIPT_DIR}/modules/config-module.sh" << 'EOL' #!/bin/bash # Configuration module for Transmission RSS Manager Installation @@ -173,7 +183,7 @@ EOF EOL # Create utils module - cat > "${SCRIPT_DIR}/modules/utils.sh" << 'EOL' + cat > "${SCRIPT_DIR}/modules/utils-module.sh" << 'EOL' #!/bin/bash # Utilities module for Transmission RSS Manager Installation diff --git a/main-installer.sh b/main-installer.sh index 708caf8..0f22305 100755 --- a/main-installer.sh +++ b/main-installer.sh @@ -2,6 +2,9 @@ # Transmission RSS Manager Modular Installer # Main installer script that coordinates the installation process +# Set script to exit on error +set -e + # Text formatting BOLD='\033[1m' GREEN='\033[0;32m' @@ -25,38 +28,112 @@ fi # Get current directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +# Check for installation type +IS_UPDATE=false +if [ -f "${SCRIPT_DIR}/config.json" ]; then + IS_UPDATE=true + echo -e "${YELLOW}Existing installation detected. Running in update mode.${NC}" + echo -e "${GREEN}Your existing configuration will be preserved.${NC}" +else + echo -e "${GREEN}Fresh installation. Will create new configuration.${NC}" +fi +export IS_UPDATE + +# Check if required module files exist +REQUIRED_MODULES=( + "${SCRIPT_DIR}/modules/config-module.sh" + "${SCRIPT_DIR}/modules/utils-module.sh" + "${SCRIPT_DIR}/modules/dependencies-module.sh" + "${SCRIPT_DIR}/modules/file-creator-module.sh" + "${SCRIPT_DIR}/modules/service-setup-module.sh" +) + +for module in "${REQUIRED_MODULES[@]}"; do + if [ ! -f "$module" ]; then + echo -e "${RED}Error: Required module file not found: $module${NC}" + echo -e "${YELLOW}Please run the install-script.sh first to generate module files.${NC}" + exit 1 + fi +done + # 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" +source "${SCRIPT_DIR}/modules/utils-module.sh" # Load utilities first for logging +source "${SCRIPT_DIR}/modules/config-module.sh" +source "${SCRIPT_DIR}/modules/dependencies-module.sh" +source "${SCRIPT_DIR}/modules/file-creator-module.sh" +source "${SCRIPT_DIR}/modules/service-setup-module.sh" + +# Function to handle cleanup on error +function cleanup_on_error() { + log "ERROR" "Installation failed: $1" + log "INFO" "Cleaning up..." + + # Add any cleanup steps here if needed + + log "INFO" "You can try running the installer again after fixing the issues." + exit 1 +} + +# Set trap for error handling +trap 'cleanup_on_error "$BASH_COMMAND"' ERR # Execute the installation steps in sequence -echo -e "${YELLOW}Starting installation process...${NC}" +log "INFO" "Starting installation process..." # Step 1: Gather configuration from user -gather_configuration +log "INFO" "Gathering configuration..." +gather_configuration || { + log "ERROR" "Configuration gathering failed" + exit 1 +} # Step 2: Install dependencies -install_dependencies +log "INFO" "Installing dependencies..." +install_dependencies || { + log "ERROR" "Dependency installation failed" + exit 1 +} # Step 3: Create installation directories -create_directories +log "INFO" "Creating directories..." +create_directories || { + log "ERROR" "Directory creation failed" + exit 1 +} # Step 4: Create configuration files and scripts -create_config_files +log "INFO" "Creating configuration files..." +create_config_files || { + log "ERROR" "Configuration file creation failed" + exit 1 +} # Step 5: Create service files and install the service -setup_service +log "INFO" "Setting up service..." +setup_service || { + log "ERROR" "Service setup failed" + exit 1 +} # Step 6: Final setup and permissions -finalize_setup +log "INFO" "Finalizing setup..." +finalize_setup || { + log "ERROR" "Setup finalization failed" + exit 1 +} -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}" +# Installation complete echo -echo -e "${BOLD}Thank you for installing Transmission RSS Manager Enhanced Edition!${NC}" +echo -e "${BOLD}${GREEN}==================================================${NC}" +echo -e "${BOLD}${GREEN} Installation Complete! ${NC}" +echo -e "${BOLD}${GREEN}==================================================${NC}" +echo -e "You can access the web interface at: ${BOLD}http://localhost:$PORT${NC} or ${BOLD}http://your-server-ip:$PORT${NC}" +echo -e "You may need to configure your firewall to allow access to port $PORT" +echo +echo -e "${BOLD}Useful Commands:${NC}" +echo -e " To check the service status: ${YELLOW}systemctl status $SERVICE_NAME${NC}" +echo -e " To view logs: ${YELLOW}journalctl -u $SERVICE_NAME${NC}" +echo -e " To restart the service: ${YELLOW}systemctl restart $SERVICE_NAME${NC}" +echo +echo -e "Thank you for installing Transmission RSS Manager!" +echo -e "${BOLD}==================================================${NC}" diff --git a/modules/config-module.sh b/modules/config-module.sh index 0fc8701..0f07350 100644 --- a/modules/config-module.sh +++ b/modules/config-module.sh @@ -4,9 +4,38 @@ # Configuration variables with defaults INSTALL_DIR="/opt/transmission-rss-manager" SERVICE_NAME="transmission-rss-manager" -USER=$(logname || echo $SUDO_USER) PORT=3000 +# Get default user safely - avoid using root +get_default_user() { + local default_user + + # Try logname first to get the user who invoked sudo + if command -v logname &> /dev/null; then + default_user=$(logname 2>/dev/null) + fi + + # If logname failed, try SUDO_USER + if [ -z "$default_user" ] && [ -n "$SUDO_USER" ]; then + default_user="$SUDO_USER" + fi + + # Fallback to current user if both methods failed + if [ -z "$default_user" ]; then + default_user="$(whoami)" + fi + + # Ensure the user is not root + if [ "$default_user" = "root" ]; then + echo "nobody" + else + echo "$default_user" + fi +} + +# Initialize default user +USER=$(get_default_user) + # Transmission configuration variables TRANSMISSION_REMOTE=false TRANSMISSION_HOST="localhost" @@ -21,43 +50,124 @@ TRANSMISSION_DIR_MAPPING="{}" MEDIA_DIR="/mnt/media" ENABLE_BOOK_SORTING=true +# Helper function to validate port number +validate_port() { + local port="$1" + if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then + return 0 + else + return 1 + fi +} + +# Helper function to validate URL hostname +validate_hostname() { + local hostname="$1" + if [[ "$hostname" =~ ^[a-zA-Z0-9]([a-zA-Z0-9\-\.]+[a-zA-Z0-9])?$ ]]; then + return 0 + elif [[ "$hostname" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 0 + else + return 1 + fi +} + function gather_configuration() { + log "INFO" "Starting configuration gathering" 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} + if [ -n "$input_install_dir" ]; then + # Validate installation directory + if [[ ! "$input_install_dir" =~ ^/ ]]; then + log "WARN" "Installation directory must be an absolute path. Using default." + else + INSTALL_DIR="$input_install_dir" + fi + fi - read -p "Web interface port [$PORT]: " input_port - PORT=${input_port:-$PORT} + # Get and validate port + while true; do + read -p "Web interface port [$PORT]: " input_port + if [ -z "$input_port" ]; then + break + elif validate_port "$input_port"; then + PORT="$input_port" + break + else + log "WARN" "Invalid port number. Port must be between 1 and 65535." + fi + done + # Get user read -p "Run as user [$USER]: " input_user - USER=${input_user:-$USER} + if [ -n "$input_user" ]; then + # Check if user exists + if id "$input_user" &>/dev/null; then + USER="$input_user" + else + log "WARN" "User $input_user does not exist. Using $USER instead." + fi + fi echo echo -e "${BOLD}Transmission Configuration:${NC}" echo -e "Configure connection to your Transmission client:" echo + # Ask if Transmission is remote 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} + # Get and validate hostname + while true; do + read -p "Remote Transmission host [localhost]: " input_trans_host + if [ -z "$input_trans_host" ]; then + break + elif validate_hostname "$input_trans_host"; then + TRANSMISSION_HOST="$input_trans_host" + break + else + log "WARN" "Invalid hostname format." + fi + done - read -p "Remote Transmission port [9091]: " input_trans_port - TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT} + # Get and validate port + while true; do + read -p "Remote Transmission port [9091]: " input_trans_port + if [ -z "$input_trans_port" ]; then + break + elif validate_port "$input_trans_port"; then + TRANSMISSION_PORT="$input_trans_port" + break + else + log "WARN" "Invalid port number. Port must be between 1 and 65535." + fi + done + # Get credentials 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} + # Use read -s for password to avoid showing it on screen + read -s -p "Remote Transmission password []: " input_trans_pass + echo # Add a newline after the password input + if [ -n "$input_trans_pass" ]; then + # TODO: In a production environment, consider encrypting this password + TRANSMISSION_PASS="$input_trans_pass" + fi read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path - TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH} + if [ -n "$input_trans_path" ]; then + # Ensure path starts with / for consistency + if [[ ! "$input_trans_path" =~ ^/ ]]; then + input_trans_path="/$input_trans_path" + fi + TRANSMISSION_RPC_PATH="$input_trans_path" + fi # Configure directory mapping for remote setup echo @@ -74,17 +184,20 @@ function gather_configuration() { 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 + # Check for package manager + if command -v apt-get &> /dev/null; then + # Update package index 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 + # Install Node.js and npm if not already installed + if ! command_exists node; then + log "INFO" "Installing Node.js and npm..." + apt-get install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + + # Check if download succeeds + if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then + log "ERROR" "Failed to download Node.js GPG key" + exit 1 + fi + + 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 + + # Update again after adding repo + apt-get update + + # Install nodejs + if ! apt-get install -y nodejs; then + log "ERROR" "Failed to install Node.js" + exit 1 + fi + else + log "INFO" "Node.js is already installed." + fi + + # Install additional dependencies + log "INFO" "Installing additional dependencies..." + apt-get install -y unrar unzip p7zip-full nginx + else + log "ERROR" "This installer requires apt-get package manager" + log "INFO" "Please install the following dependencies manually:" + log "INFO" "- Node.js (v18.x)" + log "INFO" "- npm" + log "INFO" "- unrar" + log "INFO" "- unzip" + log "INFO" "- p7zip-full" + log "INFO" "- nginx" + exit 1 + fi # Check if all dependencies were installed successfully local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") @@ -35,26 +61,49 @@ function install_dependencies() { done if [ ${#missing_deps[@]} -eq 0 ]; then - echo -e "${GREEN}All dependencies installed successfully.${NC}" + log "INFO" "All dependencies installed successfully." else - echo -e "${RED}Failed to install some dependencies: ${missing_deps[*]}${NC}" - echo -e "${YELLOW}Please install them manually and rerun this script.${NC}" + log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}" + log "WARN" "Please install them manually and rerun this script." + + # More helpful information based on which deps are missing + if [[ " ${missing_deps[*]} " =~ " node " ]]; then + log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/" + fi + + if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then + log "INFO" "To install nginx manually: sudo apt-get install nginx" + fi + exit 1 fi } function create_directories() { - echo -e "${YELLOW}Creating installation directories...${NC}" + log "INFO" "Creating installation directories..." - # 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 + # Check if INSTALL_DIR is defined + if [ -z "$INSTALL_DIR" ]; then + log "ERROR" "INSTALL_DIR is not defined" + exit 1 + fi - # Create directory for file storage - mkdir -p $INSTALL_DIR/data + # Create directories and check for errors + DIRECTORIES=( + "$INSTALL_DIR" + "$INSTALL_DIR/logs" + "$INSTALL_DIR/public/js" + "$INSTALL_DIR/public/css" + "$INSTALL_DIR/modules" + "$INSTALL_DIR/data" + ) - echo -e "${GREEN}Directories created successfully.${NC}" + for dir in "${DIRECTORIES[@]}"; do + if ! mkdir -p "$dir"; then + log "ERROR" "Failed to create directory: $dir" + exit 1 + fi + done + + log "INFO" "Directories created successfully." } diff --git a/modules/file-creator-module.sh b/modules/file-creator-module.sh index 3d262b7..d868773 100644 --- a/modules/file-creator-module.sh +++ b/modules/file-creator-module.sh @@ -18,11 +18,14 @@ function create_config_files() { "dependencies": { "express": "^4.18.2", "body-parser": "^1.20.2", - "transmission": "^0.4.10", + "transmission-promise": "^1.1.5", "adm-zip": "^0.5.10", "node-fetch": "^2.6.9", "xml2js": "^0.5.0", - "cors": "^2.8.5" + "cors": "^2.8.5", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.0", + "morgan": "^1.10.0" } } EOF diff --git a/modules/post-processor.js b/modules/post-processor.js new file mode 100644 index 0000000..7ddb054 --- /dev/null +++ b/modules/post-processor.js @@ -0,0 +1,517 @@ +/** + * Post-Processor Module + * Handles the organization and processing of completed downloads + */ + +const fs = require('fs').promises; +const path = require('path'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); +const crypto = require('crypto'); + +class PostProcessor { + constructor(config, transmissionClient) { + if (!config) { + throw new Error('Configuration is required for Post Processor'); + } + + if (!transmissionClient) { + throw new Error('Transmission client is required for Post Processor'); + } + + this.config = config; + this.transmissionClient = transmissionClient; + this.isProcessing = false; + this.processingQueue = []; + this.processIntervalId = null; + this.checkIntervalSeconds = config.seedingRequirements?.checkIntervalSeconds || 300; + this.destinationPaths = config.destinationPaths || {}; + this.processingOptions = config.processingOptions || {}; + } + + /** + * Start the post-processor + * @returns {boolean} Whether the processor started successfully + */ + start() { + if (this.processIntervalId) { + console.log('Post-processor is already running'); + return false; + } + + console.log(`Starting post-processor, check interval: ${this.checkIntervalSeconds} seconds`); + + // Run immediately + this.checkCompletedDownloads(); + + // Then set up interval + this.processIntervalId = setInterval(() => { + this.checkCompletedDownloads(); + }, this.checkIntervalSeconds * 1000); + + return true; + } + + /** + * Stop the post-processor + * @returns {boolean} Whether the processor stopped successfully + */ + stop() { + if (!this.processIntervalId) { + console.log('Post-processor is not running'); + return false; + } + + clearInterval(this.processIntervalId); + this.processIntervalId = null; + console.log('Post-processor stopped'); + + return true; + } + + /** + * Check for completed downloads that meet seeding requirements + */ + async checkCompletedDownloads() { + if (this.isProcessing) { + console.log('Post-processor is already running a processing cycle, skipping'); + return; + } + + this.isProcessing = true; + + try { + console.log('Checking for completed downloads...'); + + // Get all torrents + const torrentsResult = await this.transmissionClient.getTorrents(); + + if (!torrentsResult.success) { + console.error('Failed to get torrents from Transmission:', torrentsResult.error); + this.isProcessing = false; + return; + } + + const torrents = torrentsResult.torrents; + + // Filter completed torrents + const completedTorrents = torrents.filter(torrent => + torrent.percentDone === 1 && // Fully downloaded + torrent.status !== 0 && // Not stopped + torrent.doneDate > 0 // Has a completion date + ); + + console.log(`Found ${completedTorrents.length} completed torrents`); + + // Check each completed torrent against requirements + for (const torrent of completedTorrents) { + // Skip already processed torrents + if (this.processingQueue.includes(torrent.id)) { + continue; + } + + // Check if it meets seeding requirements + const reqResult = await this.transmissionClient.verifyTorrentSeedingRequirements( + torrent.id, + this.config.seedingRequirements || {} + ); + + if (!reqResult.success) { + console.error(`Error checking requirements for ${torrent.name}:`, reqResult.error); + continue; + } + + if (reqResult.requirementsMet) { + console.log(`Torrent ${torrent.name} has met seeding requirements, queuing for processing`); + + // Add to processing queue + this.processingQueue.push(torrent.id); + + // Process the torrent + await this.processTorrent(reqResult.torrent); + + // Remove from queue after processing + this.processingQueue = this.processingQueue.filter(id => id !== torrent.id); + } else { + const { currentRatio, currentSeedingTimeMinutes } = reqResult; + const { minRatio, minTimeMinutes } = this.config.seedingRequirements || { minRatio: 1.0, minTimeMinutes: 60 }; + + console.log(`Torrent ${torrent.name} has not met seeding requirements yet:`); + console.log(`- Ratio: ${currentRatio.toFixed(2)} / ${minRatio} (${reqResult.ratioMet ? 'Met' : 'Not Met'})`); + console.log(`- Time: ${Math.floor(currentSeedingTimeMinutes)} / ${minTimeMinutes} minutes (${reqResult.timeMet ? 'Met' : 'Not Met'})`); + } + } + } catch (error) { + console.error('Error in post-processor cycle:', error); + } finally { + this.isProcessing = false; + } + } + + /** + * Process a completed torrent + * @param {Object} torrent - Torrent object + */ + async processTorrent(torrent) { + console.log(`Processing torrent: ${torrent.name}`); + + try { + // Get detailed info with file analysis + const details = await this.transmissionClient.getTorrentDetails(torrent.id); + + if (!details.success) { + console.error(`Failed to get details for torrent ${torrent.name}:`, details.error); + return; + } + + torrent = details.torrent; + const mediaInfo = torrent.mediaInfo || { type: 'unknown' }; + + console.log(`Detected media type: ${mediaInfo.type}`); + + // Determine destination path based on content type + let destinationDir = this.getDestinationPath(mediaInfo.type); + + if (!destinationDir) { + console.error(`No destination directory configured for media type: ${mediaInfo.type}`); + return; + } + + // Create the destination directory if it doesn't exist + await this.createDirectoryIfNotExists(destinationDir); + + // If we're creating category folders, add category-specific subdirectory + if (this.processingOptions.createCategoryFolders) { + const categoryFolder = this.getCategoryFolder(torrent, mediaInfo); + if (categoryFolder) { + destinationDir = path.join(destinationDir, categoryFolder); + await this.createDirectoryIfNotExists(destinationDir); + } + } + + console.log(`Processing to destination: ${destinationDir}`); + + // Process files based on content type + if (mediaInfo.type === 'archive' && this.processingOptions.extractArchives) { + await this.processArchives(torrent, mediaInfo, destinationDir); + } else { + await this.processStandardFiles(torrent, mediaInfo, destinationDir); + } + + console.log(`Finished processing torrent: ${torrent.name}`); + } catch (error) { + console.error(`Error processing torrent ${torrent.name}:`, error); + } + } + + /** + * Get the appropriate destination path for a media type + * @param {string} mediaType - Type of media + * @returns {string} Destination path + */ + getDestinationPath(mediaType) { + switch (mediaType) { + case 'movie': + return this.destinationPaths.movies; + case 'tvshow': + return this.destinationPaths.tvShows; + case 'audio': + return this.destinationPaths.music; + case 'book': + return this.destinationPaths.books; + case 'magazine': + return this.destinationPaths.magazines; + default: + return this.destinationPaths.software; + } + } + + /** + * Generate a category folder name based on the content + * @param {Object} torrent - Torrent object + * @param {Object} mediaInfo - Media information + * @returns {string} Folder name + */ + getCategoryFolder(torrent, mediaInfo) { + const name = torrent.name; + + switch (mediaInfo.type) { + case 'movie': { + // For movies, use the first letter of the title + const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase(); + return firstLetter || '#'; + } + case 'tvshow': { + // For TV shows, extract the show name + const showName = name.replace(/[sS]\d{2}[eE]\d{2}.*$/, '').trim(); + return showName; + } + case 'audio': { + // For music, try to extract artist name + const artistMatch = name.match(/^(.*?)\s*-\s*/); + return artistMatch ? artistMatch[1].trim() : 'Unsorted'; + } + case 'book': { + // For books, use the first letter of title or author names + const firstLetter = name.replace(/^[^a-zA-Z0-9]+/, '').charAt(0).toUpperCase(); + return firstLetter || '#'; + } + case 'magazine': { + // For magazines, use the magazine name if possible + const magazineMatch = name.match(/^(.*?)\s*(?:Issue|Vol|Volume)/i); + return magazineMatch ? magazineMatch[1].trim() : 'Unsorted'; + } + default: + return null; + } + } + + /** + * Process archive files (extract them) + * @param {Object} torrent - Torrent object + * @param {Object} mediaInfo - Media information + * @param {string} destinationDir - Destination directory + */ + async processArchives(torrent, mediaInfo, destinationDir) { + console.log(`Processing archives in ${torrent.name}`); + + const archiveFiles = mediaInfo.archiveFiles; + const torrentDir = torrent.downloadDir; + + for (const file of archiveFiles) { + const filePath = path.join(torrentDir, file.name); + + try { + // Create a unique extraction directory + const extractionDirName = path.basename(file.name, path.extname(file.name)); + const extractionDir = path.join(destinationDir, extractionDirName); + + await this.createDirectoryIfNotExists(extractionDir); + + console.log(`Extracting ${filePath} to ${extractionDir}`); + + // Extract the archive based on type + if (/\.zip$/i.test(file.name)) { + await exec(`unzip -o "${filePath}" -d "${extractionDir}"`); + } else if (/\.rar$/i.test(file.name)) { + await exec(`unrar x -o+ "${filePath}" "${extractionDir}"`); + } else if (/\.7z$/i.test(file.name)) { + await exec(`7z x "${filePath}" -o"${extractionDir}"`); + } else if (/\.tar(\.(gz|bz2|xz))?$/i.test(file.name)) { + await exec(`tar -xf "${filePath}" -C "${extractionDir}"`); + } else { + console.log(`Unknown archive format for ${file.name}, skipping extraction`); + continue; + } + + console.log(`Successfully extracted ${file.name}`); + + // Delete archive if option is enabled + if (this.processingOptions.deleteArchives) { + try { + console.log(`Deleting archive after extraction: ${filePath}`); + await fs.unlink(filePath); + } catch (deleteError) { + console.error(`Failed to delete archive ${filePath}:`, deleteError); + } + } + } catch (error) { + console.error(`Error extracting archive ${filePath}:`, error); + } + } + } + + /** + * Process standard (non-archive) files + * @param {Object} torrent - Torrent object + * @param {Object} mediaInfo - Media information + * @param {string} destinationDir - Destination directory + */ + async processStandardFiles(torrent, mediaInfo, destinationDir) { + console.log(`Processing standard files in ${torrent.name}`); + + const torrentDir = torrent.downloadDir; + const allFiles = []; + + // Collect all files based on media type + switch (mediaInfo.type) { + case 'movie': + case 'tvshow': + allFiles.push(...mediaInfo.videoFiles); + break; + case 'audio': + allFiles.push(...mediaInfo.audioFiles); + break; + case 'book': + case 'magazine': + allFiles.push(...mediaInfo.documentFiles); + break; + default: + // For unknown/software, add all files except samples if enabled + for (const type of Object.keys(mediaInfo)) { + if (Array.isArray(mediaInfo[type])) { + allFiles.push(...mediaInfo[type]); + } + } + } + + // Filter out sample files if option is enabled + let filesToProcess = allFiles; + if (this.processingOptions.ignoreSample) { + filesToProcess = allFiles.filter(file => !file.isSample); + console.log(`Filtered out ${allFiles.length - filesToProcess.length} sample files`); + } + + // Process each file + for (const file of filesToProcess) { + const sourceFilePath = path.join(torrentDir, file.name); + let destFileName = file.name; + + // Generate a better filename if rename option is enabled + if (this.processingOptions.renameFiles) { + destFileName = this.generateBetterFilename(file.name, mediaInfo.type); + } + + const destFilePath = path.join(destinationDir, destFileName); + + try { + // Check if destination file already exists with the same name + const fileExists = await this.fileExists(destFilePath); + + if (fileExists) { + if (this.processingOptions.autoReplaceUpgrades) { + // Compare file sizes to see if the new one is larger (potentially higher quality) + const existingStats = await fs.stat(destFilePath); + + if (file.size > existingStats.size) { + console.log(`Replacing existing file with larger version: ${destFilePath}`); + await fs.copyFile(sourceFilePath, destFilePath); + } else { + console.log(`Skipping ${file.name}, existing file is same or better quality`); + } + } else { + // Generate a unique filename + const uniqueDestFilePath = this.makeFilenameUnique(destFilePath); + console.log(`Copying ${file.name} to ${uniqueDestFilePath}`); + await fs.copyFile(sourceFilePath, uniqueDestFilePath); + } + } else { + // File doesn't exist, simple copy + console.log(`Copying ${file.name} to ${destFilePath}`); + await fs.copyFile(sourceFilePath, destFilePath); + } + } catch (error) { + console.error(`Error processing file ${file.name}:`, error); + } + } + } + + /** + * Generate a better filename based on content type + * @param {string} originalFilename - Original filename + * @param {string} mediaType - Media type + * @returns {string} Improved filename + */ + generateBetterFilename(originalFilename, mediaType) { + // Get the file extension + const ext = path.extname(originalFilename); + const basename = path.basename(originalFilename, ext); + + // Clean up common issues in filenames + let cleanName = basename + .replace(/\[.*?\]|\(.*?\)/g, '') // Remove content in brackets/parentheses + .replace(/\._/g, '.') // Remove underscore after dots + .replace(/\./g, ' ') // Replace dots with spaces + .replace(/_/g, ' ') // Replace underscores with spaces + .replace(/\s{2,}/g, ' ') // Replace multiple spaces with a single one + .trim(); + + // Media type specific formatting + switch (mediaType) { + case 'movie': + // Keep (year) format for movies if present + const yearMatch = basename.match(/\(*(19|20)\d{2}\)*$/); + if (yearMatch) { + const year = yearMatch[0].replace(/[()]/g, ''); + // Remove any year that might have been part of the clean name already + cleanName = cleanName.replace(/(19|20)\d{2}/g, '').trim(); + // Add the year in a consistent format + cleanName = `${cleanName} (${year})`; + } + break; + + case 'tvshow': + // Keep season and episode info for TV shows + const episodeMatch = basename.match(/[sS](\d{1,2})[eE](\d{1,2})/); + if (episodeMatch) { + const seasonNum = parseInt(episodeMatch[1], 10); + const episodeNum = parseInt(episodeMatch[2], 10); + + // First, remove any existing season/episode info from clean name + cleanName = cleanName.replace(/[sS]\d{1,2}[eE]\d{1,2}/g, '').trim(); + + // Add back the season/episode in a consistent format + cleanName = `${cleanName} S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; + } + break; + + case 'audio': + // Try to organize as "Artist - Title" for music + const musicMatch = basename.match(/^(.*?)\s*-\s*(.*?)$/); + if (musicMatch && musicMatch[1] && musicMatch[2]) { + const artist = musicMatch[1].trim(); + const title = musicMatch[2].trim(); + cleanName = `${artist} - ${title}`; + } + break; + } + + return cleanName + ext; + } + + /** + * Make a filename unique by adding a suffix + * @param {string} filepath - Original filepath + * @returns {string} Unique filepath + */ + makeFilenameUnique(filepath) { + const ext = path.extname(filepath); + const basename = path.basename(filepath, ext); + const dirname = path.dirname(filepath); + + // Add a timestamp to make it unique + const timestamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').substring(0, 15); + return path.join(dirname, `${basename}_${timestamp}${ext}`); + } + + /** + * Create a directory if it doesn't exist + * @param {string} dirPath - Directory path + */ + async createDirectoryIfNotExists(dirPath) { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error) { + // Ignore error if directory already exists + if (error.code !== 'EEXIST') { + throw error; + } + } + } + + /** + * Check if a file exists + * @param {string} filePath - File path + * @returns {Promise} Whether the file exists + */ + async fileExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } +} + +module.exports = PostProcessor; \ No newline at end of file diff --git a/modules/rss-feed-manager.js b/modules/rss-feed-manager.js index f15db0d..2cd729c 100644 --- a/modules/rss-feed-manager.js +++ b/modules/rss-feed-manager.js @@ -1,4 +1,4 @@ -// rssFeedManager.js +// rss-feed-manager.js - Handles RSS feed fetching, parsing, and torrent management const fs = require('fs').promises; const path = require('path'); const fetch = require('node-fetch'); @@ -7,13 +7,22 @@ const crypto = require('crypto'); class RssFeedManager { constructor(config) { + if (!config) { + throw new Error('Configuration is required'); + } + this.config = config; this.feeds = config.feeds || []; this.items = []; this.updateIntervalId = null; this.updateIntervalMinutes = config.updateIntervalMinutes || 60; this.parser = new xml2js.Parser({ explicitArray: false }); + + // Ensure dataPath is properly defined this.dataPath = path.join(__dirname, '..', 'data'); + + // Maximum items to keep in memory to prevent memory leaks + this.maxItemsInMemory = config.maxItemsInMemory || 5000; } async start() { @@ -21,15 +30,28 @@ class RssFeedManager { 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`); + try { + // Load existing feeds and items + await this.loadFeeds(); + await this.loadItems(); + + // Run update immediately + await this.updateAllFeeds().catch(error => { + console.error('Error in initial feed update:', error); + }); + + // Then set up interval + this.updateIntervalId = setInterval(async () => { + await this.updateAllFeeds().catch(error => { + console.error('Error in scheduled feed update:', error); + }); + }, this.updateIntervalMinutes * 60 * 1000); + + console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`); + } catch (error) { + console.error('Failed to start RSS feed manager:', error); + throw error; + } } stop() { @@ -47,7 +69,19 @@ class RssFeedManager { const results = []; + // Check if feeds array is valid + if (!Array.isArray(this.feeds)) { + console.error('Feeds is not an array:', this.feeds); + this.feeds = []; + return results; + } + for (const feed of this.feeds) { + if (!feed || !feed.id || !feed.url) { + console.error('Invalid feed object:', feed); + continue; + } + try { const result = await this.updateFeed(feed); results.push({ @@ -65,30 +99,65 @@ class RssFeedManager { } } - // Save updated items - await this.saveItems(); + try { + // Save updated items and truncate if necessary + this.trimItemsIfNeeded(); + await this.saveItems(); + await this.saveFeeds(); + } catch (error) { + console.error('Error saving data after feed update:', error); + } console.log('RSS feed update completed'); return results; } + // Trim items to prevent memory bloat + trimItemsIfNeeded() { + if (this.items.length > this.maxItemsInMemory) { + console.log(`Trimming items from ${this.items.length} to ${this.maxItemsInMemory}`); + + // Sort by date (newest first) and keep only the newest maxItemsInMemory items + this.items.sort((a, b) => new Date(b.added) - new Date(a.added)); + this.items = this.items.slice(0, this.maxItemsInMemory); + } + } + async updateFeed(feed) { - console.log(`Updating feed: ${feed.name} (${feed.url})`); + if (!feed || !feed.url) { + throw new Error('Invalid feed configuration'); + } + + console.log(`Updating feed: ${feed.name || 'Unnamed'} (${feed.url})`); try { - const response = await fetch(feed.url); + const response = await fetch(feed.url, { + timeout: 30000, // 30 second timeout + headers: { + 'User-Agent': 'Transmission-RSS-Manager/1.2.0' + } + }); if (!response.ok) { throw new Error(`HTTP error ${response.status}: ${response.statusText}`); } const xml = await response.text(); + + if (!xml || xml.trim() === '') { + throw new Error('Empty feed content'); + } + const result = await this.parseXml(xml); + if (!result) { + throw new Error('Failed to parse XML feed'); + } + 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}`); + console.log(`Found ${rssItems.length} items, ${newItems.length} new items in feed: ${feed.name || 'Unnamed'}`); return { totalItems: rssItems.length, @@ -101,6 +170,10 @@ class RssFeedManager { } parseXml(xml) { + if (!xml || typeof xml !== 'string') { + return Promise.reject(new Error('Invalid XML input')); + } + return new Promise((resolve, reject) => { this.parser.parseString(xml, (error, result) => { if (error) { @@ -113,17 +186,33 @@ class RssFeedManager { } extractItems(parsedXml, feed) { + if (!parsedXml || !feed) { + console.error('Invalid parsed XML or feed'); + return []; + } + 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); + + if (!channel.item) { + return []; + } + + const items = Array.isArray(channel.item) + ? channel.item.filter(Boolean) + : (channel.item ? [channel.item] : []); + 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); + const entries = Array.isArray(parsedXml.feed.entry) + ? parsedXml.feed.entry.filter(Boolean) + : (parsedXml.feed.entry ? [parsedXml.feed.entry] : []); + return entries.map(entry => this.normalizeAtomItem(entry, feed)); } @@ -135,88 +224,155 @@ class RssFeedManager { } 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); + if (!item || !feed) { + console.error('Invalid RSS item or feed'); + return null; } - // 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; + try { + // Create a unique ID for the item + const title = item.title || 'Untitled'; + const pubDate = item.pubDate || ''; + const link = item.link || ''; + const idContent = `${feed.id}:${title}:${pubDate}:${link}`; + const id = crypto.createHash('md5').update(idContent).digest('hex'); + + // Extract enclosure (torrent link) + let torrentLink = link; + let fileSize = 0; + + if (item.enclosure) { + if (item.enclosure.$) { + torrentLink = item.enclosure.$.url || torrentLink; + fileSize = parseInt(item.enclosure.$.length || 0, 10); + } else if (typeof item.enclosure === 'object') { + torrentLink = item.enclosure.url || torrentLink; + fileSize = 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; + // Handle if category is an object with a value property + if (typeof category === 'object' && category._) { + category = category._; + } + } + + // Some feeds use torrent:contentLength + if (item['torrent:contentLength']) { + const contentLength = parseInt(item['torrent:contentLength'], 10); + if (!isNaN(contentLength)) { + size = contentLength; + } + } + + return { + id, + feedId: feed.id, + title, + link, + torrentLink, + pubDate: pubDate || new Date().toISOString(), + category: category || '', + description: item.description || '', + size: !isNaN(size) ? size : 0, + downloaded: false, + ignored: false, + added: new Date().toISOString() + }; + } catch (error) { + console.error('Error normalizing RSS item:', error); + return null; } - - // 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; - } + if (!entry || !feed) { + console.error('Invalid Atom entry or feed'); + return null; } - 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() - }; + try { + // Create a unique ID for the item + const title = entry.title || 'Untitled'; + const updated = entry.updated || ''; + const entryId = entry.id || ''; + const idContent = `${feed.id}:${title}:${updated}:${entryId}`; + 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.filter(l => l && l.$); + const alternateLink = links.find(l => l.$ && l.$.rel === 'alternate'); + const torrentTypeLink = links.find(l => l.$ && l.$.type && l.$.type.includes('torrent')); + + link = alternateLink && alternateLink.$ && alternateLink.$.href ? + alternateLink.$.href : + (links[0] && links[0].$ && links[0].$.href ? links[0].$.href : ''); + + torrentLink = torrentTypeLink && torrentTypeLink.$ && torrentTypeLink.$.href ? + torrentTypeLink.$.href : link; + } else if (entry.link.$ && entry.link.$.href) { + link = entry.link.$.href; + torrentLink = link; + } + } + + // Extract category + let category = ''; + if (entry.category && entry.category.$ && entry.category.$.term) { + category = entry.category.$.term; + } + + // Extract content + let description = ''; + if (entry.summary) { + description = entry.summary; + } else if (entry.content) { + description = entry.content; + } + + return { + id, + feedId: feed.id, + title, + link, + torrentLink, + pubDate: entry.updated || entry.published || new Date().toISOString(), + category, + description, + size: 0, // Atom doesn't typically include file size + downloaded: false, + ignored: false, + added: new Date().toISOString() + }; + } catch (error) { + console.error('Error normalizing Atom item:', error); + return null; + } } processNewItems(rssItems, feed) { + if (!Array.isArray(rssItems) || !feed) { + console.error('Invalid RSS items array or feed'); + return []; + } + const newItems = []; - for (const item of rssItems) { + // Filter out null items + const validItems = rssItems.filter(item => item !== null); + + for (const item of validItems) { // Check if item already exists in our list const existingItem = this.items.find(i => i.id === item.id); @@ -236,28 +392,34 @@ class RssFeedManager { } matchesFilters(item, filters) { - if (!filters || filters.length === 0) { + if (!item) return false; + + if (!filters || !Array.isArray(filters) || filters.length === 0) { return true; } // Check if the item matches any of the filters return filters.some(filter => { + if (!filter) return true; + // Title check - if (filter.title && !item.title.toLowerCase().includes(filter.title.toLowerCase())) { + if (filter.title && typeof item.title === 'string' && + !item.title.toLowerCase().includes(filter.title.toLowerCase())) { return false; } // Category check - if (filter.category && !item.category.toLowerCase().includes(filter.category.toLowerCase())) { + if (filter.category && typeof item.category === 'string' && + !item.category.toLowerCase().includes(filter.category.toLowerCase())) { return false; } - // Size check - if (filter.minSize && item.size < filter.minSize) { + // Size checks + if (filter.minSize && typeof item.size === 'number' && item.size < filter.minSize) { return false; } - if (filter.maxSize && item.size > filter.maxSize) { + if (filter.maxSize && typeof item.size === 'number' && item.size > filter.maxSize) { return false; } @@ -267,6 +429,8 @@ class RssFeedManager { } queueItemForDownload(item) { + if (!item) return; + // Mark the item as queued for download console.log(`Auto-downloading item: ${item.title}`); @@ -278,8 +442,8 @@ class RssFeedManager { async saveItems() { try { - // Create data directory if it doesn't exist - await fs.mkdir(this.dataPath, { recursive: true }); + // Ensure data directory exists + await this.ensureDataDirectory(); // Save items to file await fs.writeFile( @@ -296,10 +460,10 @@ class RssFeedManager { } } - async saveConfig() { + async saveFeeds() { try { - // Create data directory if it doesn't exist - await fs.mkdir(this.dataPath, { recursive: true }); + // Ensure data directory exists + await this.ensureDataDirectory(); // Save feeds to file await fs.writeFile( @@ -316,6 +480,15 @@ class RssFeedManager { } } + async ensureDataDirectory() { + try { + await fs.mkdir(this.dataPath, { recursive: true }); + } catch (error) { + console.error('Error creating data directory:', error); + throw error; + } + } + async loadItems() { try { const filePath = path.join(this.dataPath, 'rss-items.json'); @@ -325,17 +498,80 @@ class RssFeedManager { await fs.access(filePath); } catch (error) { console.log('No saved RSS items found'); + this.items = []; 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; + if (!data || data.trim() === '') { + console.log('Empty RSS items file'); + this.items = []; + return false; + } + + try { + const items = JSON.parse(data); + + if (Array.isArray(items)) { + this.items = items; + console.log(`Loaded ${this.items.length} RSS items from disk`); + return true; + } else { + console.error('RSS items file does not contain an array'); + this.items = []; + return false; + } + } catch (parseError) { + console.error('Error parsing RSS items JSON:', parseError); + this.items = []; + return false; + } } catch (error) { console.error('Error loading RSS items:', error); + this.items = []; + return false; + } + } + + async loadFeeds() { + try { + const filePath = path.join(this.dataPath, 'rss-feeds.json'); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + console.log('No saved RSS feeds found, using config feeds'); + return false; + } + + // Load feeds from file + const data = await fs.readFile(filePath, 'utf8'); + + if (!data || data.trim() === '') { + console.log('Empty RSS feeds file, using config feeds'); + return false; + } + + try { + const feeds = JSON.parse(data); + + if (Array.isArray(feeds)) { + this.feeds = feeds; + console.log(`Loaded ${this.feeds.length} RSS feeds from disk`); + return true; + } else { + console.error('RSS feeds file does not contain an array'); + return false; + } + } catch (parseError) { + console.error('Error parsing RSS feeds JSON:', parseError); + return false; + } + } catch (error) { + console.error('Error loading RSS feeds:', error); return false; } } @@ -343,33 +579,56 @@ class RssFeedManager { // Public API methods getAllFeeds() { - return this.feeds; + return Array.isArray(this.feeds) ? this.feeds : []; } addFeed(feedData) { + if (!feedData || !feedData.url) { + throw new Error('Feed URL is required'); + } + // Generate an ID for the feed const id = crypto.randomBytes(8).toString('hex'); const newFeed = { id, - name: feedData.name, + name: feedData.name || 'Unnamed Feed', url: feedData.url, - autoDownload: feedData.autoDownload || false, - filters: feedData.filters || [], + autoDownload: !!feedData.autoDownload, + filters: Array.isArray(feedData.filters) ? feedData.filters : [], added: new Date().toISOString() }; + if (!Array.isArray(this.feeds)) { + this.feeds = []; + } + this.feeds.push(newFeed); + // Save the updated feeds + this.saveFeeds().catch(err => { + console.error('Error saving feeds after adding new feed:', err); + }); + console.log(`Added new feed: ${newFeed.name} (${newFeed.url})`); return newFeed; } updateFeedConfig(feedId, updates) { - const feedIndex = this.feeds.findIndex(f => f.id === feedId); + if (!feedId || !updates) { + return false; + } + + if (!Array.isArray(this.feeds)) { + console.error('Feeds is not an array'); + return false; + } + + const feedIndex = this.feeds.findIndex(f => f && f.id === feedId); if (feedIndex === -1) { + console.error(`Feed with ID ${feedId} not found`); return false; } @@ -381,28 +640,52 @@ class RssFeedManager { added: this.feeds[feedIndex].added }; + // Save the updated feeds + this.saveFeeds().catch(err => { + console.error('Error saving feeds after updating feed:', err); + }); + 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); + if (!feedId || !Array.isArray(this.feeds)) { + return false; + } - return this.feeds.length !== initialLength; + const initialLength = this.feeds.length; + this.feeds = this.feeds.filter(f => f && f.id !== feedId); + + if (this.feeds.length !== initialLength) { + // Save the updated feeds + this.saveFeeds().catch(err => { + console.error('Error saving feeds after removing feed:', err); + }); + + return true; + } + + return false; } getAllItems() { - return this.items; + return Array.isArray(this.items) ? this.items : []; } getUndownloadedItems() { - return this.items.filter(item => !item.downloaded && !item.ignored); + if (!Array.isArray(this.items)) { + return []; + } + return this.items.filter(item => item && !item.downloaded && !item.ignored); } filterItems(filters) { - return this.items.filter(item => this.matchesFilters(item, [filters])); + if (!filters || !Array.isArray(this.items)) { + return []; + } + return this.items.filter(item => item && this.matchesFilters(item, [filters])); } async downloadItem(item, transmissionClient) { @@ -421,7 +704,7 @@ class RssFeedManager { } return new Promise((resolve) => { - transmissionClient.addUrl(item.torrentLink, (err, result) => { + transmissionClient.addUrl(item.torrentLink, async (err, result) => { if (err) { console.error(`Error adding torrent for ${item.title}:`, err); resolve({ @@ -437,9 +720,11 @@ class RssFeedManager { item.downloadDate = new Date().toISOString(); // Save the updated items - this.saveItems().catch(err => { + try { + await this.saveItems(); + } catch (err) { console.error('Error saving items after download:', err); - }); + } console.log(`Successfully added torrent for item: ${item.title}`); diff --git a/modules/service-setup-module.sh b/modules/service-setup-module.sh index e8483cc..2c7fdae 100644 --- a/modules/service-setup-module.sh +++ b/modules/service-setup-module.sh @@ -3,13 +3,47 @@ # Setup systemd service function setup_service() { - echo -e "${YELLOW}Setting up systemd service...${NC}" + log "INFO" "Setting up systemd service..." + + # Ensure required variables are set + if [ -z "$SERVICE_NAME" ]; then + log "ERROR" "SERVICE_NAME variable is not set" + exit 1 + fi + + if [ -z "$USER" ]; then + log "ERROR" "USER variable is not set" + exit 1 + fi + + if [ -z "$INSTALL_DIR" ]; then + log "ERROR" "INSTALL_DIR variable is not set" + exit 1 + fi + + if [ -z "$PORT" ]; then + log "ERROR" "PORT variable is not set" + exit 1 + fi + + # Check if systemd is available + if ! command -v systemctl &> /dev/null; then + log "ERROR" "systemd is not available on this system" + log "INFO" "Please set up the service manually using your system's service manager" + return 1 + fi + + # Create backup of existing service file if it exists + if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then + backup_file "/etc/systemd/system/$SERVICE_NAME.service" + fi # Create systemd service file - cat > /etc/systemd/system/$SERVICE_NAME.service << EOF + SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" + cat > "$SERVICE_FILE" << EOF [Unit] Description=Transmission RSS Manager -After=network.target +After=network.target transmission-daemon.service Wants=network-online.target [Service] @@ -23,22 +57,77 @@ StandardOutput=journal StandardError=journal Environment=PORT=$PORT Environment=NODE_ENV=production +Environment=DEBUG_ENABLED=false +Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log +# Generate a random JWT secret for security +Environment=JWT_SECRET=$(openssl rand -hex 32) [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." + # Create logs directory + mkdir -p "$INSTALL_DIR/logs" + chown -R $USER:$USER "$INSTALL_DIR/logs" + + # Check if file was created successfully + if [ ! -f "$SERVICE_FILE" ]; then + log "ERROR" "Failed to create systemd service file" + return 1 fi - # Create nginx configuration - cat > /etc/nginx/sites-available/$SERVICE_NAME << EOF + log "INFO" "Setting up Nginx reverse proxy..." + + # Check if nginx is installed + if ! command -v nginx &> /dev/null; then + log "ERROR" "Nginx is not installed" + log "INFO" "Skipping Nginx configuration. Please configure your web server manually." + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Detect nginx configuration directory + NGINX_AVAILABLE_DIR="" + NGINX_ENABLED_DIR="" + + if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then + # Debian/Ubuntu style + NGINX_AVAILABLE_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + elif [ -d "/etc/nginx/conf.d" ]; then + # CentOS/RHEL style + NGINX_AVAILABLE_DIR="/etc/nginx/conf.d" + NGINX_ENABLED_DIR="/etc/nginx/conf.d" + else + log "WARN" "Unable to determine Nginx configuration directory" + log "INFO" "Please configure Nginx manually" + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Check if default nginx file exists, back it up if it does + if [ -f "$NGINX_ENABLED_DIR/default" ]; then + backup_file "$NGINX_ENABLED_DIR/default" + if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then + log "INFO" "Backed up default nginx configuration." + fi + fi + + # Create nginx configuration file + NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf" + cat > "$NGINX_CONFIG_FILE" << EOF server { listen 80; server_name _; @@ -57,27 +146,36 @@ server { } EOF - # Create symbolic link to enable the site - ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/ + # Check if Debian/Ubuntu style (need symlink between available and enabled) + if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then + # Create symbolic link to enable the site (if it doesn't already exist) + if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then + ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/" + fi + fi # Test nginx configuration - nginx -t - - if [ $? -eq 0 ]; then + if nginx -t; then # Reload nginx systemctl reload nginx - echo -e "${GREEN}Nginx configuration has been set up successfully.${NC}" + log "INFO" "Nginx configuration has been set up successfully." 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}" + log "ERROR" "Nginx configuration test failed. Please check the configuration manually." + log "WARN" "You may need to correct the configuration before the web interface will be accessible." + fi + + # Check for port conflicts + if ss -lnt | grep ":$PORT " &> /dev/null; then + log "WARN" "Port $PORT is already in use. This may cause conflicts with the service." + log "WARN" "Consider changing the port if you encounter issues." fi # Reload systemd systemctl daemon-reload # Enable the service to start on boot - systemctl enable $SERVICE_NAME + 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}" + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." } \ No newline at end of file diff --git a/modules/transmission-client.js b/modules/transmission-client.js new file mode 100644 index 0000000..3fcaac4 --- /dev/null +++ b/modules/transmission-client.js @@ -0,0 +1,517 @@ +/** + * Transmission Client Module + * Enhanced integration with Transmission BitTorrent client + */ + +const Transmission = require('transmission-promise'); +const fs = require('fs').promises; +const path = require('path'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +class TransmissionClient { + constructor(config) { + if (!config) { + throw new Error('Configuration is required for Transmission client'); + } + + this.config = config; + this.client = null; + this.dirMappings = null; + this.lastSessionId = null; + this.connectRetries = 0; + this.maxRetries = 5; + this.retryDelay = 5000; // 5 seconds + + // Initialize directory mappings if remote + if (config.remoteConfig && config.remoteConfig.isRemote && config.remoteConfig.directoryMapping) { + this.dirMappings = config.remoteConfig.directoryMapping; + } + + // Initialize the connection + this.initializeConnection(); + } + + /** + * Initialize the connection to Transmission + */ + initializeConnection() { + const { host, port, username, password, path: rpcPath } = this.config.transmissionConfig; + + try { + this.client = new Transmission({ + host: host || 'localhost', + port: port || 9091, + username: username || '', + password: password || '', + path: rpcPath || '/transmission/rpc', + timeout: 30000 // 30 seconds + }); + + console.log(`Initialized Transmission client connection to ${host}:${port}${rpcPath}`); + } catch (error) { + console.error('Failed to initialize Transmission client:', error); + throw error; + } + } + + /** + * Get client status and session information + * @returns {Promise} Status information + */ + async getStatus() { + try { + const sessionInfo = await this.client.sessionStats(); + const version = await this.client.sessionGet(); + + return { + connected: true, + version: version.version, + rpcVersion: version['rpc-version'], + downloadSpeed: sessionInfo.downloadSpeed, + uploadSpeed: sessionInfo.uploadSpeed, + torrentCount: sessionInfo.torrentCount, + activeTorrentCount: sessionInfo.activeTorrentCount + }; + } catch (error) { + console.error('Error getting Transmission status:', error); + + if (error.message.includes('Connection refused') && this.connectRetries < this.maxRetries) { + this.connectRetries++; + console.log(`Retrying connection (${this.connectRetries}/${this.maxRetries})...`); + + return new Promise((resolve) => { + setTimeout(async () => { + this.initializeConnection(); + try { + const status = await this.getStatus(); + this.connectRetries = 0; // Reset retries on success + resolve(status); + } catch (retryError) { + resolve({ + connected: false, + error: retryError.message + }); + } + }, this.retryDelay); + }); + } + + return { + connected: false, + error: error.message + }; + } + } + + /** + * Add a torrent from a URL or magnet link + * @param {string} url - Torrent URL or magnet link + * @param {Object} options - Additional options + * @returns {Promise} Result with torrent ID + */ + async addTorrent(url, options = {}) { + try { + const downloadDir = options.downloadDir || null; + const result = await this.client.addUrl(url, { + "download-dir": downloadDir, + paused: options.paused || false + }); + + console.log(`Added torrent from ${url}, ID: ${result.id}`); + return { + success: true, + id: result.id, + name: result.name, + hashString: result.hashString + }; + } catch (error) { + console.error(`Error adding torrent from ${url}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Get all torrents with detailed information + * @param {Array} ids - Optional array of torrent IDs to filter + * @returns {Promise} Array of torrent objects + */ + async getTorrents(ids = null) { + try { + const torrents = await this.client.get(ids); + + // Map remote paths to local paths if needed + if (this.dirMappings && Object.keys(this.dirMappings).length > 0) { + torrents.torrents = torrents.torrents.map(torrent => { + torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir); + return torrent; + }); + } + + return { + success: true, + torrents: torrents.torrents + }; + } catch (error) { + console.error('Error getting torrents:', error); + return { + success: false, + error: error.message, + torrents: [] + }; + } + } + + /** + * Stop torrents by IDs + * @param {Array|number} ids - Torrent ID(s) to stop + * @returns {Promise} Result + */ + async stopTorrents(ids) { + try { + await this.client.stop(ids); + return { + success: true, + message: 'Torrents stopped successfully' + }; + } catch (error) { + console.error(`Error stopping torrents ${ids}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Start torrents by IDs + * @param {Array|number} ids - Torrent ID(s) to start + * @returns {Promise} Result + */ + async startTorrents(ids) { + try { + await this.client.start(ids); + return { + success: true, + message: 'Torrents started successfully' + }; + } catch (error) { + console.error(`Error starting torrents ${ids}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Remove torrents by IDs + * @param {Array|number} ids - Torrent ID(s) to remove + * @param {boolean} deleteLocalData - Whether to delete local data + * @returns {Promise} Result + */ + async removeTorrents(ids, deleteLocalData = false) { + try { + await this.client.remove(ids, deleteLocalData); + return { + success: true, + message: `Torrents removed successfully${deleteLocalData ? ' with data' : ''}` + }; + } catch (error) { + console.error(`Error removing torrents ${ids}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Get detailed information for a specific torrent + * @param {number} id - Torrent ID + * @returns {Promise} Torrent details + */ + async getTorrentDetails(id) { + try { + const fields = [ + 'id', 'name', 'status', 'hashString', 'downloadDir', 'totalSize', + 'percentDone', 'addedDate', 'doneDate', 'uploadRatio', 'rateDownload', + 'rateUpload', 'downloadedEver', 'uploadedEver', 'seedRatioLimit', + 'error', 'errorString', 'files', 'fileStats', 'peers', 'peersFrom', + 'pieces', 'trackers', 'trackerStats', 'labels' + ]; + + const result = await this.client.get(id, fields); + + if (!result.torrents || result.torrents.length === 0) { + return { + success: false, + error: 'Torrent not found' + }; + } + + let torrent = result.torrents[0]; + + // Map download directory if needed + if (this.dirMappings) { + torrent.downloadDir = this.mapRemotePathToLocal(torrent.downloadDir); + } + + // Process files for extra information if available + if (torrent.files && torrent.files.length > 0) { + torrent.mediaInfo = await this.analyzeMediaFiles(torrent.files, torrent.downloadDir); + } + + return { + success: true, + torrent + }; + } catch (error) { + console.error(`Error getting torrent details for ID ${id}:`, error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Map a remote path to a local path + * @param {string} remotePath - Path on the remote server + * @returns {string} Local path + */ + mapRemotePathToLocal(remotePath) { + if (!this.dirMappings || !remotePath) { + return remotePath; + } + + for (const [remote, local] of Object.entries(this.dirMappings)) { + if (remotePath.startsWith(remote)) { + return remotePath.replace(remote, local); + } + } + + return remotePath; + } + + /** + * Analyze media files in a torrent + * @param {Array} files - Torrent files + * @param {string} baseDir - Base directory of the torrent + * @returns {Promise} Media info + */ + async analyzeMediaFiles(files, baseDir) { + try { + const mediaInfo = { + type: 'unknown', + videoFiles: [], + audioFiles: [], + imageFiles: [], + documentFiles: [], + archiveFiles: [], + otherFiles: [], + totalVideoSize: 0, + totalAudioSize: 0, + totalImageSize: 0, + totalDocumentSize: 0, + totalArchiveSize: 0, + totalOtherSize: 0 + }; + + // File type patterns + const videoPattern = /\.(mp4|mkv|avi|mov|wmv|flv|webm|m4v|mpg|mpeg|3gp|ts)$/i; + const audioPattern = /\.(mp3|flac|wav|aac|ogg|m4a|wma|opus)$/i; + const imagePattern = /\.(jpg|jpeg|png|gif|bmp|tiff|webp|svg)$/i; + const documentPattern = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|txt|rtf|odt|ods|odp|epub|mobi|azw3)$/i; + const archivePattern = /\.(zip|rar|7z|tar|gz|bz2|xz|iso)$/i; + const subtitlePattern = /\.(srt|sub|sbv|vtt|ass|ssa)$/i; + const samplePattern = /sample|trailer/i; + + // Count files by category + for (const file of files) { + const fileName = path.basename(file.name).toLowerCase(); + const fileSize = file.length; + + const fileInfo = { + name: file.name, + size: fileSize, + extension: path.extname(file.name).substr(1).toLowerCase(), + isSample: samplePattern.test(fileName) + }; + + if (videoPattern.test(fileName)) { + mediaInfo.videoFiles.push(fileInfo); + mediaInfo.totalVideoSize += fileSize; + } else if (audioPattern.test(fileName)) { + mediaInfo.audioFiles.push(fileInfo); + mediaInfo.totalAudioSize += fileSize; + } else if (imagePattern.test(fileName)) { + mediaInfo.imageFiles.push(fileInfo); + mediaInfo.totalImageSize += fileSize; + } else if (documentPattern.test(fileName)) { + mediaInfo.documentFiles.push(fileInfo); + mediaInfo.totalDocumentSize += fileSize; + } else if (archivePattern.test(fileName)) { + mediaInfo.archiveFiles.push(fileInfo); + mediaInfo.totalArchiveSize += fileSize; + } else if (!subtitlePattern.test(fileName)) { + mediaInfo.otherFiles.push(fileInfo); + mediaInfo.totalOtherSize += fileSize; + } + } + + // Determine content type based on file distribution + if (mediaInfo.videoFiles.length > 0 && + mediaInfo.totalVideoSize > (mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) { + mediaInfo.type = 'video'; + + // Determine if it's a movie or TV show + const tvEpisodePattern = /(s\d{1,2}e\d{1,2}|\d{1,2}x\d{1,2})/i; + const movieYearPattern = /\(?(19|20)\d{2}\)?/; + + let tvShowMatch = false; + + for (const file of mediaInfo.videoFiles) { + if (tvEpisodePattern.test(file.name)) { + tvShowMatch = true; + break; + } + } + + if (tvShowMatch) { + mediaInfo.type = 'tvshow'; + } else if (movieYearPattern.test(files[0].name)) { + mediaInfo.type = 'movie'; + } + } else if (mediaInfo.audioFiles.length > 0 && + mediaInfo.totalAudioSize > (mediaInfo.totalVideoSize + mediaInfo.totalDocumentSize)) { + mediaInfo.type = 'audio'; + } else if (mediaInfo.documentFiles.length > 0 && + mediaInfo.totalDocumentSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize)) { + // Check if it's a book or magazine + const magazinePattern = /(magazine|issue|volume|vol\.)\s*\d+/i; + + let isMagazine = false; + for (const file of mediaInfo.documentFiles) { + if (magazinePattern.test(file.name)) { + isMagazine = true; + break; + } + } + + mediaInfo.type = isMagazine ? 'magazine' : 'book'; + } else if (mediaInfo.archiveFiles.length > 0 && + mediaInfo.totalArchiveSize > (mediaInfo.totalVideoSize + mediaInfo.totalAudioSize + mediaInfo.totalDocumentSize)) { + // If archives dominate, we need to check their content + mediaInfo.type = 'archive'; + } + + return mediaInfo; + } catch (error) { + console.error('Error analyzing media files:', error); + return { type: 'unknown', error: error.message }; + } + } + + /** + * Get session stats from Transmission + * @returns {Promise} Stats + */ + async getSessionStats() { + try { + const stats = await this.client.sessionStats(); + return { + success: true, + stats + }; + } catch (error) { + console.error('Error getting session stats:', error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Set session parameters + * @param {Object} params - Session parameters + * @returns {Promise} Result + */ + async setSessionParams(params) { + try { + await this.client.sessionSet(params); + return { + success: true, + message: 'Session parameters updated successfully' + }; + } catch (error) { + console.error('Error setting session parameters:', error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Verify if a torrent has met seeding requirements + * @param {number} id - Torrent ID + * @param {Object} requirements - Seeding requirements + * @returns {Promise} Whether requirements are met + */ + async verifyTorrentSeedingRequirements(id, requirements) { + try { + const { minRatio = 1.0, minTimeMinutes = 60 } = requirements; + + const details = await this.getTorrentDetails(id); + + if (!details.success) { + return { + success: false, + error: details.error + }; + } + + const torrent = details.torrent; + + // Check if download is complete + if (torrent.percentDone < 1.0) { + return { + success: true, + requirementsMet: false, + reason: 'Download not complete', + torrent + }; + } + + // Check ratio requirement + const ratioMet = torrent.uploadRatio >= minRatio; + + // Check time requirement (doneDate is unix timestamp in seconds) + const seedingTimeMinutes = (Date.now() / 1000 - torrent.doneDate) / 60; + const timeMet = seedingTimeMinutes >= minTimeMinutes; + + return { + success: true, + requirementsMet: ratioMet && timeMet, + ratioMet, + timeMet, + currentRatio: torrent.uploadRatio, + currentSeedingTimeMinutes: seedingTimeMinutes, + torrent + }; + } catch (error) { + console.error(`Error checking torrent seeding requirements for ID ${id}:`, error); + return { + success: false, + error: error.message + }; + } + } +} + +module.exports = TransmissionClient; \ No newline at end of file diff --git a/modules/utils-module.sh b/modules/utils-module.sh index 15e35c5..636f66a 100644 --- a/modules/utils-module.sh +++ b/modules/utils-module.sh @@ -17,10 +17,20 @@ function log() { "ERROR") echo -e "${timestamp} ${RED}[ERROR]${NC} $message" ;; + "DEBUG") + if [ "${DEBUG_ENABLED}" = "true" ]; then + echo -e "${timestamp} ${BOLD}[DEBUG]${NC} $message" + fi + ;; *) echo -e "${timestamp} [LOG] $message" ;; esac + + # If log file is specified, also write to log file + if [ -n "${LOG_FILE}" ]; then + echo "${timestamp} [${level}] ${message}" >> "${LOG_FILE}" + fi } # Function to check if a command exists @@ -35,6 +45,38 @@ function backup_file() { local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" cp "$file" "$backup" log "INFO" "Created backup of $file at $backup" + echo "$backup" + fi +} + +# Function to manage config file updates +function update_config_file() { + local config_file=$1 + local is_update=$2 + + if [ "$is_update" = true ] && [ -f "$config_file" ]; then + # Backup the existing config file + local backup_file=$(backup_file "$config_file") + log "INFO" "Existing configuration backed up to $backup_file" + + # We'll let the server.js handle merging the config + log "INFO" "Existing configuration will be preserved" + + # Update the config version if needed + local current_version=$(grep -o '"version": "[^"]*"' "$config_file" | cut -d'"' -f4) + if [ -n "$current_version" ]; then + local new_version="1.2.0" + if [ "$current_version" != "$new_version" ]; then + log "INFO" "Updating config version from $current_version to $new_version" + sed -i "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/" "$config_file" + fi + fi + + return 0 + else + # New installation, config file will be created by finalize_setup + log "INFO" "New configuration will be created" + return 1 fi } @@ -58,6 +100,10 @@ function create_dir_if_not_exists() { function finalize_setup() { log "INFO" "Setting up final permissions and configurations..." + # Ensure logs directory exists + mkdir -p "$INSTALL_DIR/logs" + log "INFO" "Created logs directory: $INSTALL_DIR/logs" + # Set proper ownership for the installation directory chown -R $USER:$USER $INSTALL_DIR @@ -77,25 +123,19 @@ function finalize_setup() { 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 + # Handle configuration file + if ! update_config_file "$INSTALL_DIR/config.json" "$IS_UPDATE"; then log "INFO" "Creating default configuration file..." + + # Create the users array content for JSON + USER_JSON="" + if [ "${AUTH_ENABLED}" = "true" ] && [ -n "${ADMIN_USERNAME}" ]; then + USER_JSON="{ \"username\": \"${ADMIN_USERNAME}\", \"password\": \"${ADMIN_PASSWORD}\", \"role\": \"admin\" }" + fi + cat > $INSTALL_DIR/config.json << EOF { + "version": "1.2.0", "transmissionConfig": { "host": "${TRANSMISSION_HOST}", "port": ${TRANSMISSION_PORT}, @@ -132,12 +172,38 @@ function finalize_setup() { "removeDuplicates": true, "keepOnlyBestVersion": true }, + "securitySettings": { + "authEnabled": ${AUTH_ENABLED:-false}, + "httpsEnabled": ${HTTPS_ENABLED:-false}, + "sslCertPath": "${SSL_CERT_PATH:-""}", + "sslKeyPath": "${SSL_KEY_PATH:-""}", + "users": [ + ${USER_JSON} + ] + }, "rssFeeds": [], "rssUpdateIntervalMinutes": 60, - "autoProcessing": false + "autoProcessing": false, + "port": ${PORT}, + "logLevel": "info" } EOF chown $USER:$USER $INSTALL_DIR/config.json + log "INFO" "Default configuration created successfully" + fi + + # 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 log "INFO" "Setup finalized!" diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ea840d --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "transmission-rss-manager", + "version": "1.2.0", + "description": "A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration and intelligent media organization", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "lint": "eslint --fix --ext .js,.jsx .", + "test": "jest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/yourusername/transmission-rss-manager.git" + }, + "keywords": [ + "transmission", + "rss", + "torrent", + "automation", + "media", + "manager" + ], + "author": "Your Name", + "license": "MIT", + "dependencies": { + "bcrypt": "^5.1.0", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.0", + "morgan": "^1.10.0", + "node-fetch": "^2.6.11", + "transmission-promise": "^1.1.5", + "xml2js": "^0.5.0" + }, + "devDependencies": { + "eslint": "^8.42.0", + "jest": "^29.5.0", + "nodemon": "^2.0.22" + }, + "engines": { + "node": ">=14.0.0" + } +} \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..e55f420 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,665 @@ +/* Main Styles for Transmission RSS Manager */ +:root { + --primary-color: #3498db; + --primary-dark: #2980b9; + --secondary-color: #2ecc71; + --secondary-dark: #27ae60; + --warning-color: #f39c12; + --danger-color: #e74c3c; + --background-color: #f8f9fa; + --dark-background: #1a1a1a; + --card-background: #ffffff; + --dark-card-background: #2a2a2a; + --text-color: #333333; + --dark-text-color: #f5f5f5; + --border-color: #dddddd; + --dark-border-color: #444444; + --success-background: #d4edda; + --success-text: #155724; + --error-background: #f8d7da; + --error-text: #721c24; + --input-background: #ffffff; + --dark-input-background: #333333; +} + +/* Dark mode styles */ +[data-theme="dark"] { + --background-color: var(--dark-background); + --card-background: var(--dark-card-background); + --text-color: var(--dark-text-color); + --border-color: var(--dark-border-color); + --input-background: var(--dark-input-background); +} + +* { + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--background-color); + margin: 0; + padding: 0; + transition: background-color 0.3s ease; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + margin-top: 0; +} + +a { + color: var(--primary-color); + text-decoration: none; + transition: color 0.3s ease; +} + +a:hover { + color: var(--primary-dark); + text-decoration: underline; +} + +/* Buttons */ +.btn { + cursor: pointer; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + background-color: var(--primary-color); + color: white; + transition: background-color 0.3s ease, transform 0.2s ease; + margin: 2px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn i, .btn svg { + margin-right: 8px; +} + +.btn.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.btn.btn-lg { + padding: 12px 20px; + font-size: 16px; +} + +.btn.btn-primary { + background-color: var(--primary-color); +} + +.btn.btn-success { + background-color: var(--secondary-color); +} + +.btn.btn-warning { + background-color: var(--warning-color); +} + +.btn.btn-danger { + background-color: var(--danger-color); +} + +.btn.btn-outline { + background-color: transparent; + border: 1px solid var(--primary-color); + color: var(--primary-color); +} + +.btn.btn-outline:hover { + background-color: var(--primary-color); + color: white; +} + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.row { + display: flex; + flex-wrap: wrap; + margin: -10px; +} + +.col { + flex: 1; + padding: 10px; +} + +.col-25 { + flex: 0 0 25%; + max-width: 25%; + padding: 10px; +} + +.col-50 { + flex: 0 0 50%; + max-width: 50%; + padding: 10px; +} + +.col-75 { + flex: 0 0 75%; + max-width: 75%; + padding: 10px; +} + +/* Header and Navigation */ +header { + background-color: var(--primary-color); + color: white; + padding: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 1000; +} + +.navbar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.navbar-brand { + font-size: 1.5rem; + font-weight: bold; + color: white; + text-decoration: none; + display: flex; + align-items: center; +} + +.navbar-brand i, .navbar-brand svg { + margin-right: 8px; +} + +.navbar-menu { + display: flex; + list-style: none; + margin: 0; + padding: 0; +} + +.navbar-item { + margin: 0 10px; +} + +.navbar-link { + color: rgba(255, 255, 255, 0.85); + text-decoration: none; + transition: color 0.3s ease; + display: flex; + align-items: center; +} + +.navbar-link i, .navbar-link svg { + margin-right: 5px; +} + +.navbar-link:hover, +.navbar-link.active { + color: white; +} + +.navbar-right { + display: flex; + align-items: center; +} + +.theme-toggle { + background: none; + border: none; + color: white; + cursor: pointer; + font-size: 1.2rem; + margin-left: 1rem; +} + +/* Cards */ +.card { + background-color: var(--card-background); + border-radius: 8px; + border: 1px solid var(--border-color); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + margin-bottom: 1.5rem; + transition: box-shadow 0.3s ease, transform 0.3s ease; +} + +.card:hover { + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); +} + +.card-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h2, .card-header h3 { + margin: 0; +} + +.card-body { + padding: 1.5rem; +} + +.card-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-control { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--input-background); + color: var(--text-color); + font-size: 1rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; +} + +.form-control:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2); +} + +.form-check { + display: flex; + align-items: center; + margin-bottom: 0.5rem; +} + +.form-check-input { + margin-right: 0.5rem; +} + +/* Tables */ +.table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1.5rem; +} + +.table th, +.table td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.table th { + background-color: rgba(0, 0, 0, 0.05); + font-weight: 600; +} + +.table tr:hover { + background-color: rgba(0, 0, 0, 0.025); +} + +.table-responsive { + overflow-x: auto; +} + +/* Progress Bar */ +.progress { + height: 0.75rem; + background-color: var(--border-color); + border-radius: 0.375rem; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background-color: var(--primary-color); + transition: width 0.3s ease; +} + +.progress-bar-success { + background-color: var(--secondary-color); +} + +.progress-bar-warning { + background-color: var(--warning-color); +} + +.progress-bar-danger { + background-color: var(--danger-color); +} + +/* Alerts */ +.alert { + padding: 0.75rem 1.25rem; + margin-bottom: 1rem; + border-radius: 4px; + border: 1px solid transparent; +} + +.alert-success { + background-color: var(--success-background); + border-color: #c3e6cb; + color: var(--success-text); +} + +.alert-danger { + background-color: var(--error-background); + border-color: #f5c6cb; + color: var(--error-text); +} + +.alert-warning { + background-color: #fff3cd; + border-color: #ffeeba; + color: #856404; +} + +.alert-info { + background-color: #d1ecf1; + border-color: #bee5eb; + color: #0c5460; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 0.25rem; + text-align: center; + white-space: nowrap; + vertical-align: baseline; +} + +.badge-primary { + background-color: var(--primary-color); + color: white; +} + +.badge-success { + background-color: var(--secondary-color); + color: white; +} + +.badge-warning { + background-color: var(--warning-color); + color: white; +} + +.badge-danger { + background-color: var(--danger-color); + color: white; +} + +/* Modals */ +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1050; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s ease, visibility 0.3s ease; +} + +.modal-backdrop.show { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: var(--card-background); + border-radius: 8px; + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; + opacity: 0; + transform: translateY(-20px); + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.modal-backdrop.show .modal { + opacity: 1; + transform: translateY(0); +} + +.modal-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h2 { + margin: 0; +} + +.modal-close { + border: none; + background: none; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: var(--text-color); +} + +.modal-body { + padding: 1rem; +} + +.modal-footer { + padding: 1rem; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; +} + +/* Tabs */ +.tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} + +.tab { + padding: 0.75rem 1.5rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: border-color 0.3s ease, color 0.3s ease; + font-weight: 500; +} + +.tab:hover { + color: var(--primary-color); +} + +.tab.active { + border-bottom-color: var(--primary-color); + color: var(--primary-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* Dashboard Widgets */ +.stats-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background-color: var(--card-background); + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 1.5rem; + display: flex; + align-items: center; +} + +.stat-icon { + width: 3rem; + height: 3rem; + border-radius: 50%; + background-color: rgba(52, 152, 219, 0.1); + display: flex; + align-items: center; + justify-content: center; + margin-right: 1rem; + font-size: 1.5rem; + color: var(--primary-color); +} + +.stat-info h3 { + margin: 0; + font-size: 2rem; + font-weight: 700; +} + +.stat-info p { + margin: 0; + color: #777; + font-size: 0.875rem; +} + +/* Utilities */ +.text-center { text-align: center; } +.text-right { text-align: right; } +.text-left { text-align: left; } +.font-weight-bold { font-weight: bold; } +.text-muted { color: #6c757d; } + +.mt-1 { margin-top: 0.5rem; } +.mt-2 { margin-top: 1rem; } +.mt-3 { margin-top: 1.5rem; } + +.mb-1 { margin-bottom: 0.5rem; } +.mb-2 { margin-bottom: 1rem; } +.mb-3 { margin-bottom: 1.5rem; } + +.ml-1 { margin-left: 0.5rem; } +.ml-2 { margin-left: 1rem; } +.ml-3 { margin-left: 1.5rem; } + +.mr-1 { margin-right: 0.5rem; } +.mr-2 { margin-right: 1rem; } +.mr-3 { margin-right: 1.5rem; } + +.pt-1 { padding-top: 0.5rem; } +.pt-2 { padding-top: 1rem; } +.pt-3 { padding-top: 1.5rem; } + +.pb-1 { padding-bottom: 0.5rem; } +.pb-2 { padding-bottom: 1rem; } +.pb-3 { padding-bottom: 1.5rem; } + +.pl-1 { padding-left: 0.5rem; } +.pl-2 { padding-left: 1rem; } +.pl-3 { padding-left: 1.5rem; } + +.pr-1 { padding-right: 0.5rem; } +.pr-2 { padding-right: 1rem; } +.pr-3 { padding-right: 1.5rem; } + +.d-none { display: none; } +.d-block { display: block; } +.d-flex { display: flex; } +.flex-wrap { flex-wrap: wrap; } +.align-items-center { align-items: center; } +.justify-content-center { justify-content: center; } +.justify-content-between { justify-content: space-between; } +.justify-content-end { justify-content: flex-end; } + +/* Media Queries */ +@media (max-width: 768px) { + .navbar { + flex-direction: column; + align-items: stretch; + } + + .navbar-menu { + flex-direction: column; + margin-top: 1rem; + } + + .navbar-item { + margin: 0.25rem 0; + } + + .navbar-right { + margin-top: 0.5rem; + justify-content: flex-end; + } + + .stats-container { + grid-template-columns: 1fr; + } + + .col-25, .col-50, .col-75 { + flex: 0 0 100%; + max-width: 100%; + } +} + +@media (max-width: 576px) { + .card-header { + flex-direction: column; + align-items: flex-start; + } + + .card-header > .btn { + margin-top: 0.5rem; + } +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 5b4bc8a..8480227 100644 --- a/public/index.html +++ b/public/index.html @@ -1,1386 +1,515 @@ - + Transmission RSS Manager - + + + + + + + + + -
-

Transmission RSS Manager

- -
- -
- - - -
+ +
+
+
+ + +
+
-

Dashboard

-
-

Loading system status...

+
+

Login

- -

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}

+ +
+
+
+

Post-Processing

+
+
+

Process completed downloads to your media library

+
+ +
- `; - 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); + +
+
+
+

Add New Torrent

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

RSS Feeds

+ +
+
+
+

Loading feeds...

+
+
+
+ +
+
+

Available Items

+
+ + +
+
+
+
+

Loading items...

+
+
+
+
+ + +
+
+
+

Active Torrents

+ +
+
+
+

Loading torrents...

+
+
+
+
+ + +
+
+
+

Media Library

+
+
+
+
+
+ +
+ + +
+
+
+
+ +
+ + + + + + + +
+
+
- 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}

+
+

Loading media library...

- `; - 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 + +
+
+
+

Security Settings

+
+
+
+ + + + Requires valid SSL certificate (not self-signed) + +
+ +
+ + + + Requires login to access the application + +
+ +
+
+ + +
+ +
+ + +
+
+
+
+
+
+ +
+
+

Post-Processing Settings

+
+
+
+
+

Seeding Requirements

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

Media Paths

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

Processing Options

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

RSS Settings

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

Transmission RSS Manager v1.2.0

+
+
+

GitHub | About

+
+
+
+
+ + + + + + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..e669a83 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1650 @@ +/** + * Transmission RSS Manager - Main Application Script + * @description Core functionality for the web interface + */ + +// Application state +const appState = { + darkMode: localStorage.getItem('darkMode') === 'true', + currentTab: 'home', + notifications: [], + config: null, + feeds: [], + items: [], + torrents: [], + library: {}, + isLoading: false, + authToken: localStorage.getItem('authToken') || null, + user: null +}; + +// Initialize app when DOM is loaded +document.addEventListener('DOMContentLoaded', () => { + initializeApp(); +}); + +/** + * Initialize the application + */ +function initializeApp() { + // Apply theme based on saved preference + applyTheme(); + + // Add event listeners + addEventListeners(); + + // Check authentication status + checkAuthStatus(); + + // Load initial data + loadInitialData(); + + // Initialize notifications system + initNotifications(); +} + +/** + * Apply theme (light/dark) to the document + */ +function applyTheme() { + if (appState.darkMode) { + document.documentElement.setAttribute('data-theme', 'dark'); + document.getElementById('theme-toggle-icon').className = 'fas fa-sun'; + } else { + document.documentElement.setAttribute('data-theme', 'light'); + document.getElementById('theme-toggle-icon').className = 'fas fa-moon'; + } +} + +/** + * Set up all event listeners + */ +function addEventListeners() { + // Theme toggle + document.getElementById('theme-toggle').addEventListener('click', toggleTheme); + + // Tab navigation + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const tabId = e.currentTarget.getAttribute('data-tab'); + switchTab(tabId); + }); + }); + + // Action buttons + document.getElementById('btn-update-feeds').addEventListener('click', updateFeeds); + document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); + document.getElementById('add-torrent-form').addEventListener('submit', handleAddTorrent); + + // Form submissions + document.getElementById('settings-form').addEventListener('submit', saveSettings); + document.getElementById('login-form')?.addEventListener('submit', handleLogin); + + // Processor controls + document.getElementById('btn-start-processor').addEventListener('click', startProcessor); + document.getElementById('btn-stop-processor').addEventListener('click', stopProcessor); + + // Filter handlers + document.querySelectorAll('input[name="item-filter"]').forEach(input => { + input.addEventListener('change', loadRssItems); + }); + + document.querySelectorAll('input[name="library-filter"]').forEach(input => { + input.addEventListener('change', filterLibrary); + }); + + // Search handlers + document.getElementById('library-search')?.addEventListener('input', debounce(searchLibrary, 300)); +} + +/** + * Check authentication status + */ +function checkAuthStatus() { + if (!appState.authToken) { + // If login form exists, show it; otherwise we're on a public page + const loginForm = document.getElementById('login-form'); + if (loginForm) { + document.getElementById('app-container').classList.add('d-none'); + document.getElementById('login-container').classList.remove('d-none'); + } + return; + } + + // Validate token + fetch('/api/auth/validate', { + headers: { + 'Authorization': `Bearer ${appState.authToken}` + } + }) + .then(response => { + if (!response.ok) throw new Error('Invalid token'); + return response.json(); + }) + .then(data => { + appState.user = data.user; + updateUserInfo(); + + // Show app content + if (document.getElementById('login-container')) { + document.getElementById('login-container').classList.add('d-none'); + document.getElementById('app-container').classList.remove('d-none'); + } + }) + .catch(error => { + console.error('Auth validation error:', error); + localStorage.removeItem('authToken'); + appState.authToken = null; + appState.user = null; + + // Show login if present + if (document.getElementById('login-form')) { + document.getElementById('app-container').classList.add('d-none'); + document.getElementById('login-container').classList.remove('d-none'); + } + }); +} + +/** + * Handle user login + * @param {Event} e - Submit event + */ +function handleLogin(e) { + e.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + setLoading(true); + + fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password }) + }) + .then(response => { + if (!response.ok) throw new Error('Invalid credentials'); + return response.json(); + }) + .then(data => { + localStorage.setItem('authToken', data.token); + appState.authToken = data.token; + appState.user = data.user; + + document.getElementById('login-container').classList.add('d-none'); + document.getElementById('app-container').classList.remove('d-none'); + + // Load data + loadInitialData(); + + showNotification('Welcome back!', 'success'); + }) + .catch(error => { + showNotification('Login failed: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update user info in the UI + */ +function updateUserInfo() { + if (!appState.user) return; + + const userInfoEl = document.getElementById('user-info'); + if (userInfoEl) { + userInfoEl.textContent = appState.user.username; + } +} + +/** + * Toggle between light and dark theme + */ +function toggleTheme() { + appState.darkMode = !appState.darkMode; + localStorage.setItem('darkMode', appState.darkMode); + applyTheme(); +} + +/** + * Switch to a different tab + * @param {string} tabId - ID of the tab to switch to + */ +function switchTab(tabId) { + // Update active tab + document.querySelectorAll('.nav-link').forEach(link => { + link.classList.toggle('active', link.getAttribute('data-tab') === tabId); + }); + + // Show the selected tab content + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.toggle('active', tab.id === tabId); + }); + + // Update state + appState.currentTab = tabId; + + // Load tab-specific data + if (tabId === 'torrents-tab') { + loadTorrents(); + } else if (tabId === 'rss-tab') { + loadRssFeeds(); + loadRssItems(); + } else if (tabId === 'media-tab') { + loadLibrary(); + } else if (tabId === 'settings-tab') { + loadSettings(); + } +} + +/** + * Load initial data for the application + */ +function loadInitialData() { + setLoading(true); + + // Load system status first + loadSystemStatus() + .then(() => { + // Then load data for the current tab + switch (appState.currentTab) { + case 'home': + loadTorrents(); + break; + case 'torrents-tab': + loadTorrents(); + break; + case 'rss-tab': + loadRssFeeds(); + loadRssItems(); + break; + case 'media-tab': + loadLibrary(); + break; + case 'settings-tab': + loadSettings(); + break; + } + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Load system status information + * @returns {Promise} - Resolves when data is loaded + */ +function loadSystemStatus() { + return fetch('/api/status', { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + updateStatusDisplay(data); + return data; + }) + .catch(error => { + showNotification('Error loading status: ' + error.message, 'danger'); + }); +} + +/** + * Update the status display with current system status + * @param {Object} data - Status data from API + */ +function updateStatusDisplay(data) { + const statusContainer = document.getElementById('status-container'); + + if (!statusContainer) return; + + const statusHtml = ` +
+
+
+ +
+
+

System Status

+

${data.status}

+ Version ${data.version} +
+
+ +
+
+ +
+
+

Transmission

+

${data.transmissionConnected ? 'Connected' : 'Disconnected'}

+
+
+ +
+
+ +
+
+

Post-Processor

+

${data.postProcessorActive ? 'Running' : 'Stopped'}

+ ${data.config.autoProcessing ? 'Auto-processing enabled' : 'Auto-processing disabled'} +
+
+ +
+
+ +
+
+

RSS Manager

+

${data.rssFeedManagerActive ? 'Running' : 'Stopped'}

+ ${data.config.rssEnabled ? `${appState.feeds?.length || 0} feeds active` : 'No feeds'} +
+
+
+ `; + + statusContainer.innerHTML = statusHtml; +} + +/** + * Load torrents data from the server + */ +function loadTorrents() { + setLoading(true); + + fetch('/api/transmission/torrents', { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + appState.torrents = data.data || []; + updateTorrentsDisplay(); + }) + .catch(error => { + showNotification('Error loading torrents: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update the torrents display with current data + */ +function updateTorrentsDisplay() { + const torrentsContainer = document.getElementById('torrents-container'); + + if (!torrentsContainer) return; + + if (!appState.torrents || appState.torrents.length === 0) { + torrentsContainer.innerHTML = '

No active torrents.

'; + return; + } + + let html = ` +
+ + + + + + + + + + + + + `; + + appState.torrents.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 ? + `` : + `` + } + +
+
+ `; + + torrentsContainer.innerHTML = html; +} + +/** + * Get the appropriate CSS class for a torrent status badge + * @param {number} status - Torrent status code + * @returns {string} - CSS class name + */ +function getBadgeClassForStatus(status) { + switch (status) { + case 0: return 'badge-danger'; // Stopped + case 1: case 2: case 3: return 'badge-warning'; // Checking/Waiting + case 4: return 'badge-primary'; // Downloading + case 5: case 6: return 'badge-success'; // Seeding + default: return 'badge-secondary'; + } +} + +/** + * Get the appropriate CSS class for a torrent progress bar + * @param {number} status - Torrent status code + * @returns {string} - CSS class name + */ +function getProgressBarClassForStatus(status) { + switch (status) { + case 0: return 'bg-danger'; // Stopped + case 4: return 'bg-primary'; // Downloading + case 5: case 6: return 'bg-success'; // Seeding + default: return ''; + } +} + +/** + * Start a torrent + * @param {number} id - Torrent ID + */ +function startTorrent(id) { + setLoading(true); + + fetch('/api/transmission/start', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ ids: id }) + }) + .then(handleResponse) + .then(data => { + showNotification('Torrent started successfully', 'success'); + loadTorrents(); + }) + .catch(error => { + showNotification('Error starting torrent: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Stop a torrent + * @param {number} id - Torrent ID + */ +function stopTorrent(id) { + setLoading(true); + + fetch('/api/transmission/stop', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ ids: id }) + }) + .then(handleResponse) + .then(data => { + showNotification('Torrent stopped successfully', 'success'); + loadTorrents(); + }) + .catch(error => { + showNotification('Error stopping torrent: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Remove a torrent + * @param {number} id - Torrent ID + */ +function removeTorrent(id) { + if (!confirm('Are you sure you want to remove this torrent? This will also delete the local data.')) { + return; + } + + setLoading(true); + + fetch('/api/transmission/remove', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ ids: id, deleteLocalData: true }) + }) + .then(handleResponse) + .then(data => { + showNotification('Torrent removed successfully', 'success'); + loadTorrents(); + }) + .catch(error => { + showNotification('Error removing torrent: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Handle adding a torrent from URL + * @param {Event} e - Form submit event + */ +function handleAddTorrent(e) { + e.preventDefault(); + + const url = document.getElementById('torrent-url').value.trim(); + + if (!url) { + showNotification('Please enter a torrent URL', 'warning'); + return; + } + + setLoading(true); + + fetch('/api/transmission/add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ url }) + }) + .then(handleResponse) + .then(data => { + showNotification('Torrent added successfully', 'success'); + document.getElementById('torrent-url').value = ''; + loadTorrents(); + }) + .catch(error => { + showNotification('Error adding torrent: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Load RSS feeds from the server + */ +function loadRssFeeds() { + setLoading(true); + + fetch('/api/rss/feeds', { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + appState.feeds = data.data || []; + updateRssFeedsDisplay(); + }) + .catch(error => { + showNotification('Error loading RSS feeds: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update the RSS feeds display with current data + */ +function updateRssFeedsDisplay() { + const feedsContainer = document.getElementById('feeds-container'); + + if (!feedsContainer) return; + + if (!appState.feeds || appState.feeds.length === 0) { + feedsContainer.innerHTML = '

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

'; + return; + } + + let html = ` +
+ + + + + + + + + + + `; + + appState.feeds.forEach(feed => { + html += ` + + + + + + + `; + }); + + html += ` + +
NameURLAuto-DownloadActions
${feed.name}${feed.url}${feed.autoDownload ? 'Yes' : 'No'} + + +
+
+ `; + + feedsContainer.innerHTML = html; +} + +/** + * Load RSS items from the server + */ +function loadRssItems() { + setLoading(true); + + const itemFilter = document.querySelector('input[name="item-filter"]:checked').value; + + fetch(`/api/rss/items?filter=${itemFilter}`, { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + appState.items = data.data || []; + updateRssItemsDisplay(); + }) + .catch(error => { + showNotification('Error loading RSS items: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update the RSS items display with current data + */ +function updateRssItemsDisplay() { + const itemsContainer = document.getElementById('items-container'); + + if (!itemsContainer) return; + + if (!appState.items || appState.items.length === 0) { + itemsContainer.innerHTML = '

No RSS items found.

'; + return; + } + + let html = ` +
+ + + + + + + + + + + + `; + + appState.items.forEach(item => { + const feed = appState.feeds.find(f => f.id === item.feedId) || { name: 'Unknown' }; + const size = item.size ? formatBytes(item.size) : 'Unknown'; + const date = new Date(item.pubDate).toLocaleDateString(); + + html += ` + + + + + + + + `; + }); + + html += ` + +
TitleFeedSizeDateActions
${item.title}${feed.name}${size}${date} + +
+
+ `; + + itemsContainer.innerHTML = html; +} + +/** + * Download an RSS item as a torrent + * @param {string} itemId - RSS item ID + */ +function downloadRssItem(itemId) { + setLoading(true); + + fetch('/api/rss/download', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ itemId }) + }) + .then(handleResponse) + .then(data => { + showNotification('Item added to Transmission successfully', 'success'); + loadRssItems(); + loadTorrents(); + }) + .catch(error => { + showNotification('Error downloading item: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Show the add feed modal + */ +function showAddFeedModal() { + const modalHtml = ` + + `; + + // Add modal to the document + const modalContainer = document.createElement('div'); + modalContainer.innerHTML = modalHtml; + document.body.appendChild(modalContainer.firstChild); + + // Show the modal + setTimeout(() => { + document.getElementById('add-feed-modal').classList.add('show'); + }, 10); + + // Add event listeners + document.getElementById('feed-auto-download').addEventListener('change', function() { + document.getElementById('filter-container').classList.toggle('d-none', !this.checked); + }); +} + +/** + * Close a modal + * @param {string} modalId - ID of the modal to close + */ +function closeModal(modalId) { + const modal = document.getElementById(modalId); + if (!modal) return; + + modal.classList.remove('show'); + + // Remove modal after animation + setTimeout(() => { + modal.remove(); + }, 300); +} + +/** + * Add a new RSS feed + */ +function addFeed() { + 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) { + showNotification('Name and URL are required!', 'warning'); + 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 + }]; + } + + setLoading(true); + + fetch('/api/rss/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(feed) + }) + .then(handleResponse) + .then(data => { + showNotification('Feed added successfully', 'success'); + closeModal('add-feed-modal'); + loadRssFeeds(); + }) + .catch(error => { + showNotification('Error adding feed: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Edit an existing RSS feed + * @param {string} feedId - ID of the feed to edit + */ +function editFeed(feedId) { + const feed = appState.feeds.find(f => f.id === feedId); + if (!feed) { + showNotification('Feed not found', 'danger'); + return; + } + + // Create modal with feed data + const modalHtml = ` + + `; + + // Add modal to the document + const modalContainer = document.createElement('div'); + modalContainer.innerHTML = modalHtml; + document.body.appendChild(modalContainer.firstChild); + + // Show the modal + setTimeout(() => { + document.getElementById('edit-feed-modal').classList.add('show'); + }, 10); + + // Add event listeners + document.getElementById('edit-feed-auto-download').addEventListener('change', function() { + document.getElementById('edit-filter-container').classList.toggle('d-none', !this.checked); + }); +} + +/** + * Update an RSS feed + */ +function updateFeed() { + const feedId = document.getElementById('edit-feed-id').value; + const name = document.getElementById('edit-feed-name').value.trim(); + const url = document.getElementById('edit-feed-url').value.trim(); + const autoDownload = document.getElementById('edit-feed-auto-download').checked; + + if (!name || !url) { + showNotification('Name and URL are required!', 'warning'); + return; + } + + // Create feed object + const feed = { + name, + url, + autoDownload + }; + + // Add filters if auto-download is enabled + if (autoDownload) { + const title = document.getElementById('edit-filter-title').value.trim(); + const category = document.getElementById('edit-filter-category').value.trim(); + const minSize = document.getElementById('edit-filter-min-size').value ? + parseInt(document.getElementById('edit-filter-min-size').value, 10) * 1024 * 1024 : null; + const maxSize = document.getElementById('edit-filter-max-size').value ? + parseInt(document.getElementById('edit-filter-max-size').value, 10) * 1024 * 1024 : null; + + feed.filters = [{ + title, + category, + minSize, + maxSize + }]; + } + + setLoading(true); + + fetch(`/api/rss/feeds/${feedId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(feed) + }) + .then(handleResponse) + .then(data => { + showNotification('Feed updated successfully', 'success'); + closeModal('edit-feed-modal'); + loadRssFeeds(); + }) + .catch(error => { + showNotification('Error updating feed: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Delete an RSS feed + * @param {string} feedId - ID of the feed to delete + */ +function deleteFeed(feedId) { + if (!confirm('Are you sure you want to delete this feed?')) { + return; + } + + setLoading(true); + + fetch(`/api/rss/feeds/${feedId}`, { + method: 'DELETE', + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + showNotification('Feed deleted successfully', 'success'); + loadRssFeeds(); + }) + .catch(error => { + showNotification('Error deleting feed: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update all RSS feeds + */ +function updateFeeds() { + setLoading(true); + + fetch('/api/rss/update', { + method: 'POST', + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + showNotification('RSS feeds updated successfully', 'success'); + loadRssItems(); + }) + .catch(error => { + showNotification('Error updating RSS feeds: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Load media library data + */ +function loadLibrary() { + setLoading(true); + + fetch('/api/media/library', { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + appState.library = data.data || {}; + updateLibraryDisplay(); + }) + .catch(error => { + showNotification('Error loading library: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update the library display with current data + */ +function updateLibraryDisplay() { + const libraryContainer = document.getElementById('library-container'); + + if (!libraryContainer) return; + + const selectedCategory = document.querySelector('input[name="library-filter"]:checked').value; + const categories = selectedCategory === 'all' ? Object.keys(appState.library) : [selectedCategory]; + + if (!appState.library || Object.keys(appState.library).every(key => !appState.library[key] || appState.library[key].length === 0)) { + libraryContainer.innerHTML = '

No media files in library.

'; + return; + } + + let html = ''; + + categories.forEach(category => { + const items = appState.library[category]; + + if (!items || items.length === 0) { + return; + } + + const displayName = getCategoryTitle(category); + + html += ` +
+
+

${displayName} ${items.length}

+
+
+
+ `; + + items.forEach(item => { + html += ` +
+
+
+
${item.name}
+

Added: ${new Date(item.added).toLocaleDateString()}

+
+ +
+
+ `; + }); + + html += ` +
+
+
+ `; + }); + + libraryContainer.innerHTML = html || '

No media files match the selected category.

'; +} + +/** + * Filter the library display based on selected category + */ +function filterLibrary() { + updateLibraryDisplay(); +} + +/** + * Search the library + * @param {Event} e - Input event + */ +function searchLibrary(e) { + const query = e.target.value.toLowerCase(); + + if (!query) { + // If search is cleared, show everything + updateLibraryDisplay(); + return; + } + + setLoading(true); + + fetch(`/api/media/library?query=${encodeURIComponent(query)}`, { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + // Temporarily update the library for display + const originalLibrary = appState.library; + appState.library = data.data || {}; + updateLibraryDisplay(); + // Restore original library data + appState.library = originalLibrary; + }) + .catch(error => { + showNotification('Error searching library: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Open a media file + * @param {string} path - Path to the media file + */ +function openMediaFile(path) { + // In a real application, this would open the file in a media player + // For now, we'll just open a notification + showNotification(`Opening file: ${path}`, 'info'); + + // In a real app, you might do: + // window.open(`/media/stream?path=${encodeURIComponent(path)}`, '_blank'); +} + +/** + * Load settings data from the server + */ +function loadSettings() { + setLoading(true); + + fetch('/api/config', { + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + appState.config = data; + updateSettingsForm(); + }) + .catch(error => { + showNotification('Error loading settings: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Update the settings form with current configuration + */ +function updateSettingsForm() { + const config = appState.config; + if (!config) return; + + // 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 || ''; + // Don't set password field for security + + // 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 || ''; + + // Path settings + 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 || ''; + + // Processing options + document.getElementById('extract-archives').checked = config.processingOptions?.extractArchives || false; + document.getElementById('delete-archives').checked = config.processingOptions?.deleteArchives || false; + document.getElementById('create-category-folders').checked = config.processingOptions?.createCategoryFolders || false; + document.getElementById('rename-files').checked = config.processingOptions?.renameFiles || false; + document.getElementById('ignore-sample').checked = config.processingOptions?.ignoreSample || false; + document.getElementById('ignore-extras').checked = config.processingOptions?.ignoreExtras || false; + + // RSS settings + document.getElementById('rss-interval').value = config.rssUpdateIntervalMinutes || ''; + + // Security settings if they exist + if (document.getElementById('https-enabled')) { + document.getElementById('https-enabled').checked = config.securitySettings?.httpsEnabled || false; + document.getElementById('auth-enabled').checked = config.securitySettings?.authEnabled || false; + } +} + +/** + * Save application settings + * @param {Event} e - Form submit event + */ +function saveSettings(e) { + e.preventDefault(); + + const settingsData = { + transmissionConfig: { + host: document.getElementById('transmission-host').value.trim(), + port: parseInt(document.getElementById('transmission-port').value.trim(), 10), + username: document.getElementById('transmission-user').value.trim() + }, + seedingRequirements: { + minRatio: parseFloat(document.getElementById('seeding-ratio').value.trim()), + minTimeMinutes: parseInt(document.getElementById('seeding-time').value.trim(), 10), + checkIntervalSeconds: parseInt(document.getElementById('check-interval').value.trim(), 10) + }, + destinationPaths: { + movies: document.getElementById('movies-path').value.trim(), + tvShows: document.getElementById('tvshows-path').value.trim(), + music: document.getElementById('music-path').value.trim(), + books: document.getElementById('books-path').value.trim(), + magazines: document.getElementById('magazines-path').value.trim(), + software: document.getElementById('software-path').value.trim() + }, + processingOptions: { + extractArchives: document.getElementById('extract-archives').checked, + deleteArchives: document.getElementById('delete-archives').checked, + createCategoryFolders: document.getElementById('create-category-folders').checked, + renameFiles: document.getElementById('rename-files').checked, + ignoreSample: document.getElementById('ignore-sample').checked, + ignoreExtras: document.getElementById('ignore-extras').checked + }, + rssUpdateIntervalMinutes: parseInt(document.getElementById('rss-interval').value.trim(), 10) + }; + + // Add password only if provided + const password = document.getElementById('transmission-pass').value.trim(); + if (password) { + settingsData.transmissionConfig.password = password; + } + + // Add security settings if they exist + if (document.getElementById('https-enabled')) { + settingsData.securitySettings = { + httpsEnabled: document.getElementById('https-enabled').checked, + authEnabled: document.getElementById('auth-enabled').checked + }; + } + + setLoading(true); + + fetch('/api/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(settingsData) + }) + .then(handleResponse) + .then(data => { + showNotification('Settings saved successfully', 'success'); + loadSettings(); // Reload settings to get the updated values + loadSystemStatus(); // Reload status to reflect any changes + }) + .catch(error => { + showNotification('Error saving settings: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Test the connection to the Transmission server + */ +function testTransmissionConnection() { + const host = document.getElementById('transmission-host').value.trim(); + const port = document.getElementById('transmission-port').value.trim(); + const username = document.getElementById('transmission-user').value.trim(); + const password = document.getElementById('transmission-pass').value.trim(); + + setLoading(true); + + fetch('/api/transmission/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify({ host, port, username, password }) + }) + .then(handleResponse) + .then(data => { + if (data.success) { + showNotification(`${data.message} (Version: ${data.data.version})`, 'success'); + } else { + showNotification(data.message, 'danger'); + } + }) + .catch(error => { + showNotification('Error testing connection: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Start the post-processor + */ +function startProcessor() { + setLoading(true); + + fetch('/api/post-processor/start', { + method: 'POST', + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + showNotification('Post-processor started successfully', 'success'); + loadSystemStatus(); + }) + .catch(error => { + showNotification('Error starting post-processor: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Stop the post-processor + */ +function stopProcessor() { + setLoading(true); + + fetch('/api/post-processor/stop', { + method: 'POST', + headers: authHeaders() + }) + .then(handleResponse) + .then(data => { + showNotification('Post-processor stopped successfully', 'success'); + loadSystemStatus(); + }) + .catch(error => { + showNotification('Error stopping post-processor: ' + error.message, 'danger'); + }) + .finally(() => { + setLoading(false); + }); +} + +/** + * Initialize the notifications system + */ +function initNotifications() { + // Create notifications container if it doesn't exist + if (!document.getElementById('notifications-container')) { + const container = document.createElement('div'); + container.id = 'notifications-container'; + container.style.position = 'fixed'; + container.style.top = '20px'; + container.style.right = '20px'; + container.style.zIndex = '1060'; + document.body.appendChild(container); + } +} + +/** + * Show a notification message + * @param {string} message - Message to display + * @param {string} type - Type of notification (success, danger, warning, info) + */ +function showNotification(message, type = 'info') { + const container = document.getElementById('notifications-container'); + + const notification = document.createElement('div'); + notification.className = `alert alert-${type}`; + notification.innerHTML = message; + notification.style.opacity = '0'; + notification.style.transform = 'translateY(-20px)'; + notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + + container.appendChild(notification); + + // Fade in + setTimeout(() => { + notification.style.opacity = '1'; + notification.style.transform = 'translateY(0)'; + }, 10); + + // Auto-remove after a delay + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateY(-20px)'; + + setTimeout(() => { + notification.remove(); + }, 300); + }, 5000); +} + +/** + * Get the title display name for a category + * @param {string} category - Category key + * @returns {string} - Formatted category title + */ +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); + } +} + +/** + * Get the status string for a torrent + * @param {Object} torrent - Torrent object + * @returns {string} - Status string + */ +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'; +} + +/** + * Format bytes to human-readable size + * @param {number} bytes - Size in bytes + * @param {number} decimals - Number of decimal places + * @returns {string} - Formatted size string + */ +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]; +} + +/** + * Set the loading state of the application + * @param {boolean} isLoading - Whether the app is loading + */ +function setLoading(isLoading) { + appState.isLoading = isLoading; + + // Update loading spinner if it exists + const spinner = document.getElementById('loading-spinner'); + if (spinner) { + spinner.style.display = isLoading ? 'block' : 'none'; + } +} + +/** + * Create auth headers including token if available + * @returns {Object} - Headers object + */ +function authHeaders() { + return appState.authToken ? { + 'Authorization': `Bearer ${appState.authToken}` + } : {}; +} + +/** + * Handle API response with error checking + * @param {Response} response - Fetch API response + * @returns {Promise} - Resolves to response data + */ +function handleResponse(response) { + if (!response.ok) { + // Try to get error message from response + return response.json() + .then(data => { + throw new Error(data.message || `HTTP error ${response.status}`); + }) + .catch(e => { + // If JSON parsing fails, throw generic error + if (e instanceof SyntaxError) { + throw new Error(`HTTP error ${response.status}`); + } + throw e; + }); + } + return response.json(); +} + +/** + * Create a debounced version of a function + * @param {Function} func - Function to debounce + * @param {number} wait - Milliseconds to wait + * @returns {Function} - Debounced function + */ +function debounce(func, wait) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; +} \ No newline at end of file diff --git a/public/js/utils.js b/public/js/utils.js new file mode 100644 index 0000000..8d7f27c --- /dev/null +++ b/public/js/utils.js @@ -0,0 +1,637 @@ +/** + * Utility functions for Transmission RSS Manager + */ + +/** + * Format a byte value to a human-readable string + * @param {number} bytes - Bytes to format + * @param {number} decimals - Number of decimal places to show + * @returns {string} - Formatted string (e.g., "1.5 MB") + */ +export 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]; +} + +/** + * Create a debounced version of a function + * @param {Function} func - Function to debounce + * @param {number} wait - Milliseconds to wait + * @returns {Function} - Debounced function + */ +export function debounce(func, wait) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; +} + +/** + * Create a throttled version of a function + * @param {Function} func - Function to throttle + * @param {number} limit - Milliseconds to throttle + * @returns {Function} - Throttled function + */ +export function throttle(func, limit) { + let inThrottle; + return function(...args) { + const context = this; + if (!inThrottle) { + func.apply(context, args); + inThrottle = true; + setTimeout(() => { inThrottle = false; }, limit); + } + }; +} + +/** + * Safely parse JSON with error handling + * @param {string} json - JSON string to parse + * @param {*} fallback - Fallback value if parsing fails + * @returns {*} - Parsed object or fallback + */ +export function safeJsonParse(json, fallback = {}) { + try { + return JSON.parse(json); + } catch (e) { + console.error('Error parsing JSON:', e); + return fallback; + } +} + +/** + * Escape HTML special characters + * @param {string} html - String potentially containing HTML + * @returns {string} - Escaped string + */ +export function escapeHtml(html) { + if (!html) return ''; + const entities = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' + }; + return String(html).replace(/[&<>"'/]/g, match => entities[match]); +} + +/** + * Get URL query parameters as an object + * @returns {Object} - Object containing query parameters + */ +export function getQueryParams() { + const params = {}; + new URLSearchParams(window.location.search).forEach((value, key) => { + params[key] = value; + }); + return params; +} + +/** + * Add query parameters to a URL + * @param {string} url - Base URL + * @param {Object} params - Parameters to add + * @returns {string} - URL with parameters + */ +export function addQueryParams(url, params) { + const urlObj = new URL(url, window.location.origin); + Object.keys(params).forEach(key => { + if (params[key] !== null && params[key] !== undefined) { + urlObj.searchParams.append(key, params[key]); + } + }); + return urlObj.toString(); +} + +/** + * Create a simple hash of a string + * @param {string} str - String to hash + * @returns {number} - Numeric hash + */ +export function simpleHash(str) { + let hash = 0; + if (str.length === 0) return hash; + + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + + return hash; +} + +/** + * Generate a random string of specified length + * @param {number} length - Length of the string + * @returns {string} - Random string + */ +export function randomString(length = 8) { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Format a date to a readable string + * @param {string|Date} date - Date to format + * @param {boolean} includeTime - Whether to include time + * @returns {string} - Formatted date string + */ +export function formatDate(date, includeTime = false) { + try { + const d = new Date(date); + const options = { + year: 'numeric', + month: 'short', + day: 'numeric', + ...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {}) + }; + return d.toLocaleDateString(undefined, options); + } catch (e) { + console.error('Error formatting date:', e); + return ''; + } +} + +/** + * Check if a date is today + * @param {string|Date} date - Date to check + * @returns {boolean} - True if date is today + */ +export function isToday(date) { + const d = new Date(date); + const today = new Date(); + return d.getDate() === today.getDate() && + d.getMonth() === today.getMonth() && + d.getFullYear() === today.getFullYear(); +} + +/** + * Get file extension from path + * @param {string} path - File path + * @returns {string} - File extension + */ +export function getFileExtension(path) { + if (!path) return ''; + return path.split('.').pop().toLowerCase(); +} + +/** + * Check if file is an image based on extension + * @param {string} path - File path + * @returns {boolean} - True if file is an image + */ +export function isImageFile(path) { + const ext = getFileExtension(path); + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext); +} + +/** + * Check if file is a video based on extension + * @param {string} path - File path + * @returns {boolean} - True if file is a video + */ +export function isVideoFile(path) { + const ext = getFileExtension(path); + return ['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv', 'm4v'].includes(ext); +} + +/** + * Check if file is an audio file based on extension + * @param {string} path - File path + * @returns {boolean} - True if file is audio + */ +export function isAudioFile(path) { + const ext = getFileExtension(path); + return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext); +} + +/** + * Extract base filename without extension + * @param {string} path - File path + * @returns {string} - Base filename + */ +export function getBaseName(path) { + if (!path) return ''; + const fileName = path.split('/').pop(); + return fileName.substring(0, fileName.lastIndexOf('.')) || fileName; +} + +/** + * Copy text to clipboard + * @param {string} text - Text to copy + * @returns {Promise} - Success status + */ +export async function copyToClipboard(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.error('Failed to copy text: ', err); + return false; + } +} + +/** + * Download data as a file + * @param {string} content - Content to download + * @param {string} fileName - Name of the file + * @param {string} contentType - MIME type of the file + */ +export function downloadFile(content, fileName, contentType = 'text/plain') { + const a = document.createElement('a'); + const file = new Blob([content], { type: contentType }); + a.href = URL.createObjectURL(file); + a.download = fileName; + a.click(); + URL.revokeObjectURL(a.href); +} + +/** + * Sort array of objects by a property + * @param {Array} array - Array to sort + * @param {string} property - Property to sort by + * @param {boolean} ascending - Sort direction + * @returns {Array} - Sorted array + */ +export function sortArrayByProperty(array, property, ascending = true) { + const sortFactor = ascending ? 1 : -1; + return [...array].sort((a, b) => { + if (a[property] < b[property]) return -1 * sortFactor; + if (a[property] > b[property]) return 1 * sortFactor; + return 0; + }); +} + +/** + * Filter array by a search term across multiple properties + * @param {Array} array - Array to filter + * @param {string} search - Search term + * @param {Array} properties - Properties to search in + * @returns {Array} - Filtered array + */ +export function filterArrayBySearch(array, search, properties) { + if (!search || !properties || properties.length === 0) return array; + + const term = search.toLowerCase(); + return array.filter(item => { + return properties.some(prop => { + const value = item[prop]; + if (typeof value === 'string') { + return value.toLowerCase().includes(term); + } + return false; + }); + }); +} + +/** + * Deep clone an object + * @param {Object} obj - Object to clone + * @returns {Object} - Cloned object + */ +export function deepClone(obj) { + if (!obj) return obj; + return JSON.parse(JSON.stringify(obj)); +} + +/** + * Get readable torrent status + * @param {number} status - Transmission status code + * @returns {string} - Human-readable status + */ +export function getTorrentStatus(status) { + const statusMap = { + 0: 'Stopped', + 1: 'Check Waiting', + 2: 'Checking', + 3: 'Download Waiting', + 4: 'Downloading', + 5: 'Seed Waiting', + 6: 'Seeding' + }; + + return statusMap[status] || 'Unknown'; +} + +/** + * Get appropriate CSS class for a torrent status badge + * @param {number} status - Torrent status code + * @returns {string} - CSS class + */ +export function getBadgeClassForStatus(status) { + switch (status) { + case 0: return 'badge-danger'; // Stopped + case 1: case 2: case 3: return 'badge-warning'; // Checking/Waiting + case 4: return 'badge-primary'; // Downloading + case 5: case 6: return 'badge-success'; // Seeding + default: return 'badge-secondary'; + } +} + +/** + * Get appropriate CSS class for a torrent progress bar + * @param {number} status - Torrent status code + * @returns {string} - CSS class + */ +export function getProgressBarClassForStatus(status) { + switch (status) { + case 0: return 'bg-danger'; // Stopped + case 4: return 'bg-primary'; // Downloading + case 5: case 6: return 'bg-success'; // Seeding + default: return ''; + } +} + +/** + * Get cookie value by name + * @param {string} name - Cookie name + * @returns {string|null} - Cookie value or null + */ +export function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} + +/** + * Set a cookie + * @param {string} name - Cookie name + * @param {string} value - Cookie value + * @param {number} days - Days until expiry + */ +export function setCookie(name, value, days = 30) { + const date = new Date(); + date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + const expires = `expires=${date.toUTCString()}`; + document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`; +} + +/** + * Delete a cookie + * @param {string} name - Cookie name + */ +export function deleteCookie(name) { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Strict`; +} + +/** + * Handle common API response with error checking + * @param {Response} response - Fetch API response + * @returns {Promise} - Resolves to response data + */ +export function handleApiResponse(response) { + if (!response.ok) { + // Try to get error message from response + return response.json() + .then(data => { + throw new Error(data.message || `HTTP error ${response.status}`); + }) + .catch(e => { + // If JSON parsing fails, throw generic error + if (e instanceof SyntaxError) { + throw new Error(`HTTP error ${response.status}`); + } + throw e; + }); + } + return response.json(); +} + +/** + * Encrypt a string using AES (for client-side only, not secure) + * @param {string} text - Text to encrypt + * @param {string} key - Encryption key + * @returns {string} - Encrypted text + */ +export function encrypt(text, key) { + // This is a simple XOR "encryption" - NOT SECURE! + // Only for basic obfuscation + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + } + return btoa(result); // Base64 encode +} + +/** + * Decrypt a string encrypted with the encrypt function + * @param {string} encrypted - Encrypted text + * @param {string} key - Encryption key + * @returns {string} - Decrypted text + */ +export function decrypt(encrypted, key) { + try { + const text = atob(encrypted); // Base64 decode + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length)); + } + return result; + } catch (e) { + console.error('Decryption error:', e); + return ''; + } +} + +/** + * Get the title display name for a media category + * @param {string} category - Category key + * @returns {string} - Formatted category title + */ +export 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); + } +} + +/** + * Wait for an element to exist in the DOM + * @param {string} selector - CSS selector + * @param {number} timeout - Timeout in milliseconds + * @returns {Promise} - Element when found + */ +export function waitForElement(selector, timeout = 5000) { + return new Promise((resolve, reject) => { + const element = document.querySelector(selector); + if (element) return resolve(element); + + const observer = new MutationObserver((mutations) => { + const element = document.querySelector(selector); + if (element) { + observer.disconnect(); + resolve(element); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + setTimeout(() => { + observer.disconnect(); + reject(new Error(`Element ${selector} not found within ${timeout}ms`)); + }, timeout); + }); +} + +/** + * Create a notification message + * @param {string} message - Message to display + * @param {string} type - Type of notification (success, danger, warning, info) + * @param {number} duration - Display duration in milliseconds + */ +export function showNotification(message, type = 'info', duration = 5000) { + // Create notifications container if it doesn't exist + let container = document.getElementById('notifications-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'notifications-container'; + container.style.position = 'fixed'; + container.style.top = '20px'; + container.style.right = '20px'; + container.style.zIndex = '1060'; + document.body.appendChild(container); + } + + // Create notification element + const notification = document.createElement('div'); + notification.className = `alert alert-${type}`; + notification.innerHTML = message; + notification.style.opacity = '0'; + notification.style.transform = 'translateY(-20px)'; + notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + + container.appendChild(notification); + + // Fade in + setTimeout(() => { + notification.style.opacity = '1'; + notification.style.transform = 'translateY(0)'; + }, 10); + + // Auto-remove after the specified duration + setTimeout(() => { + notification.style.opacity = '0'; + notification.style.transform = 'translateY(-20px)'; + + setTimeout(() => { + notification.remove(); + }, 300); + }, duration); +} + +/** + * Create authorization headers for API requests + * @param {string} token - Auth token + * @returns {Object} - Headers object + */ +export function createAuthHeaders(token) { + return token ? { + 'Authorization': `Bearer ${token}` + } : {}; +} + +/** + * Validate common input types + */ +export const validator = { + /** + * Validate email + * @param {string} email - Email to validate + * @returns {boolean} - True if valid + */ + isEmail: (email) => { + const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(String(email).toLowerCase()); + }, + + /** + * Validate URL + * @param {string} url - URL to validate + * @returns {boolean} - True if valid + */ + isUrl: (url) => { + try { + new URL(url); + return true; + } catch (e) { + return false; + } + }, + + /** + * Validate number + * @param {string|number} value - Value to validate + * @returns {boolean} - True if valid + */ + isNumeric: (value) => { + return !isNaN(parseFloat(value)) && isFinite(value); + }, + + /** + * Validate field is not empty + * @param {string} value - Value to validate + * @returns {boolean} - True if not empty + */ + isRequired: (value) => { + return value !== null && value !== undefined && value !== ''; + }, + + /** + * Validate file path + * @param {string} path - Path to validate + * @returns {boolean} - True if valid + */ + isValidPath: (path) => { + // Simple path validation - should start with / for Unix-like systems + return /^(\/[\w.-]+)+\/?$/.test(path); + }, + + /** + * Validate password complexity + * @param {string} password - Password to validate + * @returns {boolean} - True if valid + */ + isStrongPassword: (password) => { + return password && password.length >= 8 && + /[A-Z]/.test(password) && + /[a-z]/.test(password) && + /[0-9]/.test(password); + }, + + /** + * Validate a value is in range + * @param {number} value - Value to validate + * @param {number} min - Minimum value + * @param {number} max - Maximum value + * @returns {boolean} - True if in range + */ + isInRange: (value, min, max) => { + const num = parseFloat(value); + return !isNaN(num) && num >= min && num <= max; + } +}; \ No newline at end of file diff --git a/scripts/test-and-start.sh b/scripts/test-and-start.sh new file mode 100755 index 0000000..3246812 --- /dev/null +++ b/scripts/test-and-start.sh @@ -0,0 +1,165 @@ +#!/bin/bash +# Test and start script for Transmission RSS Manager +# This script checks the installation, dependencies, and starts the application + +# Text formatting +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Get directory of this script +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +APP_DIR="$(dirname "$SCRIPT_DIR")" + +# Function to check if a command exists +command_exists() { + command -v "$1" &> /dev/null +} + +# Check Node.js and npm +check_node() { + echo -e "${BOLD}Checking Node.js and npm...${NC}" + + if command_exists node; then + NODE_VERSION=$(node -v) + echo -e "${GREEN}Node.js is installed: $NODE_VERSION${NC}" + else + echo -e "${RED}Node.js is not installed. Please install Node.js 14 or later.${NC}" + exit 1 + fi + + if command_exists npm; then + NPM_VERSION=$(npm -v) + echo -e "${GREEN}npm is installed: $NPM_VERSION${NC}" + else + echo -e "${RED}npm is not installed. Please install npm.${NC}" + exit 1 + fi +} + +# Check if Transmission is running +check_transmission() { + echo -e "${BOLD}Checking Transmission...${NC}" + + # Try to get the status of the transmission-daemon service + if command_exists systemctl; then + if systemctl is-active --quiet transmission-daemon; then + echo -e "${GREEN}Transmission daemon is running${NC}" + else + echo -e "${YELLOW}Warning: Transmission daemon does not appear to be running${NC}" + echo -e "${YELLOW}You may need to start it with: sudo systemctl start transmission-daemon${NC}" + fi + else + # Try a different method if systemctl is not available + if pgrep -x "transmission-daemon" > /dev/null; then + echo -e "${GREEN}Transmission daemon is running${NC}" + else + echo -e "${YELLOW}Warning: Transmission daemon does not appear to be running${NC}" + echo -e "${YELLOW}Please start Transmission daemon before using this application${NC}" + fi + fi +} + +# Check dependencies in package.json +check_dependencies() { + echo -e "${BOLD}Checking dependencies...${NC}" + + # Check if node_modules exists + if [ ! -d "$APP_DIR/node_modules" ]; then + echo -e "${YELLOW}Node modules not found. Installing dependencies...${NC}" + cd "$APP_DIR" && npm install + + if [ $? -ne 0 ]; then + echo -e "${RED}Failed to install dependencies.${NC}" + exit 1 + else + echo -e "${GREEN}Dependencies installed successfully${NC}" + fi + else + echo -e "${GREEN}Dependencies are already installed${NC}" + fi +} + +# Check if config.json exists +check_config() { + echo -e "${BOLD}Checking configuration...${NC}" + + if [ ! -f "$APP_DIR/config.json" ]; then + echo -e "${RED}Configuration file not found: $APP_DIR/config.json${NC}" + echo -e "${YELLOW}Please run the installer or create a config.json file${NC}" + exit 1 + else + echo -e "${GREEN}Configuration file found${NC}" + fi +} + +# Start the application +start_app() { + echo -e "${BOLD}Starting Transmission RSS Manager...${NC}" + + # Check if running as a service + if command_exists systemctl; then + if systemctl is-active --quiet transmission-rss-manager; then + echo -e "${YELLOW}Transmission RSS Manager is already running as a service${NC}" + echo -e "${YELLOW}To restart it, use: sudo systemctl restart transmission-rss-manager${NC}" + exit 0 + fi + fi + + # Start the application + cd "$APP_DIR" + + # Parse arguments + FOREGROUND=false + DEBUG=false + + while [[ "$#" -gt 0 ]]; do + case $1 in + --foreground|-f) FOREGROUND=true ;; + --debug|-d) DEBUG=true ;; + *) echo "Unknown parameter: $1"; exit 1 ;; + esac + shift + done + + if [ "$FOREGROUND" = true ]; then + echo -e "${GREEN}Starting in foreground mode...${NC}" + + if [ "$DEBUG" = true ]; then + echo -e "${YELLOW}Debug mode enabled${NC}" + DEBUG_ENABLED=true node server.js + else + node server.js + fi + else + echo -e "${GREEN}Starting in background mode...${NC}" + + if [ "$DEBUG" = true ]; then + echo -e "${YELLOW}Debug mode enabled${NC}" + DEBUG_ENABLED=true nohup node server.js > logs/output.log 2>&1 & + else + nohup node server.js > logs/output.log 2>&1 & + fi + + echo $! > "$APP_DIR/transmission-rss-manager.pid" + echo -e "${GREEN}Application started with PID: $!${NC}" + echo -e "${GREEN}Logs available at: $APP_DIR/logs/output.log${NC}" + fi +} + +# Main script +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Transmission RSS Manager - Test & Start ${NC}" +echo -e "${BOLD}==================================================${NC}" +echo + +# Run checks +check_node +check_transmission +check_dependencies +check_config + +# Start the application +start_app "$@" \ No newline at end of file diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..b5beca6 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,178 @@ +#!/bin/bash +# Update script for Transmission RSS Manager + +# Text formatting +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +APP_DIR="$(dirname "$SCRIPT_DIR")" + +# 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 + +# Print header +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Transmission RSS Manager Updater ${NC}" +echo -e "${BOLD} Version 1.2.0 ${NC}" +echo -e "${BOLD}==================================================${NC}" +echo + +# Function to check if a service is running +service_is_running() { + systemctl is-active --quiet "$1" + return $? +} + +# Backup existing files +backup_app() { + echo -e "${BOLD}Backing up existing installation...${NC}" + + TIMESTAMP=$(date +%Y%m%d%H%M%S) + BACKUP_DIR="${APP_DIR}_backup_${TIMESTAMP}" + + # Create backup directory + mkdir -p "$BACKUP_DIR" + + # Copy files to backup directory + cp -rf "$APP_DIR"/* "$BACKUP_DIR" + + echo -e "${GREEN}Backup created at: $BACKUP_DIR${NC}" +} + +# Update the application +update_app() { + echo -e "${BOLD}Updating application...${NC}" + + # Get user account that owns the files + APP_USER=$(stat -c '%U' "$APP_DIR") + + # Check if app is running as a service + WAS_RUNNING=false + if service_is_running transmission-rss-manager; then + WAS_RUNNING=true + echo -e "${YELLOW}Stopping service during update...${NC}" + systemctl stop transmission-rss-manager + fi + + # Set environment variable to indicate it's an update + export IS_UPDATE=true + + # Backup config files before update + if [ -f "$APP_DIR/config.json" ]; then + echo -e "${YELLOW}Backing up configuration file...${NC}" + CONFIG_BACKUP="${APP_DIR}/config.json.bak.$(date +%Y%m%d%H%M%S)" + cp "$APP_DIR/config.json" "$CONFIG_BACKUP" + echo -e "${GREEN}Configuration backed up to $CONFIG_BACKUP${NC}" + fi + + # Update npm dependencies + cd "$APP_DIR" + echo -e "${YELLOW}Updating dependencies...${NC}" + npm install + + # Fix permissions + chown -R $APP_USER:$APP_USER "$APP_DIR" + + # Check if update script was successful + UPDATE_SUCCESS=true + + # Restart service if it was running before + if [ "$WAS_RUNNING" = true ]; then + echo -e "${YELLOW}Restarting service...${NC}" + systemctl daemon-reload + systemctl start transmission-rss-manager + + # Check if service started successfully + if service_is_running transmission-rss-manager; then + echo -e "${GREEN}Service restarted successfully.${NC}" + else + echo -e "${RED}Failed to restart service. Check logs with: journalctl -u transmission-rss-manager${NC}" + UPDATE_SUCCESS=false + fi + else + echo -e "${YELLOW}Service was not running before update. Not restarting.${NC}" + fi + + # Provide info about configuration changes + if [ -f "$APP_DIR/config.json" ]; then + # Check if the configuration was updated by the service + if [ $(stat -c %Y "$APP_DIR/config.json") -gt $(stat -c %Y "$CONFIG_BACKUP") ]; then + echo -e "${GREEN}Configuration updated successfully with new options.${NC}" + echo -e "${YELLOW}Your existing settings have been preserved.${NC}" + else + echo -e "${YELLOW}Configuration was not modified during update.${NC}" + echo -e "${YELLOW}If you experience issues, check for new configuration options.${NC}" + fi + fi + + if [ "$UPDATE_SUCCESS" = true ]; then + echo -e "${GREEN}Update completed successfully.${NC}" + else + echo -e "${RED}Update completed with some issues.${NC}" + echo -e "${YELLOW}If needed, you can restore configuration from: $CONFIG_BACKUP${NC}" + fi +} + +# Check for updates in Git repository +check_git_updates() { + echo -e "${BOLD}Checking for updates in Git repository...${NC}" + + # Check if git is installed + if ! command -v git &> /dev/null; then + echo -e "${YELLOW}Git is not installed, skipping Git update check.${NC}" + return 1 + fi + + # Check if app directory is a git repository + if [ ! -d "$APP_DIR/.git" ]; then + echo -e "${YELLOW}Not a Git repository, skipping Git update check.${NC}" + return 1 + fi + + # Check for updates + cd "$APP_DIR" + git fetch + + # Check if we're behind the remote + BEHIND=$(git rev-list HEAD..origin/main --count) + if [ "$BEHIND" -gt 0 ]; then + echo -e "${GREEN}Updates available: $BEHIND new commit(s)${NC}" + + # Confirm update + read -p "Do you want to pull the latest changes? (y/n) [y]: " CONFIRM + CONFIRM=${CONFIRM:-y} + + if [[ $CONFIRM =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}Pulling latest changes...${NC}" + git pull + return 0 + else + echo -e "${YELLOW}Skipping Git update.${NC}" + return 1 + fi + else + echo -e "${GREEN}Already up to date.${NC}" + return 1 + fi +} + +# Main update process +backup_app +if check_git_updates || [ "$1" = "--force" ]; then + update_app +else + echo -e "${YELLOW}No updates needed or available.${NC}" + echo -e "${YELLOW}Use --force flag to update dependencies anyway.${NC}" +fi + +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Update process completed ${NC}" +echo -e "${BOLD}==================================================${NC}" \ No newline at end of file diff --git a/server.js b/server.js new file mode 100644 index 0000000..8b0b550 --- /dev/null +++ b/server.js @@ -0,0 +1,1070 @@ +/** + * Transmission RSS Manager + * Main Server Application + */ + +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const bodyParser = require('body-parser'); +const cors = require('cors'); +const morgan = require('morgan'); +const http = require('http'); +const https = require('https'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcrypt'); + +// Import custom modules +const RssFeedManager = require('./modules/rss-feed-manager'); +const TransmissionClient = require('./modules/transmission-client'); +const PostProcessor = require('./modules/post-processor'); + +// Constants and configuration +const DEFAULT_CONFIG_PATH = path.join(__dirname, 'config.json'); +const DEFAULT_PORT = 3000; +const JWT_SECRET = process.env.JWT_SECRET || 'transmission-rss-manager-secret'; +const JWT_EXPIRY = '24h'; + +// Create Express app +const app = express(); + +// Middleware +app.use(cors()); +app.use(bodyParser.json()); +app.use(morgan('combined')); +app.use(express.static(path.join(__dirname, 'public'))); + +// Application state +let config = null; +let transmissionClient = null; +let rssFeedManager = null; +let postProcessor = null; +let isInitialized = false; +let server = null; + +/** + * Initialize the application with configuration + */ +async function initializeApp() { + try { + // Load configuration + config = await loadConfig(); + console.log('Configuration loaded'); + + // Initialize transmission client + transmissionClient = new TransmissionClient(config); + console.log('Transmission client initialized'); + + // Initialize RSS feed manager + rssFeedManager = new RssFeedManager(config); + await rssFeedManager.start(); + console.log('RSS feed manager started'); + + // Initialize post processor + postProcessor = new PostProcessor(config, transmissionClient); + + // Start post processor if auto-processing is enabled + if (config.autoProcessing) { + postProcessor.start(); + console.log('Post-processor auto-started'); + } else { + console.log('Post-processor not auto-starting (autoProcessing disabled)'); + } + + isInitialized = true; + console.log('Application initialized successfully'); + } catch (error) { + console.error('Failed to initialize application:', error); + throw error; + } +} + +/** + * Load configuration from file + * @returns {Promise} Configuration object + */ +async function loadConfig() { + try { + // Define default configuration + const defaultConfig = { + version: '1.2.0', + 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, + securitySettings: { + authEnabled: false, + httpsEnabled: false, + sslCertPath: "", + sslKeyPath: "", + users: [] + }, + port: DEFAULT_PORT, + logLevel: "info" + }; + + try { + // Try to read existing config + const configData = await fs.readFile(DEFAULT_CONFIG_PATH, 'utf8'); + const loadedConfig = JSON.parse(configData); + + // Use recursive merge function to merge configs + const mergedConfig = mergeConfigs(defaultConfig, loadedConfig); + + // If version is different, save updated config + if (loadedConfig.version !== defaultConfig.version) { + console.log(`Updating config from version ${loadedConfig.version || 'unknown'} to ${defaultConfig.version}`); + mergedConfig.version = defaultConfig.version; + await saveConfig(mergedConfig); + } + + return mergedConfig; + } catch (readError) { + // If file not found, create default config + if (readError.code === 'ENOENT') { + console.log('Config file not found, creating default configuration'); + await saveConfig(defaultConfig); + return defaultConfig; + } + + // For other errors, rethrow + throw readError; + } + } catch (error) { + console.error('Error loading configuration:', error); + throw error; + } +} + +/** + * Recursively merge configurations + * @param {Object} defaultConfig - Default configuration object + * @param {Object} userConfig - User configuration object + * @returns {Object} Merged configuration object + */ +function mergeConfigs(defaultConfig, userConfig) { + // Create a new object to avoid modifying the originals + const merged = {}; + + // Add all properties from default config + for (const key in defaultConfig) { + // If property exists in user config + if (key in userConfig) { + // If both are objects (not arrays or null), recursively merge + if ( + typeof defaultConfig[key] === 'object' && + defaultConfig[key] !== null && + !Array.isArray(defaultConfig[key]) && + typeof userConfig[key] === 'object' && + userConfig[key] !== null && + !Array.isArray(userConfig[key]) + ) { + merged[key] = mergeConfigs(defaultConfig[key], userConfig[key]); + } else { + // Use user value + merged[key] = userConfig[key]; + } + } else { + // Property doesn't exist in user config, use default + merged[key] = defaultConfig[key]; + } + } + + // Add any properties from user config that aren't in default + for (const key in userConfig) { + if (!(key in defaultConfig)) { + merged[key] = userConfig[key]; + } + } + + return merged; +} + +/** + * Save configuration to file + * @param {Object} config - Configuration object + * @returns {Promise} + */ +async function saveConfig(config) { + try { + await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + console.log('Configuration saved'); + } catch (error) { + console.error('Error saving configuration:', error); + throw error; + } +} + +/** + * Start the server + */ +async function startServer() { + try { + // Initialize the application + await initializeApp(); + + // Determine port + const port = config.port || process.env.PORT || DEFAULT_PORT; + + // Create server based on configuration + if (config.securitySettings?.httpsEnabled && + config.securitySettings?.sslCertPath && + config.securitySettings?.sslKeyPath) { + try { + const sslOptions = { + key: await fs.readFile(config.securitySettings.sslKeyPath), + cert: await fs.readFile(config.securitySettings.sslCertPath) + }; + + server = https.createServer(sslOptions, app); + console.log('HTTPS server created'); + } catch (error) { + console.error('Error creating HTTPS server, falling back to HTTP:', error); + server = http.createServer(app); + } + } else { + server = http.createServer(app); + console.log('HTTP server created'); + } + + // Start the server + server.listen(port, () => { + console.log(`Transmission RSS Manager server running on port ${port}`); + }); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Authentication middleware +function authenticateJWT(req, res, next) { + // Skip authentication if not enabled + if (!config.securitySettings?.authEnabled) { + return next(); + } + + // Get token from header + const authHeader = req.headers.authorization; + if (!authHeader) { + return res.status(401).json({ success: false, message: 'Authentication required' }); + } + + const token = authHeader.split(' ')[1]; + if (!token) { + return res.status(401).json({ success: false, message: 'Authentication token required' }); + } + + // Verify token + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ success: false, message: 'Invalid or expired token' }); + } + + req.user = user; + next(); + }); +} + +// API Routes + +// Status endpoint +app.get('/api/status', authenticateJWT, async (req, res) => { + try { + if (!isInitialized) { + return res.status(503).json({ + success: false, + message: 'Application is initializing, please try again' + }); + } + + // Get Transmission connection status + const transmissionStatus = await transmissionClient.getStatus(); + + // Return status information + res.json({ + success: true, + status: 'running', + version: '1.2.0', + transmissionConnected: transmissionStatus.connected, + transmissionVersion: transmissionStatus.version, + transmissionStats: { + downloadSpeed: transmissionStatus.downloadSpeed, + uploadSpeed: transmissionStatus.uploadSpeed, + activeTorrentCount: transmissionStatus.activeTorrentCount, + torrentCount: transmissionStatus.torrentCount + }, + rssFeedManagerActive: !!rssFeedManager.updateIntervalId, + postProcessorActive: !!postProcessor.processIntervalId, + config: { + autoProcessing: config.autoProcessing, + rssEnabled: Array.isArray(config.rssFeeds) && config.rssFeeds.length > 0 + } + }); + } catch (error) { + console.error('Error getting status:', error); + res.status(500).json({ + success: false, + message: `Error getting status: ${error.message}` + }); + } +}); + +// Configuration endpoint +app.get('/api/config', authenticateJWT, (req, res) => { + // Return configuration (remove sensitive information) + const sanitizedConfig = { ...config }; + + // Remove passwords from response + if (sanitizedConfig.transmissionConfig) { + delete sanitizedConfig.transmissionConfig.password; + } + + if (sanitizedConfig.securitySettings?.users) { + sanitizedConfig.securitySettings.users = sanitizedConfig.securitySettings.users.map(user => ({ + ...user, + password: undefined + })); + } + + res.json(sanitizedConfig); +}); + +app.post('/api/config', authenticateJWT, async (req, res) => { + try { + // Merge the new config with the existing one + const newConfig = { ...config, ...req.body }; + + // Keep passwords if they're not provided + if (newConfig.transmissionConfig && !newConfig.transmissionConfig.password && config.transmissionConfig) { + newConfig.transmissionConfig.password = config.transmissionConfig.password; + } + + // Keep user passwords + if (newConfig.securitySettings?.users && config.securitySettings?.users) { + newConfig.securitySettings.users = newConfig.securitySettings.users.map(newUser => { + const existingUser = config.securitySettings.users.find(u => u.username === newUser.username); + + if (existingUser && !newUser.password) { + return { ...newUser, password: existingUser.password }; + } + + return newUser; + }); + } + + // Save the updated config + await saveConfig(newConfig); + + // Update the application state + config = newConfig; + + // Restart components if necessary + if (rssFeedManager) { + rssFeedManager.stop(); + rssFeedManager = new RssFeedManager(config); + await rssFeedManager.start(); + } + + if (transmissionClient) { + transmissionClient = new TransmissionClient(config); + } + + if (postProcessor) { + postProcessor.stop(); + postProcessor = new PostProcessor(config, transmissionClient); + + if (config.autoProcessing) { + postProcessor.start(); + } + } + + res.json({ + success: true, + message: 'Configuration updated successfully' + }); + } catch (error) { + console.error('Error updating configuration:', error); + res.status(500).json({ + success: false, + message: `Error updating configuration: ${error.message}` + }); + } +}); + +// RSS Feed API +app.get('/api/rss/feeds', authenticateJWT, (req, res) => { + try { + const feeds = rssFeedManager.getAllFeeds(); + res.json({ + success: true, + data: feeds + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error getting RSS feeds: ${error.message}` + }); + } +}); + +app.post('/api/rss/feeds', authenticateJWT, async (req, res) => { + try { + const feed = req.body; + const newFeed = rssFeedManager.addFeed(feed); + + res.json({ + success: true, + message: 'RSS feed added successfully', + data: newFeed + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error adding RSS feed: ${error.message}` + }); + } +}); + +app.put('/api/rss/feeds/:id', authenticateJWT, async (req, res) => { + try { + const feedId = req.params.id; + const updates = req.body; + + const result = rssFeedManager.updateFeedConfig(feedId, updates); + + if (result) { + res.json({ + success: true, + message: 'RSS feed updated successfully' + }); + } else { + res.status(404).json({ + success: false, + message: 'RSS feed not found' + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error updating RSS feed: ${error.message}` + }); + } +}); + +app.delete('/api/rss/feeds/:id', authenticateJWT, async (req, res) => { + try { + const feedId = req.params.id; + const result = rssFeedManager.removeFeed(feedId); + + if (result) { + res.json({ + success: true, + message: 'RSS feed deleted successfully' + }); + } else { + res.status(404).json({ + success: false, + message: 'RSS feed not found' + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error deleting RSS feed: ${error.message}` + }); + } +}); + +app.get('/api/rss/items', authenticateJWT, (req, res) => { + try { + let items; + const filter = req.query.filter; + + if (filter === 'undownloaded') { + items = rssFeedManager.getUndownloadedItems(); + } else { + items = rssFeedManager.getAllItems(); + } + + res.json({ + success: true, + data: items + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error getting RSS items: ${error.message}` + }); + } +}); + +app.post('/api/rss/download', authenticateJWT, async (req, res) => { + try { + const { itemId } = req.body; + + if (!itemId) { + return res.status(400).json({ + success: false, + message: 'Item ID is required' + }); + } + + // Find the item + const items = rssFeedManager.getAllItems(); + const item = items.find(i => i.id === itemId); + + if (!item) { + return res.status(404).json({ + success: false, + message: 'Item not found' + }); + } + + // Download the item + const result = await rssFeedManager.downloadItem(item, transmissionClient.client); + + if (result.success) { + res.json({ + success: true, + message: 'Item added to Transmission', + data: result.result + }); + } else { + res.status(500).json({ + success: false, + message: result.message + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error downloading item: ${error.message}` + }); + } +}); + +app.post('/api/rss/update', authenticateJWT, async (req, res) => { + try { + const result = await rssFeedManager.updateAllFeeds(); + + res.json({ + success: true, + message: 'RSS feeds updated successfully', + data: result + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error updating RSS feeds: ${error.message}` + }); + } +}); + +// Transmission API +app.get('/api/transmission/torrents', authenticateJWT, async (req, res) => { + try { + const result = await transmissionClient.getTorrents(); + + if (result.success) { + res.json({ + success: true, + data: result.torrents + }); + } else { + res.status(500).json({ + success: false, + message: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error getting torrents: ${error.message}` + }); + } +}); + +app.post('/api/transmission/add', authenticateJWT, async (req, res) => { + try { + const { url } = req.body; + + if (!url) { + return res.status(400).json({ + success: false, + message: 'URL is required' + }); + } + + const result = await transmissionClient.addTorrent(url); + + if (result.success) { + res.json({ + success: true, + message: 'Torrent added successfully', + data: result + }); + } else { + res.status(500).json({ + success: false, + message: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error adding torrent: ${error.message}` + }); + } +}); + +app.post('/api/transmission/start', authenticateJWT, async (req, res) => { + try { + const { ids } = req.body; + + if (!ids) { + return res.status(400).json({ + success: false, + message: 'Torrent ID(s) required' + }); + } + + const result = await transmissionClient.startTorrents(ids); + + if (result.success) { + res.json({ + success: true, + message: result.message + }); + } else { + res.status(500).json({ + success: false, + message: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error starting torrent: ${error.message}` + }); + } +}); + +app.post('/api/transmission/stop', authenticateJWT, async (req, res) => { + try { + const { ids } = req.body; + + if (!ids) { + return res.status(400).json({ + success: false, + message: 'Torrent ID(s) required' + }); + } + + const result = await transmissionClient.stopTorrents(ids); + + if (result.success) { + res.json({ + success: true, + message: result.message + }); + } else { + res.status(500).json({ + success: false, + message: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error stopping torrent: ${error.message}` + }); + } +}); + +app.post('/api/transmission/remove', authenticateJWT, async (req, res) => { + try { + const { ids, deleteLocalData } = req.body; + + if (!ids) { + return res.status(400).json({ + success: false, + message: 'Torrent ID(s) required' + }); + } + + const result = await transmissionClient.removeTorrents(ids, deleteLocalData); + + if (result.success) { + res.json({ + success: true, + message: result.message + }); + } else { + res.status(500).json({ + success: false, + message: result.error + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error removing torrent: ${error.message}` + }); + } +}); + +app.post('/api/transmission/test', authenticateJWT, async (req, res) => { + try { + const { host, port, username, password } = req.body; + + // Create a temporary client for testing + const testConfig = { + transmissionConfig: { + host: host || config.transmissionConfig.host, + port: port || config.transmissionConfig.port, + username: username || config.transmissionConfig.username, + password: password || config.transmissionConfig.password, + path: config.transmissionConfig.path + } + }; + + const testClient = new TransmissionClient(testConfig); + const status = await testClient.getStatus(); + + if (status.connected) { + res.json({ + success: true, + message: 'Successfully connected to Transmission server', + data: { + version: status.version, + rpcVersion: status.rpcVersion + } + }); + } else { + res.status(400).json({ + success: false, + message: `Failed to connect: ${status.error}` + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Connection test failed: ${error.message}` + }); + } +}); + +// Post-Processor API +app.post('/api/post-processor/start', authenticateJWT, (req, res) => { + try { + const result = postProcessor.start(); + + if (result) { + res.json({ + success: true, + message: 'Post-processor started successfully' + }); + } else { + res.status(400).json({ + success: false, + message: 'Post-processor is already running' + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error starting post-processor: ${error.message}` + }); + } +}); + +app.post('/api/post-processor/stop', authenticateJWT, (req, res) => { + try { + const result = postProcessor.stop(); + + if (result) { + res.json({ + success: true, + message: 'Post-processor stopped successfully' + }); + } else { + res.status(400).json({ + success: false, + message: 'Post-processor is not running' + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: `Error stopping post-processor: ${error.message}` + }); + } +}); + +// Media Library API +app.get('/api/media/library', authenticateJWT, async (req, res) => { + try { + const query = req.query.query; + const library = await getMediaLibrary(query); + + res.json({ + success: true, + data: library + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error getting media library: ${error.message}` + }); + } +}); + +/** + * Get media library content + * @param {string} searchQuery - Optional search query + * @returns {Promise} Media library content + */ +async function getMediaLibrary(searchQuery) { + const library = { + movies: [], + tvShows: [], + music: [], + books: [], + magazines: [], + software: [] + }; + + // Get destination paths from config + const destinations = config.destinationPaths || {}; + + // Process each category + for (const [category, destinationPath] of Object.entries(destinations)) { + if (!destinationPath || typeof destinationPath !== 'string') { + continue; + } + + try { + // Check if directory exists + await fs.access(destinationPath); + + // Get directory listing + const files = await fs.readdir(destinationPath, { withFileTypes: true }); + + // Process each file/directory + for (const file of files) { + const fullPath = path.join(destinationPath, file.name); + + try { + const stats = await fs.stat(fullPath); + + // Create an item object + const item = { + name: file.name, + path: fullPath, + isDirectory: stats.isDirectory(), + size: stats.size, + added: stats.ctime.toISOString() + }; + + // Filter by search query if provided + if (searchQuery && !file.name.toLowerCase().includes(searchQuery.toLowerCase())) { + continue; + } + + // Add to the appropriate category + switch (category) { + case 'movies': + library.movies.push(item); + break; + case 'tvShows': + library.tvShows.push(item); + break; + case 'music': + library.music.push(item); + break; + case 'books': + library.books.push(item); + break; + case 'magazines': + library.magazines.push(item); + break; + case 'software': + library.software.push(item); + break; + } + } catch (itemError) { + console.error(`Error processing media library item ${fullPath}:`, itemError); + } + } + + // Sort by most recently added + const sortKey = category === 'tvShows' ? 'tvShows' : category; + if (Array.isArray(library[sortKey])) { + library[sortKey].sort((a, b) => new Date(b.added) - new Date(a.added)); + } + } catch (error) { + console.error(`Error accessing media directory ${destinationPath}:`, error); + } + } + + return library; +} + +// Authentication API +app.post('/api/auth/login', async (req, res) => { + try { + // Skip authentication if not enabled + if (!config.securitySettings?.authEnabled) { + return res.json({ + success: true, + token: jwt.sign({ username: 'admin' }, JWT_SECRET, { expiresIn: JWT_EXPIRY }), + user: { username: 'admin', role: 'admin' } + }); + } + + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ + success: false, + message: 'Username and password are required' + }); + } + + // Find user + const users = config.securitySettings?.users || []; + const user = users.find(u => u.username === username); + + if (!user) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials' + }); + } + + // Check password + let passwordMatch = false; + + if (user.password.startsWith('$2')) { + // Bcrypt hashed password + passwordMatch = await bcrypt.compare(password, user.password); + } else { + // Legacy plain text password + passwordMatch = password === user.password; + + // Update to hashed password + if (passwordMatch) { + user.password = await bcrypt.hash(password, 10); + await saveConfig(config); + } + } + + if (!passwordMatch) { + return res.status(401).json({ + success: false, + message: 'Invalid credentials' + }); + } + + // Generate token + const token = jwt.sign( + { username: user.username, role: user.role || 'user' }, + JWT_SECRET, + { expiresIn: JWT_EXPIRY } + ); + + res.json({ + success: true, + token, + user: { + username: user.username, + role: user.role || 'user' + } + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Authentication error: ${error.message}` + }); + } +}); + +app.get('/api/auth/validate', authenticateJWT, (req, res) => { + res.json({ + success: true, + user: req.user + }); +}); + +// Catch-all route for SPA +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// Start the server +startServer(); + +// Handle process termination +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully'); + + if (server) { + server.close(() => { + console.log('Server closed'); + process.exit(0); + }); + } else { + process.exit(0); + } +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully'); + + if (server) { + server.close(() => { + console.log('Server closed'); + process.exit(0); + }); + } else { + process.exit(0); + } +}); + +module.exports = app; \ No newline at end of file