massive improvement
This commit is contained in:
		
							
								
								
									
										30
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										51
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								CLAUDE.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										122
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										122
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,6 +1,6 @@ | |||||||
| # Transmission RSS Manager | # 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 | ## 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 | - 📖 **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 | - 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories | ||||||
| - 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping | - 🔄 **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 | - 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices | ||||||
|  |  | ||||||
| ## Installation | ## 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) | - Ubuntu/Debian-based system (may work on other Linux distributions) | ||||||
| - Node.js 14+ and npm | - Node.js 14+ and npm | ||||||
| - Transmission daemon installed and running | - 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 | ### Automatic Installation | ||||||
|  |  | ||||||
| @@ -61,6 +69,16 @@ If you prefer to install manually: | |||||||
|  |  | ||||||
| 4. Start the server: | 4. Start the server: | ||||||
|    ```bash |    ```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 |    node server.js | ||||||
|    ``` |    ``` | ||||||
|  |  | ||||||
| @@ -154,7 +172,7 @@ When enabled, the system can: | |||||||
|  |  | ||||||
| ## Detailed Features | ## Detailed Features | ||||||
|  |  | ||||||
| ### Automatic Media Detection | ### Automatic Media Detection and Processing | ||||||
|  |  | ||||||
| The system uses sophisticated detection to categorize downloads: | 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 | - **Magazines**: Recognizes magazine naming patterns, issues, volumes, and publication dates | ||||||
| - **Software**: Detects software installers, ISOs, and other program files | - **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 | ### RSS Feed Filtering | ||||||
|  |  | ||||||
| Powerful filtering options for RSS feeds: | Powerful filtering options for RSS feeds: | ||||||
| @@ -186,8 +225,27 @@ Full support for remote Transmission instances: | |||||||
|  |  | ||||||
| To update to the latest version: | 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 | ```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 | chmod +x update.sh | ||||||
| sudo ./update.sh | sudo ./update.sh | ||||||
| ``` | ``` | ||||||
| @@ -196,18 +254,23 @@ sudo ./update.sh | |||||||
|  |  | ||||||
| ``` | ``` | ||||||
| transmission-rss-manager/ | transmission-rss-manager/ | ||||||
| ├── server.js                # Main application server | ├── server.js                    # Main application server | ||||||
| ├── postProcessor.js         # Media processing module | ├── modules/                     # Modular components | ||||||
| ├── rssFeedManager.js        # RSS feed management module | │   ├── post-processor.js        # Media processing module | ||||||
| ├── install.sh               # Installation script | │   ├── rss-feed-manager.js      # RSS feed management module | ||||||
| ├── update.sh                # Update script | │   ├── transmission-client.js   # Transmission API integration | ||||||
| ├── config.json              # Configuration file | │   └── config-module.sh         # Installation configuration | ||||||
| ├── public/                  # Web interface files | ├── install-script.sh            # Initial installer that creates modules | ||||||
| │   ├── index.html           # Main web interface | ├── main-installer.sh            # Main installation script | ||||||
| │   ├── js/                  # JavaScript files | ├── config.json                  # Configuration file | ||||||
| │   │   └── enhanced-ui.js   # Enhanced UI functionality | ├── public/                      # Web interface files | ||||||
| │   └── css/                 # CSS stylesheets | │   ├── index.html               # Main web interface | ||||||
| └── README.md                # This file | │   ├── js/                      # JavaScript files | ||||||
|  | │   │   ├── app.js               # Core application logic | ||||||
|  | │   │   └── utils.js             # Utility functions | ||||||
|  | │   └── css/                     # CSS stylesheets | ||||||
|  | │       └── styles.css           # Main stylesheet | ||||||
|  | └── README.md                    # This file | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| ## Modules | ## 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 | ### Processing Options | ||||||
|  |  | ||||||
| Customize how files are processed: | Customize how files are processed: | ||||||
|   | |||||||
| @@ -28,12 +28,22 @@ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" | |||||||
| # Create modules directory if it doesn't exist | # Create modules directory if it doesn't exist | ||||||
| mkdir -p "${SCRIPT_DIR}/modules" | 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 | # 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}" |   echo -e "${YELLOW}Creating module files...${NC}" | ||||||
|    |    | ||||||
|   # Create config module |   # Create config module | ||||||
|   cat > "${SCRIPT_DIR}/modules/config.sh" << 'EOL' |   cat > "${SCRIPT_DIR}/modules/config-module.sh" << 'EOL' | ||||||
| #!/bin/bash | #!/bin/bash | ||||||
| # Configuration module for Transmission RSS Manager Installation | # Configuration module for Transmission RSS Manager Installation | ||||||
|  |  | ||||||
| @@ -173,7 +183,7 @@ EOF | |||||||
| EOL | EOL | ||||||
|  |  | ||||||
|   # Create utils module |   # Create utils module | ||||||
|   cat > "${SCRIPT_DIR}/modules/utils.sh" << 'EOL' |   cat > "${SCRIPT_DIR}/modules/utils-module.sh" << 'EOL' | ||||||
| #!/bin/bash | #!/bin/bash | ||||||
| # Utilities module for Transmission RSS Manager Installation | # Utilities module for Transmission RSS Manager Installation | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,9 @@ | |||||||
| # Transmission RSS Manager Modular Installer | # Transmission RSS Manager Modular Installer | ||||||
| # Main installer script that coordinates the installation process | # Main installer script that coordinates the installation process | ||||||
|  |  | ||||||
|  | # Set script to exit on error | ||||||
|  | set -e | ||||||
|  |  | ||||||
| # Text formatting | # Text formatting | ||||||
| BOLD='\033[1m' | BOLD='\033[1m' | ||||||
| GREEN='\033[0;32m' | GREEN='\033[0;32m' | ||||||
| @@ -25,38 +28,112 @@ fi | |||||||
| # Get current directory | # Get current directory | ||||||
| SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" | 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 the module files | ||||||
| source "${SCRIPT_DIR}/modules/config.sh" | source "${SCRIPT_DIR}/modules/utils-module.sh" # Load utilities first for logging | ||||||
| source "${SCRIPT_DIR}/modules/utils.sh" | source "${SCRIPT_DIR}/modules/config-module.sh" | ||||||
| source "${SCRIPT_DIR}/modules/dependencies.sh" | source "${SCRIPT_DIR}/modules/dependencies-module.sh" | ||||||
| source "${SCRIPT_DIR}/modules/file_creator.sh" | source "${SCRIPT_DIR}/modules/file-creator-module.sh" | ||||||
| source "${SCRIPT_DIR}/modules/service_setup.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 | # Execute the installation steps in sequence | ||||||
| echo -e "${YELLOW}Starting installation process...${NC}" | log "INFO" "Starting installation process..." | ||||||
|  |  | ||||||
| # Step 1: Gather configuration from user | # 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 | # Step 2: Install dependencies | ||||||
| install_dependencies | log "INFO" "Installing dependencies..." | ||||||
|  | install_dependencies || { | ||||||
|  |   log "ERROR" "Dependency installation failed" | ||||||
|  |   exit 1 | ||||||
|  | } | ||||||
|  |  | ||||||
| # Step 3: Create installation directories | # 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 | # 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 | # 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 | # 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}" | # Installation complete | ||||||
| echo -e "You can access the RSS Manager at ${BOLD}http://localhost:${PORT}${NC} or ${BOLD}http://your-server-ip:${PORT}${NC}" |  | ||||||
| echo | echo | ||||||
| echo -e "The service is ${BOLD}automatically started${NC} and will ${BOLD}start on boot${NC}." | echo -e "${BOLD}${GREEN}==================================================${NC}" | ||||||
| echo -e "To manually control the service, use: ${BOLD}sudo systemctl [start|stop|restart] ${SERVICE_NAME}${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 | ||||||
| echo -e "${BOLD}Thank you for installing Transmission RSS Manager Enhanced Edition!${NC}" | 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}" | ||||||
|   | |||||||
| @@ -4,9 +4,38 @@ | |||||||
| # Configuration variables with defaults | # Configuration variables with defaults | ||||||
| INSTALL_DIR="/opt/transmission-rss-manager" | INSTALL_DIR="/opt/transmission-rss-manager" | ||||||
| SERVICE_NAME="transmission-rss-manager" | SERVICE_NAME="transmission-rss-manager" | ||||||
| USER=$(logname || echo $SUDO_USER) |  | ||||||
| PORT=3000 | 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 configuration variables | ||||||
| TRANSMISSION_REMOTE=false | TRANSMISSION_REMOTE=false | ||||||
| TRANSMISSION_HOST="localhost" | TRANSMISSION_HOST="localhost" | ||||||
| @@ -21,43 +50,124 @@ TRANSMISSION_DIR_MAPPING="{}" | |||||||
| MEDIA_DIR="/mnt/media" | MEDIA_DIR="/mnt/media" | ||||||
| ENABLE_BOOK_SORTING=true | 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() { | function gather_configuration() { | ||||||
|  |   log "INFO" "Starting configuration gathering" | ||||||
|   echo -e "${BOLD}Installation Configuration:${NC}" |   echo -e "${BOLD}Installation Configuration:${NC}" | ||||||
|   echo -e "Please provide the following configuration parameters:" |   echo -e "Please provide the following configuration parameters:" | ||||||
|   echo |   echo | ||||||
|  |  | ||||||
|   read -p "Installation directory [$INSTALL_DIR]: " input_install_dir |   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 |   # Get and validate port | ||||||
|   PORT=${input_port:-$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 |   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 | ||||||
|   echo -e "${BOLD}Transmission Configuration:${NC}" |   echo -e "${BOLD}Transmission Configuration:${NC}" | ||||||
|   echo -e "Configure connection to your Transmission client:" |   echo -e "Configure connection to your Transmission client:" | ||||||
|   echo |   echo | ||||||
|  |  | ||||||
|  |   # Ask if Transmission is remote | ||||||
|   read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote |   read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote | ||||||
|   if [[ $input_remote =~ ^[Yy]$ ]]; then |   if [[ $input_remote =~ ^[Yy]$ ]]; then | ||||||
|     TRANSMISSION_REMOTE=true |     TRANSMISSION_REMOTE=true | ||||||
|      |      | ||||||
|     read -p "Remote Transmission host [localhost]: " input_trans_host |     # Get and validate hostname | ||||||
|     TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST} |     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 |     # Get and validate port | ||||||
|     TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_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 |     read -p "Remote Transmission username []: " input_trans_user | ||||||
|     TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} |     TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} | ||||||
|      |      | ||||||
|     read -p "Remote Transmission password []: " input_trans_pass |     # Use read -s for password to avoid showing it on screen | ||||||
|     TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS} |     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 |     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 |     # Configure directory mapping for remote setup | ||||||
|     echo |     echo | ||||||
| @@ -74,17 +184,20 @@ function gather_configuration() { | |||||||
|     read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR |     read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR | ||||||
|     LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} |     LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} | ||||||
|      |      | ||||||
|     # Create mapping JSON |     # Create mapping JSON - use proper JSON escaping for directory paths | ||||||
|     TRANSMISSION_DIR_MAPPING=$(cat <<EOF |     REMOTE_DOWNLOAD_DIR_ESCAPED=$(echo "$REMOTE_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') | ||||||
| { |     LOCAL_DOWNLOAD_DIR_ESCAPED=$(echo "$LOCAL_DOWNLOAD_DIR" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') | ||||||
|   "$REMOTE_DOWNLOAD_DIR": "$LOCAL_DOWNLOAD_DIR" |      | ||||||
| } |     TRANSMISSION_DIR_MAPPING="{\"$REMOTE_DOWNLOAD_DIR_ESCAPED\": \"$LOCAL_DOWNLOAD_DIR_ESCAPED\"}" | ||||||
| EOF |  | ||||||
| ) |  | ||||||
|      |      | ||||||
|     # Create the local directory |     # Create the local directory | ||||||
|     mkdir -p "$LOCAL_DOWNLOAD_DIR" |     if ! mkdir -p "$LOCAL_DOWNLOAD_DIR"; then | ||||||
|     chown -R $USER:$USER "$LOCAL_DOWNLOAD_DIR" |       log "ERROR" "Failed to create local download directory: $LOCAL_DOWNLOAD_DIR" | ||||||
|  |     else | ||||||
|  |       if ! chown -R "$USER:$USER" "$LOCAL_DOWNLOAD_DIR"; then | ||||||
|  |         log "ERROR" "Failed to set permissions on local download directory: $LOCAL_DOWNLOAD_DIR" | ||||||
|  |       fi | ||||||
|  |     fi | ||||||
|      |      | ||||||
|     # Ask if want to add more mappings |     # Ask if want to add more mappings | ||||||
|     while true; do |     while true; do | ||||||
| @@ -97,14 +210,23 @@ EOF | |||||||
|       read -p "Corresponding local directory path: " local_dir |       read -p "Corresponding local directory path: " local_dir | ||||||
|        |        | ||||||
|       if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then |       if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then | ||||||
|         # Update mapping JSON (remove the last "}" and add the new mapping) |         # Escape directory paths for JSON | ||||||
|         TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir\": \"$local_dir\" }" |         remote_dir_escaped=$(echo "$remote_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') | ||||||
|  |         local_dir_escaped=$(echo "$local_dir" | sed 's/\\/\\\\/g' | sed 's/"/\\"/g') | ||||||
|  |          | ||||||
|  |         # Update mapping JSON (proper JSON manipulation) | ||||||
|  |         # Remove the closing brace, add a comma and the new mapping, then close with brace | ||||||
|  |         TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir_escaped\": \"$local_dir_escaped\"}" | ||||||
|          |          | ||||||
|         # Create the local directory |         # Create the local directory | ||||||
|         mkdir -p "$local_dir" |         if ! mkdir -p "$local_dir"; then | ||||||
|         chown -R $USER:$USER "$local_dir" |           log "ERROR" "Failed to create directory: $local_dir" | ||||||
|          |         else | ||||||
|         echo -e "${GREEN}Mapping added: $remote_dir → $local_dir${NC}" |           if ! chown -R "$USER:$USER" "$local_dir"; then | ||||||
|  |             log "WARN" "Failed to set permissions on directory: $local_dir" | ||||||
|  |           fi | ||||||
|  |           log "INFO" "Mapping added: $remote_dir → $local_dir" | ||||||
|  |         fi | ||||||
|       fi |       fi | ||||||
|     done |     done | ||||||
|      |      | ||||||
| @@ -112,25 +234,112 @@ EOF | |||||||
|     TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR |     TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR | ||||||
|   else |   else | ||||||
|     read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir |     read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir | ||||||
|     TRANSMISSION_DOWNLOAD_DIR=${input_trans_dir:-$TRANSMISSION_DOWNLOAD_DIR} |     if [ -n "$input_trans_dir" ]; then | ||||||
|  |       if [[ ! "$input_trans_dir" =~ ^/ ]]; then | ||||||
|  |         log "WARN" "Download directory must be an absolute path. Using default." | ||||||
|  |       else | ||||||
|  |         TRANSMISSION_DOWNLOAD_DIR="$input_trans_dir" | ||||||
|  |       fi | ||||||
|  |     fi | ||||||
|   fi |   fi | ||||||
|  |  | ||||||
|   echo |   echo | ||||||
|   echo -e "${BOLD}Media Destination Configuration:${NC}" |   echo -e "${BOLD}Media Destination Configuration:${NC}" | ||||||
|  |  | ||||||
|   read -p "Media destination base directory [/mnt/media]: " input_media_dir |   read -p "Media destination base directory [/mnt/media]: " input_media_dir | ||||||
|   MEDIA_DIR=${input_media_dir:-$MEDIA_DIR} |   if [ -n "$input_media_dir" ]; then | ||||||
|  |     if [[ ! "$input_media_dir" =~ ^/ ]]; then | ||||||
|  |       log "WARN" "Media directory must be an absolute path. Using default." | ||||||
|  |     else | ||||||
|  |       MEDIA_DIR="$input_media_dir" | ||||||
|  |     fi | ||||||
|  |   fi | ||||||
|  |  | ||||||
|   # Ask about enabling book/magazine sorting |   # Ask about enabling book/magazine sorting | ||||||
|   echo |   echo | ||||||
|   echo -e "${BOLD}Content Type Configuration:${NC}" |   echo -e "${BOLD}Content Type Configuration:${NC}" | ||||||
|   read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting |   read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting | ||||||
|   ENABLE_BOOK_SORTING=true |  | ||||||
|   if [[ $input_book_sorting =~ ^[Nn]$ ]]; then |   if [[ $input_book_sorting =~ ^[Nn]$ ]]; then | ||||||
|     ENABLE_BOOK_SORTING=false |     ENABLE_BOOK_SORTING=false | ||||||
|  |   else | ||||||
|  |     ENABLE_BOOK_SORTING=true | ||||||
|  |   fi | ||||||
|  |    | ||||||
|  |   # Security configuration | ||||||
|  |   echo | ||||||
|  |   echo -e "${BOLD}Security Configuration:${NC}" | ||||||
|  |    | ||||||
|  |   # Ask about enabling authentication | ||||||
|  |   read -p "Enable authentication? (y/n) [n]: " input_auth_enabled | ||||||
|  |   AUTH_ENABLED=false | ||||||
|  |   ADMIN_USERNAME="" | ||||||
|  |   ADMIN_PASSWORD="" | ||||||
|  |    | ||||||
|  |   if [[ $input_auth_enabled =~ ^[Yy]$ ]]; then | ||||||
|  |     AUTH_ENABLED=true | ||||||
|  |      | ||||||
|  |     # Get admin username and password | ||||||
|  |     read -p "Admin username [admin]: " input_admin_username | ||||||
|  |     ADMIN_USERNAME=${input_admin_username:-"admin"} | ||||||
|  |      | ||||||
|  |     # Use read -s for password to avoid showing it on screen | ||||||
|  |     read -s -p "Admin password: " input_admin_password | ||||||
|  |     echo  # Add a newline after the password input | ||||||
|  |      | ||||||
|  |     if [ -z "$input_admin_password" ]; then | ||||||
|  |       # Generate a random password if none provided | ||||||
|  |       ADMIN_PASSWORD=$(openssl rand -base64 12) | ||||||
|  |       echo -e "${YELLOW}Generated random admin password: $ADMIN_PASSWORD${NC}" | ||||||
|  |       echo -e "${YELLOW}Please save this password somewhere safe!${NC}" | ||||||
|  |     else | ||||||
|  |       ADMIN_PASSWORD="$input_admin_password" | ||||||
|  |     fi | ||||||
|  |   fi | ||||||
|  |    | ||||||
|  |   # Ask about enabling HTTPS | ||||||
|  |   read -p "Enable HTTPS? (requires SSL certificate) (y/n) [n]: " input_https_enabled | ||||||
|  |   HTTPS_ENABLED=false | ||||||
|  |   SSL_CERT_PATH="" | ||||||
|  |   SSL_KEY_PATH="" | ||||||
|  |    | ||||||
|  |   if [[ $input_https_enabled =~ ^[Yy]$ ]]; then | ||||||
|  |     HTTPS_ENABLED=true | ||||||
|  |      | ||||||
|  |     # Get SSL certificate paths | ||||||
|  |     read -p "SSL certificate path: " input_ssl_cert_path | ||||||
|  |     if [ -n "$input_ssl_cert_path" ]; then | ||||||
|  |       # Check if file exists | ||||||
|  |       if [ -f "$input_ssl_cert_path" ]; then | ||||||
|  |         SSL_CERT_PATH="$input_ssl_cert_path" | ||||||
|  |       else | ||||||
|  |         log "WARN" "SSL certificate file not found. HTTPS will be disabled." | ||||||
|  |         HTTPS_ENABLED=false | ||||||
|  |       fi | ||||||
|  |     else | ||||||
|  |       log "WARN" "SSL certificate path not provided. HTTPS will be disabled." | ||||||
|  |       HTTPS_ENABLED=false | ||||||
|  |     fi | ||||||
|  |      | ||||||
|  |     # Only ask for key if cert was found | ||||||
|  |     if [ "$HTTPS_ENABLED" = true ]; then | ||||||
|  |       read -p "SSL key path: " input_ssl_key_path | ||||||
|  |       if [ -n "$input_ssl_key_path" ]; then | ||||||
|  |         # Check if file exists | ||||||
|  |         if [ -f "$input_ssl_key_path" ]; then | ||||||
|  |           SSL_KEY_PATH="$input_ssl_key_path" | ||||||
|  |         else | ||||||
|  |           log "WARN" "SSL key file not found. HTTPS will be disabled." | ||||||
|  |           HTTPS_ENABLED=false | ||||||
|  |         fi | ||||||
|  |       else | ||||||
|  |         log "WARN" "SSL key path not provided. HTTPS will be disabled." | ||||||
|  |         HTTPS_ENABLED=false | ||||||
|  |       fi | ||||||
|  |     fi | ||||||
|   fi |   fi | ||||||
|  |  | ||||||
|   echo |   echo | ||||||
|  |   log "INFO" "Configuration gathering complete" | ||||||
|   echo -e "${GREEN}Configuration complete!${NC}" |   echo -e "${GREEN}Configuration complete!${NC}" | ||||||
|   echo |   echo | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,27 +2,53 @@ | |||||||
| # Dependencies module for Transmission RSS Manager Installation | # Dependencies module for Transmission RSS Manager Installation | ||||||
|  |  | ||||||
| function install_dependencies() { | function install_dependencies() { | ||||||
|   echo -e "${YELLOW}Installing dependencies...${NC}" |   log "INFO" "Installing dependencies..." | ||||||
|  |  | ||||||
|   # Update package index |   # Check for package manager | ||||||
|   apt-get update |   if command -v apt-get &> /dev/null; then | ||||||
|  |     # Update package index | ||||||
|   # Install Node.js and npm if not already installed |  | ||||||
|   if ! command_exists node; then |  | ||||||
|     echo "Installing Node.js and npm..." |  | ||||||
|     apt-get install -y ca-certificates curl gnupg |  | ||||||
|     mkdir -p /etc/apt/keyrings |  | ||||||
|     curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg |  | ||||||
|     echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list |  | ||||||
|     apt-get update |     apt-get update | ||||||
|     apt-get install -y nodejs |  | ||||||
|   else |  | ||||||
|     echo "Node.js is already installed." |  | ||||||
|   fi |  | ||||||
|  |  | ||||||
|   # Install additional dependencies |     # Install Node.js and npm if not already installed | ||||||
|   echo "Installing additional dependencies..." |     if ! command_exists node; then | ||||||
|   apt-get install -y unrar unzip p7zip-full nginx |       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 |   # Check if all dependencies were installed successfully | ||||||
|   local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") |   local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") | ||||||
| @@ -35,26 +61,49 @@ function install_dependencies() { | |||||||
|   done |   done | ||||||
|    |    | ||||||
|   if [ ${#missing_deps[@]} -eq 0 ]; then |   if [ ${#missing_deps[@]} -eq 0 ]; then | ||||||
|     echo -e "${GREEN}All dependencies installed successfully.${NC}" |     log "INFO" "All dependencies installed successfully." | ||||||
|   else |   else | ||||||
|     echo -e "${RED}Failed to install some dependencies: ${missing_deps[*]}${NC}" |     log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}" | ||||||
|     echo -e "${YELLOW}Please install them manually and rerun this script.${NC}" |     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 |     exit 1 | ||||||
|   fi |   fi | ||||||
| } | } | ||||||
|  |  | ||||||
| function create_directories() { | function create_directories() { | ||||||
|   echo -e "${YELLOW}Creating installation directories...${NC}" |   log "INFO" "Creating installation directories..." | ||||||
|    |    | ||||||
|   # Create main installation directory |   # Check if INSTALL_DIR is defined | ||||||
|   mkdir -p $INSTALL_DIR |   if [ -z "$INSTALL_DIR" ]; then | ||||||
|   mkdir -p $INSTALL_DIR/logs |     log "ERROR" "INSTALL_DIR is not defined" | ||||||
|   mkdir -p $INSTALL_DIR/public/js |     exit 1 | ||||||
|   mkdir -p $INSTALL_DIR/public/css |   fi | ||||||
|   mkdir -p $INSTALL_DIR/modules |  | ||||||
|    |    | ||||||
|   # Create directory for file storage |   # Create directories and check for errors | ||||||
|   mkdir -p $INSTALL_DIR/data |   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." | ||||||
| } | } | ||||||
|   | |||||||
| @@ -18,11 +18,14 @@ function create_config_files() { | |||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "express": "^4.18.2", |     "express": "^4.18.2", | ||||||
|     "body-parser": "^1.20.2", |     "body-parser": "^1.20.2", | ||||||
|     "transmission": "^0.4.10", |     "transmission-promise": "^1.1.5", | ||||||
|     "adm-zip": "^0.5.10", |     "adm-zip": "^0.5.10", | ||||||
|     "node-fetch": "^2.6.9", |     "node-fetch": "^2.6.9", | ||||||
|     "xml2js": "^0.5.0", |     "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 | EOF | ||||||
|   | |||||||
							
								
								
									
										517
									
								
								modules/post-processor.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										517
									
								
								modules/post-processor.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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<boolean>} Whether the file exists | ||||||
|  |    */ | ||||||
|  |   async fileExists(filePath) { | ||||||
|  |     try { | ||||||
|  |       await fs.access(filePath); | ||||||
|  |       return true; | ||||||
|  |     } catch { | ||||||
|  |       return false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | module.exports = PostProcessor; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| // rssFeedManager.js | // rss-feed-manager.js - Handles RSS feed fetching, parsing, and torrent management | ||||||
| const fs = require('fs').promises; | const fs = require('fs').promises; | ||||||
| const path = require('path'); | const path = require('path'); | ||||||
| const fetch = require('node-fetch'); | const fetch = require('node-fetch'); | ||||||
| @@ -7,13 +7,22 @@ const crypto = require('crypto'); | |||||||
|  |  | ||||||
| class RssFeedManager { | class RssFeedManager { | ||||||
|   constructor(config) { |   constructor(config) { | ||||||
|  |     if (!config) { | ||||||
|  |       throw new Error('Configuration is required'); | ||||||
|  |     } | ||||||
|  |      | ||||||
|     this.config = config; |     this.config = config; | ||||||
|     this.feeds = config.feeds || []; |     this.feeds = config.feeds || []; | ||||||
|     this.items = []; |     this.items = []; | ||||||
|     this.updateIntervalId = null; |     this.updateIntervalId = null; | ||||||
|     this.updateIntervalMinutes = config.updateIntervalMinutes || 60; |     this.updateIntervalMinutes = config.updateIntervalMinutes || 60; | ||||||
|     this.parser = new xml2js.Parser({ explicitArray: false }); |     this.parser = new xml2js.Parser({ explicitArray: false }); | ||||||
|  |      | ||||||
|  |     // Ensure dataPath is properly defined | ||||||
|     this.dataPath = path.join(__dirname, '..', 'data'); |     this.dataPath = path.join(__dirname, '..', 'data'); | ||||||
|  |      | ||||||
|  |     // Maximum items to keep in memory to prevent memory leaks | ||||||
|  |     this.maxItemsInMemory = config.maxItemsInMemory || 5000; | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   async start() { |   async start() { | ||||||
| @@ -21,15 +30,28 @@ class RssFeedManager { | |||||||
|       return; |       return; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Run update immediately |     try { | ||||||
|     await this.updateAllFeeds(); |       // Load existing feeds and items | ||||||
|  |       await this.loadFeeds(); | ||||||
|  |       await this.loadItems(); | ||||||
|        |        | ||||||
|     // Then set up interval |       // Run update immediately | ||||||
|     this.updateIntervalId = setInterval(async () => { |       await this.updateAllFeeds().catch(error => { | ||||||
|       await this.updateAllFeeds(); |         console.error('Error in initial feed update:', error); | ||||||
|     }, this.updateIntervalMinutes * 60 * 1000); |       }); | ||||||
|        |        | ||||||
|     console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`); |       // 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() { |   stop() { | ||||||
| @@ -47,7 +69,19 @@ class RssFeedManager { | |||||||
|      |      | ||||||
|     const results = []; |     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) { |     for (const feed of this.feeds) { | ||||||
|  |       if (!feed || !feed.id || !feed.url) { | ||||||
|  |         console.error('Invalid feed object:', feed); | ||||||
|  |         continue; | ||||||
|  |       } | ||||||
|  |        | ||||||
|       try { |       try { | ||||||
|         const result = await this.updateFeed(feed); |         const result = await this.updateFeed(feed); | ||||||
|         results.push({ |         results.push({ | ||||||
| @@ -65,30 +99,65 @@ class RssFeedManager { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Save updated items |     try { | ||||||
|     await this.saveItems(); |       // 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'); |     console.log('RSS feed update completed'); | ||||||
|     return results; |     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) { |   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 { |     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) { |       if (!response.ok) { | ||||||
|         throw new Error(`HTTP error ${response.status}: ${response.statusText}`); |         throw new Error(`HTTP error ${response.status}: ${response.statusText}`); | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       const xml = await response.text(); |       const xml = await response.text(); | ||||||
|  |        | ||||||
|  |       if (!xml || xml.trim() === '') { | ||||||
|  |         throw new Error('Empty feed content'); | ||||||
|  |       } | ||||||
|  |        | ||||||
|       const result = await this.parseXml(xml); |       const result = await this.parseXml(xml); | ||||||
|        |        | ||||||
|  |       if (!result) { | ||||||
|  |         throw new Error('Failed to parse XML feed'); | ||||||
|  |       } | ||||||
|  |        | ||||||
|       const rssItems = this.extractItems(result, feed); |       const rssItems = this.extractItems(result, feed); | ||||||
|       const newItems = this.processNewItems(rssItems, 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 { |       return { | ||||||
|         totalItems: rssItems.length, |         totalItems: rssItems.length, | ||||||
| @@ -101,6 +170,10 @@ class RssFeedManager { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   parseXml(xml) { |   parseXml(xml) { | ||||||
|  |     if (!xml || typeof xml !== 'string') { | ||||||
|  |       return Promise.reject(new Error('Invalid XML input')); | ||||||
|  |     } | ||||||
|  |      | ||||||
|     return new Promise((resolve, reject) => { |     return new Promise((resolve, reject) => { | ||||||
|       this.parser.parseString(xml, (error, result) => { |       this.parser.parseString(xml, (error, result) => { | ||||||
|         if (error) { |         if (error) { | ||||||
| @@ -113,17 +186,33 @@ class RssFeedManager { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   extractItems(parsedXml, feed) { |   extractItems(parsedXml, feed) { | ||||||
|  |     if (!parsedXml || !feed) { | ||||||
|  |       console.error('Invalid parsed XML or feed'); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |      | ||||||
|     try { |     try { | ||||||
|       // Handle standard RSS 2.0 |       // Handle standard RSS 2.0 | ||||||
|       if (parsedXml.rss && parsedXml.rss.channel) { |       if (parsedXml.rss && parsedXml.rss.channel) { | ||||||
|         const channel = 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)); |         return items.map(item => this.normalizeRssItem(item, feed)); | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Handle Atom |       // Handle Atom | ||||||
|       if (parsedXml.feed && parsedXml.feed.entry) { |       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)); |         return entries.map(entry => this.normalizeAtomItem(entry, feed)); | ||||||
|       } |       } | ||||||
|        |        | ||||||
| @@ -135,88 +224,155 @@ class RssFeedManager { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   normalizeRssItem(item, feed) { |   normalizeRssItem(item, feed) { | ||||||
|     // Create a unique ID for the item |     if (!item || !feed) { | ||||||
|     const idContent = `${feed.id}:${item.title}:${item.pubDate || ''}:${item.link || ''}`; |       console.error('Invalid RSS item or feed'); | ||||||
|     const id = crypto.createHash('md5').update(idContent).digest('hex'); |       return null; | ||||||
|      |  | ||||||
|     // Extract enclosure (torrent link) |  | ||||||
|     let torrentLink = item.link || ''; |  | ||||||
|     let fileSize = 0; |  | ||||||
|      |  | ||||||
|     if (item.enclosure) { |  | ||||||
|       torrentLink = item.enclosure.$ ? item.enclosure.$.url : item.enclosure.url || torrentLink; |  | ||||||
|       fileSize = item.enclosure.$ ? parseInt(item.enclosure.$.length || 0, 10) : parseInt(item.enclosure.length || 0, 10); |  | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Handle custom namespaces (common in torrent feeds) |     try { | ||||||
|     let category = ''; |       // Create a unique ID for the item | ||||||
|     let size = fileSize; |       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'); | ||||||
|        |        | ||||||
|     if (item.category) { |       // Extract enclosure (torrent link) | ||||||
|       category = Array.isArray(item.category) ? item.category[0] : item.category; |       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) { |   normalizeAtomItem(entry, feed) { | ||||||
|     // Create a unique ID for the item |     if (!entry || !feed) { | ||||||
|     const idContent = `${feed.id}:${entry.title}:${entry.updated || ''}:${entry.id || ''}`; |       console.error('Invalid Atom entry or feed'); | ||||||
|     const id = crypto.createHash('md5').update(idContent).digest('hex'); |       return null; | ||||||
|      |  | ||||||
|     // Extract link |  | ||||||
|     let link = ''; |  | ||||||
|     let torrentLink = ''; |  | ||||||
|      |  | ||||||
|     if (entry.link) { |  | ||||||
|       if (Array.isArray(entry.link)) { |  | ||||||
|         const links = entry.link; |  | ||||||
|         link = links.find(l => l.$.rel === 'alternate')?.$.href || links[0]?.$.href || ''; |  | ||||||
|         torrentLink = links.find(l => l.$.type && l.$.type.includes('torrent'))?.$.href || link; |  | ||||||
|       } else { |  | ||||||
|         link = entry.link.$.href || ''; |  | ||||||
|         torrentLink = link; |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     return { |     try { | ||||||
|       id, |       // Create a unique ID for the item | ||||||
|       feedId: feed.id, |       const title = entry.title || 'Untitled'; | ||||||
|       title: entry.title || 'Untitled', |       const updated = entry.updated || ''; | ||||||
|       link: link, |       const entryId = entry.id || ''; | ||||||
|       torrentLink: torrentLink, |       const idContent = `${feed.id}:${title}:${updated}:${entryId}`; | ||||||
|       pubDate: entry.updated || entry.published || new Date().toISOString(), |       const id = crypto.createHash('md5').update(idContent).digest('hex'); | ||||||
|       category: entry.category?.$.term || '', |        | ||||||
|       description: entry.summary || entry.content || '', |       // Extract link | ||||||
|       size: 0, // Atom doesn't typically include file size |       let link = ''; | ||||||
|       downloaded: false, |       let torrentLink = ''; | ||||||
|       ignored: false, |        | ||||||
|       added: new Date().toISOString() |       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) { |   processNewItems(rssItems, feed) { | ||||||
|  |     if (!Array.isArray(rssItems) || !feed) { | ||||||
|  |       console.error('Invalid RSS items array or feed'); | ||||||
|  |       return []; | ||||||
|  |     } | ||||||
|  |      | ||||||
|     const newItems = []; |     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 |       // Check if item already exists in our list | ||||||
|       const existingItem = this.items.find(i => i.id === item.id); |       const existingItem = this.items.find(i => i.id === item.id); | ||||||
|        |        | ||||||
| @@ -236,28 +392,34 @@ class RssFeedManager { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   matchesFilters(item, filters) { |   matchesFilters(item, filters) { | ||||||
|     if (!filters || filters.length === 0) { |     if (!item) return false; | ||||||
|  |      | ||||||
|  |     if (!filters || !Array.isArray(filters) || filters.length === 0) { | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|      |      | ||||||
|     // Check if the item matches any of the filters |     // Check if the item matches any of the filters | ||||||
|     return filters.some(filter => { |     return filters.some(filter => { | ||||||
|  |       if (!filter) return true; | ||||||
|  |        | ||||||
|       // Title check |       // 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; |         return false; | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Category check |       // 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; |         return false; | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Size check |       // Size checks | ||||||
|       if (filter.minSize && item.size < filter.minSize) { |       if (filter.minSize && typeof item.size === 'number' && item.size < filter.minSize) { | ||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       if (filter.maxSize && item.size > filter.maxSize) { |       if (filter.maxSize && typeof item.size === 'number' && item.size > filter.maxSize) { | ||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
|        |        | ||||||
| @@ -267,6 +429,8 @@ class RssFeedManager { | |||||||
|   } |   } | ||||||
|    |    | ||||||
|   queueItemForDownload(item) { |   queueItemForDownload(item) { | ||||||
|  |     if (!item) return; | ||||||
|  |      | ||||||
|     // Mark the item as queued for download |     // Mark the item as queued for download | ||||||
|     console.log(`Auto-downloading item: ${item.title}`); |     console.log(`Auto-downloading item: ${item.title}`); | ||||||
|      |      | ||||||
| @@ -278,8 +442,8 @@ class RssFeedManager { | |||||||
|    |    | ||||||
|   async saveItems() { |   async saveItems() { | ||||||
|     try { |     try { | ||||||
|       // Create data directory if it doesn't exist |       // Ensure data directory exists | ||||||
|       await fs.mkdir(this.dataPath, { recursive: true }); |       await this.ensureDataDirectory(); | ||||||
|        |        | ||||||
|       // Save items to file |       // Save items to file | ||||||
|       await fs.writeFile( |       await fs.writeFile( | ||||||
| @@ -296,10 +460,10 @@ class RssFeedManager { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   async saveConfig() { |   async saveFeeds() { | ||||||
|     try { |     try { | ||||||
|       // Create data directory if it doesn't exist |       // Ensure data directory exists | ||||||
|       await fs.mkdir(this.dataPath, { recursive: true }); |       await this.ensureDataDirectory(); | ||||||
|        |        | ||||||
|       // Save feeds to file |       // Save feeds to file | ||||||
|       await fs.writeFile( |       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() { |   async loadItems() { | ||||||
|     try { |     try { | ||||||
|       const filePath = path.join(this.dataPath, 'rss-items.json'); |       const filePath = path.join(this.dataPath, 'rss-items.json'); | ||||||
| @@ -325,17 +498,80 @@ class RssFeedManager { | |||||||
|         await fs.access(filePath); |         await fs.access(filePath); | ||||||
|       } catch (error) { |       } catch (error) { | ||||||
|         console.log('No saved RSS items found'); |         console.log('No saved RSS items found'); | ||||||
|  |         this.items = []; | ||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       // Load items from file |       // Load items from file | ||||||
|       const data = await fs.readFile(filePath, 'utf8'); |       const data = await fs.readFile(filePath, 'utf8'); | ||||||
|       this.items = JSON.parse(data); |  | ||||||
|        |        | ||||||
|       console.log(`Loaded ${this.items.length} RSS items from disk`); |       if (!data || data.trim() === '') { | ||||||
|       return true; |         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) { |     } catch (error) { | ||||||
|       console.error('Error loading RSS items:', 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; |       return false; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @@ -343,33 +579,56 @@ class RssFeedManager { | |||||||
|   // Public API methods |   // Public API methods | ||||||
|    |    | ||||||
|   getAllFeeds() { |   getAllFeeds() { | ||||||
|     return this.feeds; |     return Array.isArray(this.feeds) ? this.feeds : []; | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   addFeed(feedData) { |   addFeed(feedData) { | ||||||
|  |     if (!feedData || !feedData.url) { | ||||||
|  |       throw new Error('Feed URL is required'); | ||||||
|  |     } | ||||||
|  |      | ||||||
|     // Generate an ID for the feed |     // Generate an ID for the feed | ||||||
|     const id = crypto.randomBytes(8).toString('hex'); |     const id = crypto.randomBytes(8).toString('hex'); | ||||||
|      |      | ||||||
|     const newFeed = { |     const newFeed = { | ||||||
|       id, |       id, | ||||||
|       name: feedData.name, |       name: feedData.name || 'Unnamed Feed', | ||||||
|       url: feedData.url, |       url: feedData.url, | ||||||
|       autoDownload: feedData.autoDownload || false, |       autoDownload: !!feedData.autoDownload, | ||||||
|       filters: feedData.filters || [], |       filters: Array.isArray(feedData.filters) ? feedData.filters : [], | ||||||
|       added: new Date().toISOString() |       added: new Date().toISOString() | ||||||
|     }; |     }; | ||||||
|      |      | ||||||
|  |     if (!Array.isArray(this.feeds)) { | ||||||
|  |       this.feeds = []; | ||||||
|  |     } | ||||||
|  |      | ||||||
|     this.feeds.push(newFeed); |     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})`); |     console.log(`Added new feed: ${newFeed.name} (${newFeed.url})`); | ||||||
|      |      | ||||||
|     return newFeed; |     return newFeed; | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   updateFeedConfig(feedId, updates) { |   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) { |     if (feedIndex === -1) { | ||||||
|  |       console.error(`Feed with ID ${feedId} not found`); | ||||||
|       return false; |       return false; | ||||||
|     } |     } | ||||||
|      |      | ||||||
| @@ -381,28 +640,52 @@ class RssFeedManager { | |||||||
|       added: this.feeds[feedIndex].added |       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}`); |     console.log(`Updated feed: ${this.feeds[feedIndex].name}`); | ||||||
|      |      | ||||||
|     return true; |     return true; | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   removeFeed(feedId) { |   removeFeed(feedId) { | ||||||
|     const initialLength = this.feeds.length; |     if (!feedId || !Array.isArray(this.feeds)) { | ||||||
|     this.feeds = this.feeds.filter(f => f.id !== feedId); |       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() { |   getAllItems() { | ||||||
|     return this.items; |     return Array.isArray(this.items) ? this.items : []; | ||||||
|   } |   } | ||||||
|    |    | ||||||
|   getUndownloadedItems() { |   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) { |   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) { |   async downloadItem(item, transmissionClient) { | ||||||
| @@ -421,7 +704,7 @@ class RssFeedManager { | |||||||
|     } |     } | ||||||
|      |      | ||||||
|     return new Promise((resolve) => { |     return new Promise((resolve) => { | ||||||
|       transmissionClient.addUrl(item.torrentLink, (err, result) => { |       transmissionClient.addUrl(item.torrentLink, async (err, result) => { | ||||||
|         if (err) { |         if (err) { | ||||||
|           console.error(`Error adding torrent for ${item.title}:`, err); |           console.error(`Error adding torrent for ${item.title}:`, err); | ||||||
|           resolve({ |           resolve({ | ||||||
| @@ -437,9 +720,11 @@ class RssFeedManager { | |||||||
|         item.downloadDate = new Date().toISOString(); |         item.downloadDate = new Date().toISOString(); | ||||||
|          |          | ||||||
|         // Save the updated items |         // Save the updated items | ||||||
|         this.saveItems().catch(err => { |         try { | ||||||
|  |           await this.saveItems(); | ||||||
|  |         } catch (err) { | ||||||
|           console.error('Error saving items after download:', err); |           console.error('Error saving items after download:', err); | ||||||
|         }); |         } | ||||||
|          |          | ||||||
|         console.log(`Successfully added torrent for item: ${item.title}`); |         console.log(`Successfully added torrent for item: ${item.title}`); | ||||||
|          |          | ||||||
|   | |||||||
| @@ -3,13 +3,47 @@ | |||||||
|  |  | ||||||
| # Setup systemd service | # Setup systemd service | ||||||
| function setup_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 |   # 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] | [Unit] | ||||||
| Description=Transmission RSS Manager | Description=Transmission RSS Manager | ||||||
| After=network.target | After=network.target transmission-daemon.service | ||||||
| Wants=network-online.target | Wants=network-online.target | ||||||
|  |  | ||||||
| [Service] | [Service] | ||||||
| @@ -23,22 +57,77 @@ StandardOutput=journal | |||||||
| StandardError=journal | StandardError=journal | ||||||
| Environment=PORT=$PORT | Environment=PORT=$PORT | ||||||
| Environment=NODE_ENV=production | 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] | [Install] | ||||||
| WantedBy=multi-user.target | WantedBy=multi-user.target | ||||||
| EOF | EOF | ||||||
|  |  | ||||||
|   # Create nginx configuration for proxy |   # Create logs directory | ||||||
|   echo -e "${YELLOW}Setting up Nginx reverse proxy...${NC}" |   mkdir -p "$INSTALL_DIR/logs" | ||||||
|  |   chown -R $USER:$USER "$INSTALL_DIR/logs" | ||||||
|  |  | ||||||
|   # Check if default nginx file exists, back it up if it does |   # Check if file was created successfully | ||||||
|   if [ -f /etc/nginx/sites-enabled/default ]; then |   if [ ! -f "$SERVICE_FILE" ]; then | ||||||
|     mv /etc/nginx/sites-enabled/default /etc/nginx/sites-enabled/default.bak |     log "ERROR" "Failed to create systemd service file" | ||||||
|     echo "Backed up default nginx configuration." |     return 1 | ||||||
|   fi |   fi | ||||||
|    |    | ||||||
|   # Create nginx configuration |   log "INFO" "Setting up Nginx reverse proxy..." | ||||||
|   cat > /etc/nginx/sites-available/$SERVICE_NAME << EOF |    | ||||||
|  |   # 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 { | server { | ||||||
|     listen 80; |     listen 80; | ||||||
|     server_name _; |     server_name _; | ||||||
| @@ -57,27 +146,36 @@ server { | |||||||
| } | } | ||||||
| EOF | EOF | ||||||
|  |  | ||||||
|   # Create symbolic link to enable the site |   # Check if Debian/Ubuntu style (need symlink between available and enabled) | ||||||
|   ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-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 |   # Test nginx configuration | ||||||
|   nginx -t |   if nginx -t; then | ||||||
|    |  | ||||||
|   if [ $? -eq 0 ]; then |  | ||||||
|     # Reload nginx |     # Reload nginx | ||||||
|     systemctl 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 |   else | ||||||
|     echo -e "${RED}Nginx configuration test failed. Please check the configuration manually.${NC}" |     log "ERROR" "Nginx configuration test failed. Please check the configuration manually." | ||||||
|     echo -e "${YELLOW}You may need to correct the configuration before the web interface will be accessible.${NC}" |     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 |   fi | ||||||
|    |    | ||||||
|   # Reload systemd |   # Reload systemd | ||||||
|   systemctl daemon-reload |   systemctl daemon-reload | ||||||
|    |    | ||||||
|   # Enable the service to start on boot |   # 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}" |   log "INFO" "Systemd service has been created and enabled." | ||||||
|   echo -e "${YELLOW}The service will start automatically after installation.${NC}" |   log "INFO" "The service will start automatically after installation." | ||||||
| } | } | ||||||
							
								
								
									
										517
									
								
								modules/transmission-client.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										517
									
								
								modules/transmission-client.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Object>} 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<Object>} 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>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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<Object>} 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; | ||||||
| @@ -17,10 +17,20 @@ function log() { | |||||||
|     "ERROR") |     "ERROR") | ||||||
|       echo -e "${timestamp} ${RED}[ERROR]${NC} $message" |       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" |       echo -e "${timestamp} [LOG] $message" | ||||||
|       ;; |       ;; | ||||||
|   esac |   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 | # 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)" |     local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" | ||||||
|     cp "$file" "$backup" |     cp "$file" "$backup" | ||||||
|     log "INFO" "Created backup of $file at $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 |   fi | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -58,6 +100,10 @@ function create_dir_if_not_exists() { | |||||||
| function finalize_setup() { | function finalize_setup() { | ||||||
|   log "INFO" "Setting up final permissions and configurations..." |   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 |   # Set proper ownership for the installation directory | ||||||
|   chown -R $USER:$USER $INSTALL_DIR |   chown -R $USER:$USER $INSTALL_DIR | ||||||
|    |    | ||||||
| @@ -77,25 +123,19 @@ function finalize_setup() { | |||||||
|   log "INFO" "Installing NPM packages..." |   log "INFO" "Installing NPM packages..." | ||||||
|   cd $INSTALL_DIR && npm install |   cd $INSTALL_DIR && npm install | ||||||
|    |    | ||||||
|   # Start the service |   # Handle configuration file | ||||||
|   log "INFO" "Starting the service..." |   if ! update_config_file "$INSTALL_DIR/config.json" "$IS_UPDATE"; then | ||||||
|   systemctl daemon-reload |  | ||||||
|   systemctl enable $SERVICE_NAME |  | ||||||
|   systemctl start $SERVICE_NAME |  | ||||||
|    |  | ||||||
|   # Check if service started successfully |  | ||||||
|   sleep 2 |  | ||||||
|   if systemctl is-active --quiet $SERVICE_NAME; then |  | ||||||
|     log "INFO" "Service started successfully!" |  | ||||||
|   else |  | ||||||
|     log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME" |  | ||||||
|   fi |  | ||||||
|    |  | ||||||
|   # Create default configuration if it doesn't exist |  | ||||||
|   if [ ! -f "$INSTALL_DIR/config.json" ]; then |  | ||||||
|     log "INFO" "Creating default configuration file..." |     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 |     cat > $INSTALL_DIR/config.json << EOF | ||||||
| { | { | ||||||
|  |   "version": "1.2.0", | ||||||
|   "transmissionConfig": { |   "transmissionConfig": { | ||||||
|     "host": "${TRANSMISSION_HOST}", |     "host": "${TRANSMISSION_HOST}", | ||||||
|     "port": ${TRANSMISSION_PORT}, |     "port": ${TRANSMISSION_PORT}, | ||||||
| @@ -132,12 +172,38 @@ function finalize_setup() { | |||||||
|     "removeDuplicates": true, |     "removeDuplicates": true, | ||||||
|     "keepOnlyBestVersion": true |     "keepOnlyBestVersion": true | ||||||
|   }, |   }, | ||||||
|  |   "securitySettings": { | ||||||
|  |     "authEnabled": ${AUTH_ENABLED:-false}, | ||||||
|  |     "httpsEnabled": ${HTTPS_ENABLED:-false}, | ||||||
|  |     "sslCertPath": "${SSL_CERT_PATH:-""}", | ||||||
|  |     "sslKeyPath": "${SSL_KEY_PATH:-""}", | ||||||
|  |     "users": [ | ||||||
|  |       ${USER_JSON} | ||||||
|  |     ] | ||||||
|  |   }, | ||||||
|   "rssFeeds": [], |   "rssFeeds": [], | ||||||
|   "rssUpdateIntervalMinutes": 60, |   "rssUpdateIntervalMinutes": 60, | ||||||
|   "autoProcessing": false |   "autoProcessing": false, | ||||||
|  |   "port": ${PORT}, | ||||||
|  |   "logLevel": "info" | ||||||
| } | } | ||||||
| EOF | EOF | ||||||
|     chown $USER:$USER $INSTALL_DIR/config.json |     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 |   fi | ||||||
|    |    | ||||||
|   log "INFO" "Setup finalized!" |   log "INFO" "Setup finalized!" | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										665
									
								
								public/css/styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										665
									
								
								public/css/styles.css
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										1823
									
								
								public/index.html
									
									
									
									
									
								
							
							
						
						
									
										1823
									
								
								public/index.html
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										1650
									
								
								public/js/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1650
									
								
								public/js/app.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										637
									
								
								public/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										637
									
								
								public/js/utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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<boolean>} - 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<string>} 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>} - 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; | ||||||
|  |   } | ||||||
|  | }; | ||||||
							
								
								
									
										165
									
								
								scripts/test-and-start.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										165
									
								
								scripts/test-and-start.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -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 "$@" | ||||||
							
								
								
									
										178
									
								
								scripts/update.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										178
									
								
								scripts/update.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -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}" | ||||||
		Reference in New Issue
	
	Block a user