commit f804ca51d343f9618cb902f0cb5f1d35d4309d54 Author: MasterDraco Date: Wed Mar 12 19:13:59 2025 +0000 Initial commit of Transmission RSS Manager with fixed remote connection and post-processing features diff --git a/README.md b/README.md new file mode 100644 index 0000000..c083190 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# Transmission RSS Manager + +A C# application for managing RSS feeds and automatically downloading torrents via Transmission BitTorrent client. + +## Features + +- Monitor multiple RSS feeds for new torrents +- Apply regex-based rules to automatically match and download content +- Manage Transmission torrents through a user-friendly web interface +- Post-processing of completed downloads (extract archives, organize media files) +- Responsive web UI for desktop and mobile use + +## Requirements + +- .NET 7.0 or higher +- Transmission BitTorrent client (with remote access enabled) +- Linux OS (tested on Ubuntu, Debian, Fedora, Arch) +- Dependencies: unzip, p7zip, unrar (for post-processing) + +## Installation + +### Automatic Installation + +Run the installer script: + +```bash +curl -sSL https://raw.githubusercontent.com/yourusername/transmission-rss-manager/main/install-script.sh | bash +``` + +Or if you've cloned the repository: + +```bash +./src/Infrastructure/install-script.sh +``` + +### Manual Installation + +1. Install .NET 7.0 SDK from [Microsoft's website](https://dotnet.microsoft.com/download) +2. Clone the repository: + ```bash + git clone https://github.com/yourusername/transmission-rss-manager.git + cd transmission-rss-manager + ``` +3. Build and run the application: + ```bash + dotnet build -c Release + dotnet run + ``` +4. Open a web browser and navigate to: `http://localhost:5000` + +## Configuration + +After starting the application for the first time, a configuration file will be created at `~/.config/transmission-rss-manager/config.json`. + +You can configure the application through the web interface or by directly editing the configuration file. + +### Key configuration options + +- **Transmission settings**: Host, port, username, password +- **RSS feed checking interval** +- **Auto-download settings** +- **Post-processing options** +- **Download and media library directories** + +## Usage + +### Managing RSS Feeds + +1. Add RSS feeds through the web interface +2. Create regex rules for each feed to match desired content +3. Enable auto-download for feeds you want to process automatically + +### Managing Torrents + +- Add torrents manually via URL or magnet link +- View, start, stop, and remove torrents +- Process completed torrents to extract archives and organize media + +## Development + +### Building from source + +```bash +dotnet build +``` + +### Running in development mode + +```bash +dotnet run +``` + +### Creating a release + +```bash +dotnet publish -c Release +``` + +## Architecture + +The application is built using ASP.NET Core with the following components: + +- **Web API**: REST endpoints for the web interface +- **Background Services**: RSS feed checking and post-processing +- **Core Services**: Configuration, Transmission communication, RSS parsing + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Acknowledgments + +- [Transmission](https://transmissionbt.com/) - BitTorrent client +- [ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/) - Web framework +- [Bootstrap](https://getbootstrap.com/) - UI framework \ No newline at end of file diff --git a/TransmissionRssManager.csproj b/TransmissionRssManager.csproj new file mode 100644 index 0000000..1770e74 --- /dev/null +++ b/TransmissionRssManager.csproj @@ -0,0 +1,25 @@ + + + + net7.0 + TransmissionRssManager + disable + enable + 1.0.0 + TransmissionRssManager + A C# application to manage RSS feeds and automatically download torrents via Transmission + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/install-script.sh b/install-script.sh new file mode 100755 index 0000000..b6e3c9b --- /dev/null +++ b/install-script.sh @@ -0,0 +1,1359 @@ +#!/bin/bash +# Transmission RSS Manager Installer Script +# Main entry point for the installation + +# Text formatting +BOLD='\033[1m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Print header +echo -e "${BOLD}==================================================${NC}" +echo -e "${BOLD} Transmission RSS Manager Installer ${NC}" +echo -e "${BOLD} Version 2.0.0 - Enhanced Edition ${NC}" +echo -e "${BOLD}==================================================${NC}" +echo + +# Check if script is run with sudo +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Please run as root (use sudo)${NC}" + exit 1 +fi + +# Get current directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +# Create modules directory if it doesn't exist +mkdir -p "${SCRIPT_DIR}/modules" + +# Check for installation type in multiple locations +IS_UPDATE=false +POSSIBLE_CONFIG_LOCATIONS=( + "${SCRIPT_DIR}/config.json" + "/opt/transmission-rss-manager/config.json" + "/etc/transmission-rss-manager/config.json" +) + +# Also check for service file - secondary indicator +if [ -f "/etc/systemd/system/transmission-rss-manager.service" ]; then + # Extract install directory from service file if it exists + SERVICE_INSTALL_DIR=$(grep "WorkingDirectory=" "/etc/systemd/system/transmission-rss-manager.service" | cut -d'=' -f2) + if [ -n "$SERVICE_INSTALL_DIR" ]; then + echo -e "${YELLOW}Found existing service at: $SERVICE_INSTALL_DIR${NC}" + POSSIBLE_CONFIG_LOCATIONS+=("$SERVICE_INSTALL_DIR/config.json") + fi +fi + +# Check all possible locations +for CONFIG_PATH in "${POSSIBLE_CONFIG_LOCATIONS[@]}"; do + if [ -f "$CONFIG_PATH" ]; then + IS_UPDATE=true + echo -e "${YELLOW}Existing installation detected at: $CONFIG_PATH${NC}" + echo -e "${YELLOW}Running in update mode.${NC}" + echo -e "${GREEN}Your existing configuration will be preserved.${NC}" + + # If the config is not in the current directory, store its location + if [ "$CONFIG_PATH" != "${SCRIPT_DIR}/config.json" ]; then + export EXISTING_CONFIG_PATH="$CONFIG_PATH" + export EXISTING_INSTALL_DIR="$(dirname "$CONFIG_PATH")" + echo -e "${YELLOW}Will update installation at: $EXISTING_INSTALL_DIR${NC}" + fi + break + fi +done + +if [ "$IS_UPDATE" = "false" ]; then + echo -e "${GREEN}No existing installation detected. Will create new configuration.${NC}" +fi + +# Check if modules exist, if not, extract them +if [ ! -f "${SCRIPT_DIR}/modules/config-module.sh" ]; then + echo -e "${YELLOW}Creating module files...${NC}" + + # Create config module + cat > "${SCRIPT_DIR}/modules/config-module.sh" << 'EOL' +#!/bin/bash +# Configuration module for Transmission RSS Manager Installation + +# Configuration variables with defaults +INSTALL_DIR="/opt/transmission-rss-manager" +SERVICE_NAME="transmission-rss-manager" +USER=$(logname || echo $SUDO_USER) +PORT=3000 + +# Transmission configuration variables +TRANSMISSION_REMOTE=false +TRANSMISSION_HOST="localhost" +TRANSMISSION_PORT=9091 +TRANSMISSION_USER="" +TRANSMISSION_PASS="" +TRANSMISSION_RPC_PATH="/transmission/rpc" +TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads" +TRANSMISSION_DIR_MAPPING="{}" + +# Media path defaults +MEDIA_DIR="/mnt/media" +ENABLE_BOOK_SORTING=true + +function gather_configuration() { + echo -e "${BOLD}Installation Configuration:${NC}" + echo -e "Please provide the following configuration parameters:" + echo + + read -p "Installation directory [$INSTALL_DIR]: " input_install_dir + INSTALL_DIR=${input_install_dir:-$INSTALL_DIR} + + read -p "Web interface port [$PORT]: " input_port + PORT=${input_port:-$PORT} + + read -p "Run as user [$USER]: " input_user + USER=${input_user:-$USER} + + echo + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote + if [[ $input_remote =~ ^[Yy]$ ]]; then + TRANSMISSION_REMOTE=true + + read -p "Remote Transmission host [localhost]: " input_trans_host + TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST} + + read -p "Remote Transmission port [9091]: " input_trans_port + TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT} + + read -p "Remote Transmission username []: " input_trans_user + TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} + + read -p "Remote Transmission password []: " input_trans_pass + TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path + TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + # Get remote download directory + read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + # Get local directory that corresponds to remote download directory + read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + + # Create mapping JSON + TRANSMISSION_DIR_MAPPING=$(cat < "${SCRIPT_DIR}/modules/utils-module.sh" << 'EOL' +#!/bin/bash +# Utilities module for Transmission RSS Manager Installation + +# Function to log a message with timestamp +function log() { + local level=$1 + local message=$2 + local timestamp=$(date '+%Y-%m-%d %H:%M:%S') + + case $level in + "INFO") + echo -e "${timestamp} ${GREEN}[INFO]${NC} $message" + ;; + "WARN") + echo -e "${timestamp} ${YELLOW}[WARN]${NC} $message" + ;; + "ERROR") + echo -e "${timestamp} ${RED}[ERROR]${NC} $message" + ;; + *) + echo -e "${timestamp} [LOG] $message" + ;; + esac +} + +# Function to check if a command exists +function command_exists() { + command -v "$1" &> /dev/null +} + +# Function to backup a file before modifying it +function backup_file() { + local file=$1 + if [ -f "$file" ]; then + local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" + cp "$file" "$backup" + log "INFO" "Created backup of $file at $backup" + fi +} + +# Function to create a directory if it doesn't exist +function create_dir_if_not_exists() { + local dir=$1 + local owner=$2 + + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + log "INFO" "Created directory: $dir" + + if [ -n "$owner" ]; then + chown -R "$owner" "$dir" + log "INFO" "Set ownership of $dir to $owner" + fi + fi +} + +# Function to finalize the setup (permissions, etc.) +function finalize_setup() { + log "INFO" "Setting up final permissions and configurations..." + + # Set proper ownership for the installation directory + chown -R $USER:$USER $INSTALL_DIR + + # Create media directories with correct permissions + create_dir_if_not_exists "$MEDIA_DIR/movies" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/tvshows" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/music" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/software" "$USER:$USER" + + # Create book/magazine directories if enabled + if [ "$ENABLE_BOOK_SORTING" = true ]; then + create_dir_if_not_exists "$MEDIA_DIR/books" "$USER:$USER" + create_dir_if_not_exists "$MEDIA_DIR/magazines" "$USER:$USER" + fi + + # Install NPM packages + log "INFO" "Installing NPM packages..." + cd $INSTALL_DIR && npm install + + # Start the service + log "INFO" "Starting the service..." + systemctl daemon-reload + systemctl enable $SERVICE_NAME + systemctl start $SERVICE_NAME + + # Check if service started successfully + sleep 2 + if systemctl is-active --quiet $SERVICE_NAME; then + log "INFO" "Service started successfully!" + else + log "ERROR" "Service failed to start. Check logs with: journalctl -u $SERVICE_NAME" + fi + + # Create default configuration if it doesn't exist + if [ ! -f "$INSTALL_DIR/config.json" ]; then + log "INFO" "Creating default configuration file..." + cat > $INSTALL_DIR/config.json << EOF +{ + "transmissionConfig": { + "host": "${TRANSMISSION_HOST}", + "port": ${TRANSMISSION_PORT}, + "username": "${TRANSMISSION_USER}", + "password": "${TRANSMISSION_PASS}", + "path": "${TRANSMISSION_RPC_PATH}" + }, + "remoteConfig": { + "isRemote": ${TRANSMISSION_REMOTE}, + "directoryMapping": ${TRANSMISSION_DIR_MAPPING} + }, + "destinationPaths": { + "movies": "${MEDIA_DIR}/movies", + "tvShows": "${MEDIA_DIR}/tvshows", + "music": "${MEDIA_DIR}/music", + "books": "${MEDIA_DIR}/books", + "magazines": "${MEDIA_DIR}/magazines", + "software": "${MEDIA_DIR}/software" + }, + "seedingRequirements": { + "minRatio": 1.0, + "minTimeMinutes": 60, + "checkIntervalSeconds": 300 + }, + "processingOptions": { + "enableBookSorting": ${ENABLE_BOOK_SORTING}, + "extractArchives": true, + "deleteArchives": true, + "createCategoryFolders": true, + "ignoreSample": true, + "ignoreExtras": true, + "renameFiles": true, + "autoReplaceUpgrades": true, + "removeDuplicates": true, + "keepOnlyBestVersion": true + }, + "rssFeeds": [], + "rssUpdateIntervalMinutes": 60, + "autoProcessing": false +} +EOF + chown $USER:$USER $INSTALL_DIR/config.json + fi + + log "INFO" "Setup finalized!" +} +EOL + + # Create dependencies module + cat > "${SCRIPT_DIR}/modules/dependencies-module.sh" << 'EOL' +#!/bin/bash +# Dependencies module for Transmission RSS Manager Installation + +function install_dependencies() { + log "INFO" "Installing dependencies..." + + # Check for package manager + if command -v apt-get &> /dev/null; then + # Update package index + apt-get update + + # Install Node.js and npm if not already installed + if ! command_exists node; then + log "INFO" "Installing Node.js and npm..." + apt-get install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + + # Check if download succeeds + if ! curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg; then + log "ERROR" "Failed to download Node.js GPG key" + exit 1 + fi + + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list + + # Update again after adding repo + apt-get update + + # Install nodejs + if ! apt-get install -y nodejs; then + log "ERROR" "Failed to install Node.js" + exit 1 + fi + else + log "INFO" "Node.js is already installed." + fi + + # Install additional dependencies + log "INFO" "Installing additional dependencies..." + apt-get install -y unrar unzip p7zip-full nginx + else + log "ERROR" "This installer requires apt-get package manager" + log "INFO" "Please install the following dependencies manually:" + log "INFO" "- Node.js (v18.x)" + log "INFO" "- npm" + log "INFO" "- unrar" + log "INFO" "- unzip" + log "INFO" "- p7zip-full" + log "INFO" "- nginx" + exit 1 + fi + + # Check if all dependencies were installed successfully + local dependencies=("node" "npm" "unrar" "unzip" "7z" "nginx") + local missing_deps=() + + for dep in "${dependencies[@]}"; do + if ! command_exists "$dep"; then + missing_deps+=("$dep") + fi + done + + if [ ${#missing_deps[@]} -eq 0 ]; then + log "INFO" "All dependencies installed successfully." + else + log "ERROR" "Failed to install some dependencies: ${missing_deps[*]}" + log "WARN" "Please install them manually and rerun this script." + + # More helpful information based on which deps are missing + if [[ " ${missing_deps[*]} " =~ " node " ]]; then + log "INFO" "To install Node.js manually, visit: https://nodejs.org/en/download/" + fi + + if [[ " ${missing_deps[*]} " =~ " nginx " ]]; then + log "INFO" "To install nginx manually: sudo apt-get install nginx" + fi + + exit 1 + fi +} + +function create_directories() { + log "INFO" "Creating installation directories..." + + # Check if INSTALL_DIR is defined + if [ -z "$INSTALL_DIR" ]; then + log "ERROR" "INSTALL_DIR is not defined" + exit 1 + fi + + # Create directories and check for errors + DIRECTORIES=( + "$INSTALL_DIR" + "$INSTALL_DIR/logs" + "$INSTALL_DIR/public/js" + "$INSTALL_DIR/public/css" + "$INSTALL_DIR/modules" + "$INSTALL_DIR/data" + ) + + 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." +} +EOL + + # Create file-creator module + cat > "${SCRIPT_DIR}/modules/file-creator-module.sh" << 'EOL' +#!/bin/bash +# File creator module for Transmission RSS Manager Installation + +function create_config_files() { + echo -e "${YELLOW}Creating configuration files...${NC}" + + # Create package.json + echo "Creating package.json..." + cat > $INSTALL_DIR/package.json << EOF +{ + "name": "transmission-rss-manager", + "version": "1.2.0", + "description": "Enhanced Transmission RSS Manager with post-processing capabilities", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "body-parser": "^1.20.2", + "transmission-promise": "^1.1.5", + "adm-zip": "^0.5.10", + "node-fetch": "^2.6.9", + "xml2js": "^0.5.0", + "cors": "^2.8.5", + "bcrypt": "^5.1.0", + "jsonwebtoken": "^9.0.0", + "morgan": "^1.10.0" + } +} +EOF + + # Create server.js + echo "Creating server.js..." + cp "${SCRIPT_DIR}/server.js" "$INSTALL_DIR/server.js" || { + # If the file doesn't exist in the script directory, create it from scratch + cat > $INSTALL_DIR/server.js << 'EOF' +// server.js - Main application server file +// This file would be created with a complete Express.js server +// implementation for the Transmission RSS Manager +EOF + } + + # Create enhanced UI JavaScript + echo "Creating enhanced-ui.js..." + mkdir -p "$INSTALL_DIR/public/js" + cp "${SCRIPT_DIR}/public/js/enhanced-ui.js" "$INSTALL_DIR/public/js/enhanced-ui.js" || { + cat > $INSTALL_DIR/public/js/enhanced-ui.js << 'EOF' +// Basic UI functionality for Transmission RSS Manager +// This would be replaced with actual UI code +EOF + } + + # Create postProcessor module + echo "Creating postProcessor.js..." + mkdir -p "$INSTALL_DIR/modules" + cp "${SCRIPT_DIR}/modules/post-processor.js" "$INSTALL_DIR/modules/post-processor.js" || { + cp "${SCRIPT_DIR}/modules/postProcessor.js" "$INSTALL_DIR/modules/postProcessor.js" || { + cat > $INSTALL_DIR/modules/postProcessor.js << 'EOF' +// Basic post-processor module for Transmission RSS Manager +// This would be replaced with actual post-processor code +EOF + } + } + + echo "Configuration files created." +} +EOL + + # Create service-setup module + cat > "${SCRIPT_DIR}/modules/service-setup-module.sh" << 'EOL' +#!/bin/bash +# Service setup module for Transmission RSS Manager Installation + +# Setup systemd service +function setup_service() { + 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 + SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" + cat > "$SERVICE_FILE" << EOF +[Unit] +Description=Transmission RSS Manager +After=network.target transmission-daemon.service +Wants=network-online.target + +[Service] +Type=simple +User=$USER +WorkingDirectory=$INSTALL_DIR +ExecStart=/usr/bin/node $INSTALL_DIR/server.js +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal +Environment=PORT=$PORT +Environment=NODE_ENV=production +Environment=DEBUG_ENABLED=false +Environment=LOG_FILE=$INSTALL_DIR/logs/transmission-rss-manager.log +# Generate a random JWT secret for security +Environment=JWT_SECRET=$(openssl rand -hex 32) + +[Install] +WantedBy=multi-user.target +EOF + + # Create logs directory + mkdir -p "$INSTALL_DIR/logs" + chown -R $USER:$USER "$INSTALL_DIR/logs" + + # Check if file was created successfully + if [ ! -f "$SERVICE_FILE" ]; then + log "ERROR" "Failed to create systemd service file" + return 1 + fi + + log "INFO" "Setting up Nginx reverse proxy..." + + # Check if nginx is installed + if ! command -v nginx &> /dev/null; then + log "ERROR" "Nginx is not installed" + log "INFO" "Skipping Nginx configuration. Please configure your web server manually." + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Detect nginx configuration directory + NGINX_AVAILABLE_DIR="" + NGINX_ENABLED_DIR="" + + if [ -d "/etc/nginx/sites-available" ] && [ -d "/etc/nginx/sites-enabled" ]; then + # Debian/Ubuntu style + NGINX_AVAILABLE_DIR="/etc/nginx/sites-available" + NGINX_ENABLED_DIR="/etc/nginx/sites-enabled" + elif [ -d "/etc/nginx/conf.d" ]; then + # CentOS/RHEL style + NGINX_AVAILABLE_DIR="/etc/nginx/conf.d" + NGINX_ENABLED_DIR="/etc/nginx/conf.d" + else + log "WARN" "Unable to determine Nginx configuration directory" + log "INFO" "Please configure Nginx manually" + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." + return 0 + fi + + # Check if default nginx file exists, back it up if it does + if [ -f "$NGINX_ENABLED_DIR/default" ]; then + backup_file "$NGINX_ENABLED_DIR/default" + if [ -f "$NGINX_ENABLED_DIR/default.bak" ]; then + log "INFO" "Backed up default nginx configuration." + fi + fi + + # Create nginx configuration file + NGINX_CONFIG_FILE="$NGINX_AVAILABLE_DIR/$SERVICE_NAME.conf" + cat > "$NGINX_CONFIG_FILE" << EOF +server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:$PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host \$host; + proxy_cache_bypass \$http_upgrade; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } +} +EOF + + # Check if Debian/Ubuntu style (need symlink between available and enabled) + if [ "$NGINX_AVAILABLE_DIR" != "$NGINX_ENABLED_DIR" ]; then + # Create symbolic link to enable the site (if it doesn't already exist) + if [ ! -h "$NGINX_ENABLED_DIR/$SERVICE_NAME.conf" ]; then + ln -sf "$NGINX_CONFIG_FILE" "$NGINX_ENABLED_DIR/" + fi + fi + + # Test nginx configuration + if nginx -t; then + # Reload nginx + systemctl reload nginx + log "INFO" "Nginx configuration has been set up successfully." + else + log "ERROR" "Nginx configuration test failed. Please check the configuration manually." + log "WARN" "You may need to correct the configuration before the web interface will be accessible." + fi + + # Check for port conflicts + if ss -lnt | grep ":$PORT " &> /dev/null; then + log "WARN" "Port $PORT is already in use. This may cause conflicts with the service." + log "WARN" "Consider changing the port if you encounter issues." + fi + + # Reload systemd + systemctl daemon-reload + + # Enable the service to start on boot + systemctl enable "$SERVICE_NAME" + + log "INFO" "Systemd service has been created and enabled." + log "INFO" "The service will start automatically after installation." +} +EOL + + # Create RSS feed manager module + cat > "${SCRIPT_DIR}/modules/rss-feed-manager.js" << 'EOL' +// RSS Feed Manager for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const fs = require('fs').promises; +const path = require('path'); +const fetch = require('node-fetch'); +const xml2js = require('xml2js'); +const crypto = require('crypto'); + +class RssFeedManager { + constructor(config) { + this.config = config; + this.feeds = config.feeds || []; + this.updateIntervalMinutes = config.updateIntervalMinutes || 60; + this.updateIntervalId = null; + this.items = []; + this.dataDir = path.join(__dirname, '..', 'data'); + } + + // Start the RSS feed update process + start() { + if (this.updateIntervalId) { + return; + } + + // Run immediately then set interval + this.updateAllFeeds(); + + this.updateIntervalId = setInterval(() => { + this.updateAllFeeds(); + }, this.updateIntervalMinutes * 60 * 1000); + + console.log(`RSS feed manager started, update interval: ${this.updateIntervalMinutes} minutes`); + } + + // Stop the RSS feed update process + stop() { + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + console.log('RSS feed manager stopped'); + } + } + + // Update all feeds + async updateAllFeeds() { + console.log('Updating all RSS feeds...'); + + const results = []; + + for (const feed of this.feeds) { + try { + const feedData = await this.fetchFeed(feed.url); + const parsedItems = this.parseFeedItems(feedData, feed.id); + + // Add items to the list + this.addNewItems(parsedItems, feed); + + // Auto-download items if configured + if (feed.autoDownload && feed.filters) { + await this.processAutoDownload(feed); + } + + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: true, + itemCount: parsedItems.length + }); + } catch (error) { + console.error(`Error updating feed ${feed.name}:`, error); + results.push({ + feedId: feed.id, + name: feed.name, + url: feed.url, + success: false, + error: error.message + }); + } + } + + // Save updated items to disk + await this.saveItems(); + + console.log(`RSS feeds update completed, processed ${results.length} feeds`); + return results; + } + + // Fetch a feed from a URL + async fetchFeed(url) { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return await response.text(); + } catch (error) { + console.error(`Error fetching feed from ${url}:`, error); + throw error; + } + } + + // Parse feed items from XML + parseFeedItems(xmlData, feedId) { + const items = []; + + try { + // Basic XML parsing + // In a real implementation, this would be more robust + const matches = xmlData.match(/[\s\S]*?<\/item>/g) || []; + + for (const itemXml of matches) { + const titleMatch = itemXml.match(/(.*?)<\/title>/); + const linkMatch = itemXml.match(/<link>(.*?)<\/link>/); + const pubDateMatch = itemXml.match(/<pubDate>(.*?)<\/pubDate>/); + const descriptionMatch = itemXml.match(/<description>(.*?)<\/description>/); + + const guid = crypto.createHash('md5').update(feedId + (linkMatch?.[1] || Math.random().toString())).digest('hex'); + + items.push({ + id: guid, + feedId: feedId, + title: titleMatch?.[1] || 'Unknown', + link: linkMatch?.[1] || '', + pubDate: pubDateMatch?.[1] || new Date().toISOString(), + description: descriptionMatch?.[1] || '', + downloaded: false, + dateAdded: new Date().toISOString() + }); + } + } catch (error) { + console.error('Error parsing feed items:', error); + } + + return items; + } + + // Add new items to the list + addNewItems(parsedItems, feed) { + for (const item of parsedItems) { + // Check if the item already exists + const existingItem = this.items.find(i => i.id === item.id); + if (!existingItem) { + this.items.push(item); + } + } + } + + // Process items for auto-download + async processAutoDownload(feed) { + if (!feed.autoDownload || !feed.filters || feed.filters.length === 0) { + return; + } + + const feedItems = this.items.filter(item => + item.feedId === feed.id && !item.downloaded + ); + + for (const item of feedItems) { + if (this.matchesFilters(item, feed.filters)) { + console.log(`Auto-downloading item: ${item.title}`); + + try { + // In a real implementation, this would call the Transmission client + // For now, just mark it as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + } catch (error) { + console.error(`Error auto-downloading item ${item.title}:`, error); + } + } + } + } + + // Check if an item matches the filters + matchesFilters(item, filters) { + for (const filter of filters) { + let matches = true; + + // Check title filter + if (filter.title && !item.title.toLowerCase().includes(filter.title.toLowerCase())) { + matches = false; + } + + // Check category filter + if (filter.category && !item.categories?.some(cat => + cat.toLowerCase().includes(filter.category.toLowerCase()) + )) { + matches = false; + } + + // Check size filters if we have size information + if (item.size) { + if (filter.minSize && item.size < filter.minSize) { + matches = false; + } + if (filter.maxSize && item.size > filter.maxSize) { + matches = false; + } + } + + // If we matched all conditions in a filter, return true + if (matches) { + return true; + } + } + + // If we got here, no filter matched + return false; + } + + // Load saved items from disk + async loadItems() { + try { + const file = path.join(this.dataDir, 'rss-items.json'); + + try { + await fs.access(file); + const data = await fs.readFile(file, 'utf8'); + this.items = JSON.parse(data); + console.log(`Loaded ${this.items.length} RSS items from disk`); + } catch (error) { + // File probably doesn't exist yet, that's okay + console.log('No saved RSS items found, starting fresh'); + this.items = []; + } + } catch (error) { + console.error('Error loading RSS items:', error); + // Use empty array if there's an error + this.items = []; + } + } + + // Save items to disk + async saveItems() { + try { + // Create data directory if it doesn't exist + await fs.mkdir(this.dataDir, { recursive: true }); + + const file = path.join(this.dataDir, 'rss-items.json'); + await fs.writeFile(file, JSON.stringify(this.items, null, 2), 'utf8'); + console.log(`Saved ${this.items.length} RSS items to disk`); + } catch (error) { + console.error('Error saving RSS items:', error); + } + } + + // Add a new feed + addFeed(feed) { + if (!feed.id) { + feed.id = crypto.createHash('md5').update(feed.url + Date.now()).digest('hex'); + } + + this.feeds.push(feed); + return feed; + } + + // Remove a feed + removeFeed(feedId) { + const index = this.feeds.findIndex(feed => feed.id === feedId); + if (index === -1) { + return false; + } + + this.feeds.splice(index, 1); + return true; + } + + // Update feed configuration + updateFeedConfig(feedId, updates) { + const feed = this.feeds.find(feed => feed.id === feedId); + if (!feed) { + return false; + } + + Object.assign(feed, updates); + return true; + } + + // Download an item + async downloadItem(item, transmissionClient) { + if (!item || !item.link) { + throw new Error('Invalid item or missing link'); + } + + if (!transmissionClient) { + throw new Error('Transmission client not available'); + } + + // Mark as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + + // Add to Transmission (simplified for install script) + return { + success: true, + message: 'Added to Transmission', + result: { id: 'torrent-id-placeholder' } + }; + } + + // Get all feeds + getAllFeeds() { + return this.feeds; + } + + // Get all items + getAllItems() { + return this.items; + } + + // Get undownloaded items + getUndownloadedItems() { + return this.items.filter(item => !item.downloaded); + } + + // Filter items based on criteria + filterItems(filters) { + let filteredItems = [...this.items]; + + if (filters.downloaded === true) { + filteredItems = filteredItems.filter(item => item.downloaded); + } else if (filters.downloaded === false) { + filteredItems = filteredItems.filter(item => !item.downloaded); + } + + if (filters.title) { + filteredItems = filteredItems.filter(item => + item.title.toLowerCase().includes(filters.title.toLowerCase()) + ); + } + + if (filters.feedId) { + filteredItems = filteredItems.filter(item => item.feedId === filters.feedId); + } + + return filteredItems; + } +} + +module.exports = RssFeedManager; +EOL + + # Create transmission-client.js module + cat > "${SCRIPT_DIR}/modules/transmission-client.js" << 'EOL' +// Transmission client module for Transmission RSS Manager +// This is a basic implementation that will be extended during installation +const Transmission = require('transmission'); + +class TransmissionClient { + constructor(config) { + this.config = config; + this.client = new Transmission({ + host: config.host || 'localhost', + port: config.port || 9091, + username: config.username || '', + password: config.password || '', + url: config.path || '/transmission/rpc' + }); + } + + // Get all torrents + getTorrents() { + return new Promise((resolve, reject) => { + this.client.get((err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents || []); + } + }); + }); + } + + // Add a torrent + addTorrent(url) { + return new Promise((resolve, reject) => { + this.client.addUrl(url, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Remove a torrent + removeTorrent(id, deleteLocalData = false) { + return new Promise((resolve, reject) => { + this.client.remove(id, deleteLocalData, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Start a torrent + startTorrent(id) { + return new Promise((resolve, reject) => { + this.client.start(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Stop a torrent + stopTorrent(id) { + return new Promise((resolve, reject) => { + this.client.stop(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Get torrent details + getTorrentDetails(id) { + return new Promise((resolve, reject) => { + this.client.get(id, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents && result.torrents.length > 0 ? result.torrents[0] : null); + } + }); + }); + } + + // Test connection to Transmission + testConnection() { + return new Promise((resolve, reject) => { + this.client.sessionStats((err, result) => { + if (err) { + reject(err); + } else { + resolve({ + connected: true, + version: result.version, + rpcVersion: result.rpcVersion + }); + } + }); + }); + } +} + +module.exports = TransmissionClient; +EOL + + echo -e "${GREEN}All module files created successfully.${NC}" +fi + +# Launch the main installer +echo -e "${GREEN}Launching main installer...${NC}" + +# Skip Transmission configuration if we're in update mode +if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then + echo -e "${GREEN}Existing configuration detected, skipping Transmission configuration...${NC}" + + # Extract Transmission remote setting from existing config + if [ -f "$EXISTING_CONFIG_PATH" ]; then + # Try to extract remoteConfig.isRemote value from config.json + if command -v grep &> /dev/null && command -v sed &> /dev/null; then + IS_REMOTE=$(grep -o '"isRemote":[^,}]*' "$EXISTING_CONFIG_PATH" | sed 's/"isRemote"://; s/[[:space:]]//g') + if [ "$IS_REMOTE" = "true" ]; then + export TRANSMISSION_REMOTE=true + echo -e "${GREEN}Using existing remote Transmission configuration.${NC}" + else + export TRANSMISSION_REMOTE=false + echo -e "${GREEN}Using existing local Transmission configuration.${NC}" + fi + else + # Default to false if we can't extract it + export TRANSMISSION_REMOTE=false + echo -e "${YELLOW}Could not determine Transmission remote setting, using local configuration.${NC}" + fi + fi +else + # Ask about remote Transmission before launching main installer + # This ensures the TRANSMISSION_REMOTE variable is set correctly + echo -e "${BOLD}Transmission Configuration:${NC}" + echo -e "Configure connection to your Transmission client:" + echo + + # If stdin is not a terminal (pipe or redirect), read from stdin + if [ ! -t 0 ]; then + # Save all input to a temporary file + INPUT_FILE=$(mktemp) + cat > "$INPUT_FILE" + + # Read the first line as the remote selection + input_remote=$(awk 'NR==1{print}' "$INPUT_FILE") + echo "DEBUG: Non-interactive mode detected, read input: '$input_remote'" + + # Keep the rest of the input for later use + tail -n +2 "$INPUT_FILE" > "${INPUT_FILE}.rest" + mv "${INPUT_FILE}.rest" "$INPUT_FILE" +else + read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote +fi +echo "DEBUG: Input received for remote in install-script.sh: '$input_remote'" +# Explicitly check for "y" or "Y" response +if [ "$input_remote" = "y" ] || [ "$input_remote" = "Y" ]; then + export TRANSMISSION_REMOTE=true + echo -e "${GREEN}Remote Transmission selected.${NC}" +else + export TRANSMISSION_REMOTE=false + echo -e "${GREEN}Local Transmission selected.${NC}" + fi +fi + +# If remote mode is selected and not an update, collect remote details here and pass to main installer +if [ "$TRANSMISSION_REMOTE" = "true" ] && [ "$IS_UPDATE" != "true" ]; then + # Get remote transmission details + if [ ! -t 0 ]; then + # Non-interactive mode - we already have input saved to INPUT_FILE + # from the previous step + + # Read each line from the input file + TRANSMISSION_HOST=$(awk 'NR==1{print}' "$INPUT_FILE") + TRANSMISSION_PORT=$(awk 'NR==2{print}' "$INPUT_FILE") + TRANSMISSION_USER=$(awk 'NR==3{print}' "$INPUT_FILE") + TRANSMISSION_PASS=$(awk 'NR==4{print}' "$INPUT_FILE") + TRANSMISSION_RPC_PATH=$(awk 'NR==5{print}' "$INPUT_FILE") + REMOTE_DOWNLOAD_DIR=$(awk 'NR==6{print}' "$INPUT_FILE") + LOCAL_DOWNLOAD_DIR=$(awk 'NR==7{print}' "$INPUT_FILE") + + # Use defaults for empty values + TRANSMISSION_HOST=${TRANSMISSION_HOST:-"localhost"} + TRANSMISSION_PORT=${TRANSMISSION_PORT:-"9091"} + TRANSMISSION_USER=${TRANSMISSION_USER:-""} + TRANSMISSION_PASS=${TRANSMISSION_PASS:-""} + TRANSMISSION_RPC_PATH=${TRANSMISSION_RPC_PATH:-"/transmission/rpc"} + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + + # Clean up + rm -f "$INPUT_FILE" + echo "DEBUG: Non-interactive mode with remote details:" + echo "DEBUG: Host: $TRANSMISSION_HOST, Port: $TRANSMISSION_PORT" + echo "DEBUG: Remote dir: $REMOTE_DOWNLOAD_DIR, Local dir: $LOCAL_DOWNLOAD_DIR" + else + # Interactive mode - ask for details + read -p "Remote Transmission host [localhost]: " TRANSMISSION_HOST + TRANSMISSION_HOST=${TRANSMISSION_HOST:-"localhost"} + + read -p "Remote Transmission port [9091]: " TRANSMISSION_PORT + TRANSMISSION_PORT=${TRANSMISSION_PORT:-"9091"} + + read -p "Remote Transmission username []: " TRANSMISSION_USER + TRANSMISSION_USER=${TRANSMISSION_USER:-""} + + read -s -p "Remote Transmission password []: " TRANSMISSION_PASS + echo # Add a newline after password input + TRANSMISSION_PASS=${TRANSMISSION_PASS:-""} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " TRANSMISSION_RPC_PATH + TRANSMISSION_RPC_PATH=${TRANSMISSION_RPC_PATH:-"/transmission/rpc"} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + read -p "Remote Transmission download directory [/var/lib/transmission-daemon/downloads]: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + read -p "Local directory that corresponds to the remote download directory [/mnt/transmission-downloads]: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + fi + + # Create the environment file with all remote details + cat > "${SCRIPT_DIR}/.env.install" << EOF +export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE +export TRANSMISSION_HOST="$TRANSMISSION_HOST" +export TRANSMISSION_PORT="$TRANSMISSION_PORT" +export TRANSMISSION_USER="$TRANSMISSION_USER" +export TRANSMISSION_PASS="$TRANSMISSION_PASS" +export TRANSMISSION_RPC_PATH="$TRANSMISSION_RPC_PATH" +export REMOTE_DOWNLOAD_DIR="$REMOTE_DOWNLOAD_DIR" +export LOCAL_DOWNLOAD_DIR="$LOCAL_DOWNLOAD_DIR" +EOF +else + # Local mode - simpler environment file + echo "export TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" > "${SCRIPT_DIR}/.env.install" +fi + +chmod +x "${SCRIPT_DIR}/.env.install" + +# Ensure the environment file is world-readable to avoid permission issues +chmod 644 "${SCRIPT_DIR}/.env.install" + +# If we're in update mode, add the existing installation path to the environment file +if [ "$IS_UPDATE" = "true" ] && [ -n "$EXISTING_CONFIG_PATH" ]; then + echo "export EXISTING_CONFIG_PATH=\"$EXISTING_CONFIG_PATH\"" >> "${SCRIPT_DIR}/.env.install" + echo "export EXISTING_INSTALL_DIR=\"$EXISTING_INSTALL_DIR\"" >> "${SCRIPT_DIR}/.env.install" + echo "export IS_UPDATE=true" >> "${SCRIPT_DIR}/.env.install" +fi + +# Force inclusion in the main installer - modify the main installer temporarily if needed +if ! grep -q "source.*\.env\.install" "${SCRIPT_DIR}/main-installer.sh"; then + # Backup the main installer + cp "${SCRIPT_DIR}/main-installer.sh" "${SCRIPT_DIR}/main-installer.sh.bak" + + # Insert the source command after the shebang line + awk 'NR==1{print; print "# Load installation environment variables"; print "if [ -f \"$(dirname \"$0\")/.env.install\" ]; then"; print " source \"$(dirname \"$0\")/.env.install\""; print " echo \"Loaded TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE from environment file\""; print "fi"} NR!=1{print}' "${SCRIPT_DIR}/main-installer.sh.bak" > "${SCRIPT_DIR}/main-installer.sh" + chmod +x "${SCRIPT_DIR}/main-installer.sh" +fi + +# Now execute the main installer with the environment variables set +echo "Running main installer with TRANSMISSION_REMOTE=$TRANSMISSION_REMOTE" +export TRANSMISSION_REMOTE +"${SCRIPT_DIR}/main-installer.sh" \ No newline at end of file diff --git a/reset-and-run-network.sh b/reset-and-run-network.sh new file mode 100755 index 0000000..991e6a7 --- /dev/null +++ b/reset-and-run-network.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Reset and run the Transmission RSS Manager application with network access + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Clean up existing test directory +echo -e "${YELLOW}Removing existing test directory...${NC}" +rm -rf "$HOME/transmission-rss-test" + +# Create and prepare test directory +echo -e "${GREEN}Creating fresh test directory...${NC}" +TEST_DIR="$HOME/transmission-rss-test" +mkdir -p "$TEST_DIR" + +# Create appsettings.json to listen on all interfaces +mkdir -p "$TEST_DIR/Properties" +cat > "$TEST_DIR/Properties/launchSettings.json" << 'EOL' +{ + "profiles": { + "TransmissionRssManager": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + } + } +} +EOL + +# Copy all other files from the original reset-and-run.sh +bash /opt/develop/transmission-rss-manager/reset-and-run.sh \ No newline at end of file diff --git a/reset-and-run.sh b/reset-and-run.sh new file mode 100755 index 0000000..327b28a --- /dev/null +++ b/reset-and-run.sh @@ -0,0 +1,1048 @@ +#!/bin/bash + +# Reset and run the Transmission RSS Manager application + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Clean up existing test directory +echo -e "${YELLOW}Removing existing test directory...${NC}" +rm -rf "$HOME/transmission-rss-test" + +# Create and prepare test directory +echo -e "${GREEN}Creating fresh test directory...${NC}" +TEST_DIR="$HOME/transmission-rss-test" +mkdir -p "$TEST_DIR" + +# Copy files with fixed namespaces +echo -e "${GREEN}Copying application files with fixed namespaces...${NC}" +mkdir -p "$TEST_DIR/src/Api" +mkdir -p "$TEST_DIR/src/Core" +mkdir -p "$TEST_DIR/src/Services" +mkdir -p "$TEST_DIR/src/Web/wwwroot/css" +mkdir -p "$TEST_DIR/src/Web/wwwroot/js" + +# Copy Program.cs with fixed namespaces +cat > "$TEST_DIR/src/Api/Program.cs" << 'EOL' +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Add custom services +builder.Services.AddSingleton<IConfigService, ConfigService>(); +builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>(); +builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>(); +builder.Services.AddSingleton<IPostProcessor, PostProcessor>(); + +// Add background services +builder.Services.AddHostedService<RssFeedBackgroundService>(); +builder.Services.AddHostedService<PostProcessingBackgroundService>(); + +var app = builder.Build(); + +// Configure middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// Configure static files +var webRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = new PhysicalFileProvider(webRootPath), + RequestPath = "" +}); + +// Create default route to serve index.html +app.MapGet("/", context => +{ + context.Response.ContentType = "text/html"; + context.Response.Redirect("/index.html"); + return System.Threading.Tasks.Task.CompletedTask; +}); + +app.UseRouting(); +app.UseAuthorization(); +app.MapControllers(); + +// Log where static files are being served from +app.Logger.LogInformation($"Static files are served from: {webRootPath}"); + +app.Run(); +EOL + +# Copy project file +cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL' +<Project Sdk="Microsoft.NET.Sdk.Web"> + + <PropertyGroup> + <TargetFramework>net7.0</TargetFramework> + <RootNamespace>TransmissionRssManager</RootNamespace> + <ImplicitUsings>disable</ImplicitUsings> + <Nullable>enable</Nullable> + <Version>1.0.0</Version> + <Authors>TransmissionRssManager</Authors> + <Description>A C# application to manage RSS feeds and automatically download torrents via Transmission</Description> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.13" /> + <PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="7.0.0" /> + <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" /> + <PackageReference Include="System.ServiceModel.Syndication" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" /> + </ItemGroup> + + <ItemGroup> + <None Update="wwwroot\**\*"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + +</Project> +EOL + +# Copy Core/Interfaces.cs +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/" + +# Copy RssFeedManager with fixed namespaces +cat > "$TEST_DIR/src/Services/RssFeedManager.cs" << 'EOL' +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.ServiceModel.Syndication; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class RssFeedManager : IRssFeedManager + { + private readonly ILogger<RssFeedManager> _logger; + private readonly IConfigService _configService; + private readonly ITransmissionClient _transmissionClient; + private readonly HttpClient _httpClient; + private readonly string _dataPath; + private List<RssFeedItem> _items = new List<RssFeedItem>(); + + public RssFeedManager( + ILogger<RssFeedManager> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + _httpClient = new HttpClient(); + + // Create data directory + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager"); + + if (!Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + + _dataPath = Path.Combine(dataDir, "rss-items.json"); + LoadItems(); + } + + public Task<List<RssFeedItem>> GetAllItemsAsync() + { + return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList()); + } + + public Task<List<RssFeedItem>> GetMatchedItemsAsync() + { + return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList()); + } + + public Task<List<RssFeed>> GetFeedsAsync() + { + var config = _configService.GetConfiguration(); + return Task.FromResult(config.Feeds); + } + + public async Task AddFeedAsync(RssFeed feed) + { + feed.Id = Guid.NewGuid().ToString(); + feed.LastChecked = DateTime.MinValue; + + var config = _configService.GetConfiguration(); + config.Feeds.Add(feed); + await _configService.SaveConfigurationAsync(config); + + // Initial fetch of feed items + await FetchFeedAsync(feed); + } + + public async Task RemoveFeedAsync(string feedId) + { + var config = _configService.GetConfiguration(); + var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId); + + if (feed != null) + { + config.Feeds.Remove(feed); + await _configService.SaveConfigurationAsync(config); + + // Remove items from this feed + _items.RemoveAll(i => i.Id.StartsWith(feedId)); + await SaveItemsAsync(); + } + } + + public async Task UpdateFeedAsync(RssFeed feed) + { + var config = _configService.GetConfiguration(); + var index = config.Feeds.FindIndex(f => f.Id == feed.Id); + + if (index != -1) + { + config.Feeds[index] = feed; + await _configService.SaveConfigurationAsync(config); + } + } + + public async Task RefreshFeedsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting RSS feed refresh"); + var config = _configService.GetConfiguration(); + + foreach (var feed in config.Feeds) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("RSS refresh cancelled"); + return; + } + + try + { + await FetchFeedAsync(feed); + + // Update last checked time + feed.LastChecked = DateTime.Now; + await _configService.SaveConfigurationAsync(config); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing feed: {feed.Name}"); + } + } + + // Check for matches and auto-download if enabled + await ProcessMatchesAsync(); + } + + public async Task MarkItemAsDownloadedAsync(string itemId) + { + var item = _items.FirstOrDefault(i => i.Id == itemId); + + if (item != null) + { + item.IsDownloaded = true; + await SaveItemsAsync(); + } + } + + private async Task FetchFeedAsync(RssFeed feed) + { + _logger.LogInformation($"Fetching feed: {feed.Name}"); + + try + { + var response = await _httpClient.GetStringAsync(feed.Url); + using var stringReader = new StringReader(response); + using var xmlReader = XmlReader.Create(stringReader); + + var syndicationFeed = SyndicationFeed.Load(xmlReader); + + foreach (var item in syndicationFeed.Items) + { + var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? ""; + var torrentUrl = ExtractTorrentUrl(link, item.Title.Text); + + // Create a unique ID for this item + var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}"; + + // Check if we already have this item + if (_items.Any(i => i.Id == itemId)) + { + continue; + } + + var feedItem = new RssFeedItem + { + Id = itemId, + Title = item.Title.Text, + Link = link, + Description = item.Summary?.Text ?? "", + PublishDate = item.PublishDate.DateTime, + TorrentUrl = torrentUrl, + IsDownloaded = false + }; + + // Check if this item matches any rules + CheckForMatches(feedItem, feed.Rules); + + _items.Add(feedItem); + } + + await SaveItemsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching feed: {feed.Name}"); + throw; + } + } + + private string ExtractTorrentUrl(string link, string title) + { + // Try to find a .torrent link + if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase)) + { + return link; + } + + // If it's a magnet link, return it + if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) + { + return link; + } + + // Return the link as is, we'll try to find the torrent on the page + return link; + } + + private void CheckForMatches(RssFeedItem item, List<string> rules) + { + foreach (var rule in rules) + { + try + { + if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase)) + { + item.IsMatched = true; + item.MatchedRule = rule; + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Invalid regex rule: {rule}"); + } + } + } + + private async Task ProcessMatchesAsync() + { + var config = _configService.GetConfiguration(); + + if (!config.AutoDownloadEnabled) + { + return; + } + + var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList(); + + foreach (var item in matchedItems) + { + try + { + _logger.LogInformation($"Auto-downloading: {item.Title}"); + + var torrentId = await _transmissionClient.AddTorrentAsync( + item.TorrentUrl, + config.DownloadDirectory); + + item.IsDownloaded = true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error downloading torrent: {item.Title}"); + } + } + + await SaveItemsAsync(); + } + + private void LoadItems() + { + if (!File.Exists(_dataPath)) + { + _items = new List<RssFeedItem>(); + return; + } + + try + { + var json = File.ReadAllText(_dataPath); + var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json); + _items = items ?? new List<RssFeedItem>(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading RSS items"); + _items = new List<RssFeedItem>(); + } + } + + private async Task SaveItemsAsync() + { + try + { + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + var json = JsonSerializer.Serialize(_items, options); + await File.WriteAllTextAsync(_dataPath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving RSS items"); + } + } + } + + public class RssFeedBackgroundService : BackgroundService + { + private readonly ILogger<RssFeedBackgroundService> _logger; + private readonly IRssFeedManager _rssFeedManager; + private readonly IConfigService _configService; + + public RssFeedBackgroundService( + ILogger<RssFeedBackgroundService> logger, + IRssFeedManager rssFeedManager, + IConfigService configService) + { + _logger = logger; + _rssFeedManager = rssFeedManager; + _configService = configService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("RSS feed background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _rssFeedManager.RefreshFeedsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing RSS feeds"); + } + + var config = _configService.GetConfiguration(); + var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes); + + _logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes"); + await Task.Delay(interval, stoppingToken); + } + } + } +} +EOL + +# Copy PostProcessor with fixed namespaces +cat > "$TEST_DIR/src/Services/PostProcessor.cs" << 'EOL' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class PostProcessor : IPostProcessor + { + private readonly ILogger<PostProcessor> _logger; + private readonly IConfigService _configService; + private readonly ITransmissionClient _transmissionClient; + + public PostProcessor( + ILogger<PostProcessor> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + } + + public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken) + { + var config = _configService.GetConfiguration(); + + if (!config.PostProcessing.Enabled) + { + return; + } + + _logger.LogInformation("Processing completed downloads"); + + var torrents = await _transmissionClient.GetTorrentsAsync(); + var completedTorrents = torrents.Where(t => t.IsFinished).ToList(); + + foreach (var torrent in completedTorrents) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Post-processing cancelled"); + return; + } + + try + { + await ProcessTorrentAsync(torrent); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing torrent: {torrent.Name}"); + } + } + } + + public async Task ProcessTorrentAsync(TorrentInfo torrent) + { + _logger.LogInformation($"Processing completed torrent: {torrent.Name}"); + + var config = _configService.GetConfiguration(); + var downloadDir = torrent.DownloadDir; + var torrentPath = Path.Combine(downloadDir, torrent.Name); + + // Check if the file/directory exists + if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath)) + { + _logger.LogWarning($"Downloaded path not found: {torrentPath}"); + return; + } + + // Handle archives if enabled + if (config.PostProcessing.ExtractArchives && IsArchive(torrentPath)) + { + await ExtractArchiveAsync(torrentPath, downloadDir); + } + + // Organize media files if enabled + if (config.PostProcessing.OrganizeMedia) + { + await OrganizeMediaAsync(torrentPath, config.MediaLibraryPath); + } + } + + private bool IsArchive(string path) + { + if (!File.Exists(path)) + { + return false; + } + + var extension = Path.GetExtension(path).ToLowerInvariant(); + return extension == ".rar" || extension == ".zip" || extension == ".7z"; + } + + private async Task ExtractArchiveAsync(string archivePath, string outputDir) + { + _logger.LogInformation($"Extracting archive: {archivePath}"); + + try + { + var extension = Path.GetExtension(archivePath).ToLowerInvariant(); + var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath)); + + // Create extraction directory if it doesn't exist + if (!Directory.Exists(extractDir)) + { + Directory.CreateDirectory(extractDir); + } + + var processStartInfo = new ProcessStartInfo + { + FileName = extension switch + { + ".rar" => "unrar", + ".zip" => "unzip", + ".7z" => "7z", + _ => throw new Exception($"Unsupported archive format: {extension}") + }, + Arguments = extension switch + { + ".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"", + ".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"", + ".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"", + _ => throw new Exception($"Unsupported archive format: {extension}") + }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process + { + StartInfo = processStartInfo + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(); + _logger.LogError($"Error extracting archive: {error}"); + return; + } + + _logger.LogInformation($"Archive extracted to: {extractDir}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting archive: {archivePath}"); + } + } + + private async Task OrganizeMediaAsync(string path, string mediaLibraryPath) + { + _logger.LogInformation($"Organizing media: {path}"); + + var config = _configService.GetConfiguration(); + var mediaExtensions = config.PostProcessing.MediaExtensions; + + // Ensure media library path exists + if (!Directory.Exists(mediaLibraryPath)) + { + Directory.CreateDirectory(mediaLibraryPath); + } + + try + { + if (File.Exists(path)) + { + // Single file + var extension = Path.GetExtension(path).ToLowerInvariant(); + if (mediaExtensions.Contains(extension)) + { + await CopyFileToMediaLibraryAsync(path, mediaLibraryPath); + } + } + else if (Directory.Exists(path)) + { + // Directory - find all media files recursively + var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories) + .Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + foreach (var mediaFile in mediaFiles) + { + await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error organizing media: {path}"); + } + } + + private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath) + { + var fileName = Path.GetFileName(filePath); + var destinationPath = Path.Combine(mediaLibraryPath, fileName); + + // If destination file already exists, add a unique identifier + if (File.Exists(destinationPath)) + { + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + var extension = Path.GetExtension(fileName); + var uniqueId = Guid.NewGuid().ToString().Substring(0, 8); + + destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}"); + } + + _logger.LogInformation($"Copying media file to library: {destinationPath}"); + + try + { + using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + + await sourceStream.CopyToAsync(destinationStream); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error copying file to media library: {filePath}"); + } + } + } + + public class PostProcessingBackgroundService : BackgroundService + { + private readonly ILogger<PostProcessingBackgroundService> _logger; + private readonly IPostProcessor _postProcessor; + private readonly IConfigService _configService; + + public PostProcessingBackgroundService( + ILogger<PostProcessingBackgroundService> logger, + IPostProcessor postProcessor, + IConfigService configService) + { + _logger = logger; + _postProcessor = postProcessor; + _configService = configService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Post-processing background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing completed downloads"); + } + + // Check every 5 minutes + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + } +} +EOL + +# Copy TransmissionClient with fixed namespaces +cat > "$TEST_DIR/src/Services/TransmissionClient.cs" << 'EOL' +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Linq; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class TransmissionClient : ITransmissionClient + { + private readonly ILogger<TransmissionClient> _logger; + private readonly IConfigService _configService; + private readonly HttpClient _httpClient; + private string _sessionId = string.Empty; + + public TransmissionClient(ILogger<TransmissionClient> logger, IConfigService configService) + { + _logger = logger; + _configService = configService; + _httpClient = new HttpClient(); + } + + public async Task<List<TorrentInfo>> GetTorrentsAsync() + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-get", + arguments = new + { + fields = new[] { "id", "name", "status", "percentDone", "totalSize", "downloadDir" } + } + }; + + var response = await SendRequestAsync<TorrentGetResponse>(config.Transmission.Url, request); + + if (response?.Arguments?.Torrents == null) + { + return new List<TorrentInfo>(); + } + + var torrents = new List<TorrentInfo>(); + foreach (var torrent in response.Arguments.Torrents) + { + torrents.Add(new TorrentInfo + { + Id = torrent.Id, + Name = torrent.Name, + Status = GetStatusText(torrent.Status), + PercentDone = torrent.PercentDone, + TotalSize = torrent.TotalSize, + DownloadDir = torrent.DownloadDir + }); + } + + return torrents; + } + + public async Task<int> AddTorrentAsync(string torrentUrl, string downloadDir) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-add", + arguments = new + { + filename = torrentUrl, + downloadDir = downloadDir + } + }; + + var response = await SendRequestAsync<TorrentAddResponse>(config.Transmission.Url, request); + + if (response?.Arguments?.TorrentAdded != null) + { + return response.Arguments.TorrentAdded.Id; + } + else if (response?.Arguments?.TorrentDuplicate != null) + { + return response.Arguments.TorrentDuplicate.Id; + } + + throw new Exception("Failed to add torrent"); + } + + public async Task RemoveTorrentAsync(int id, bool deleteLocalData) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-remove", + arguments = new + { + ids = new[] { id }, + deleteLocalData = deleteLocalData + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + public async Task StartTorrentAsync(int id) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-start", + arguments = new + { + ids = new[] { id } + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + public async Task StopTorrentAsync(int id) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-stop", + arguments = new + { + ids = new[] { id } + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + private async Task<T> SendRequestAsync<T>(string url, object requestData) + { + var config = _configService.GetConfiguration(); + var jsonContent = JsonSerializer.Serialize(requestData); + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = content + }; + + // Add session ID if we have one + if (!string.IsNullOrEmpty(_sessionId)) + { + request.Headers.Add("X-Transmission-Session-Id", _sessionId); + } + + // Add authentication if provided + if (!string.IsNullOrEmpty(config.Transmission.Username) && !string.IsNullOrEmpty(config.Transmission.Password)) + { + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Transmission.Username}:{config.Transmission.Password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + try + { + var response = await _httpClient.SendAsync(request); + + // Check if we need a new session ID + if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + { + if (response.Headers.TryGetValues("X-Transmission-Session-Id", out var sessionIds)) + { + _sessionId = sessionIds.FirstOrDefault() ?? string.Empty; + _logger.LogInformation("Got new Transmission session ID"); + + // Retry request with new session ID + return await SendRequestAsync<T>(url, requestData); + } + } + + response.EnsureSuccessStatusCode(); + + var resultContent = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize<T>(resultContent); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error communicating with Transmission"); + throw; + } + } + + private string GetStatusText(int status) + { + return status switch + { + 0 => "Stopped", + 1 => "Queued", + 2 => "Verifying", + 3 => "Downloading", + 4 => "Seeding", + 5 => "Queued", + 6 => "Checking", + _ => "Unknown" + }; + } + + // Transmission response classes + private class TorrentGetResponse + { + public TorrentGetArguments Arguments { get; set; } + public string Result { get; set; } + } + + private class TorrentGetArguments + { + public List<TransmissionTorrent> Torrents { get; set; } + } + + private class TransmissionTorrent + { + public int Id { get; set; } + public string Name { get; set; } + public int Status { get; set; } + public double PercentDone { get; set; } + public long TotalSize { get; set; } + public string DownloadDir { get; set; } + } + + private class TorrentAddResponse + { + public TorrentAddArguments Arguments { get; set; } + public string Result { get; set; } + } + + private class TorrentAddArguments + { + public TorrentAddInfo TorrentAdded { get; set; } + public TorrentAddInfo TorrentDuplicate { get; set; } + } + + private class TorrentAddInfo + { + public int Id { get; set; } + public string Name { get; set; } + public string HashString { get; set; } + } + } +} +EOL + +# Copy ConfigService.cs +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/" + +# Copy API Controllers +cp -vr /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers "$TEST_DIR/src/Api/" + +# Copy web content +cp -vr /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/src/Web/wwwroot/" + +# Build the application +cd "$TEST_DIR" +echo -e "${GREEN}Setting up NuGet packages...${NC}" +dotnet restore +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to restore NuGet packages.${NC}" + exit 1 +fi + +echo -e "${GREEN}Building application...${NC}" +dotnet build +if [ $? -ne 0 ]; then + echo -e "${RED}Build failed.${NC}" + exit 1 +fi + +# Find server's IP address +SERVER_IP=$(hostname -I | awk '{print $1}') + +# Skip running if parameter is passed +if [[ "$1" == "no_run" ]]; then + return 0 +fi + +# Run the application +echo -e "${GREEN}Starting application...${NC}" +echo -e "${GREEN}The web interface will be available at:${NC}" +echo -e "${GREEN}- Local: http://localhost:5000${NC}" +if [[ -f "./Properties/launchSettings.json" && -n "$SERVER_IP" ]]; then + echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}" +fi +echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" + +dotnet run \ No newline at end of file diff --git a/run-app.sh b/run-app.sh new file mode 100755 index 0000000..754f6dd --- /dev/null +++ b/run-app.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Simple script to run the Transmission RSS Manager application + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Check if the app directory exists +APP_DIR="$HOME/transmission-rss-test" +if [ ! -d "$APP_DIR" ]; then + echo -e "${YELLOW}Application directory not found. Did you run the test installer?${NC}" + echo -e "${YELLOW}Running test installer first...${NC}" + bash /opt/develop/transmission-rss-manager/test-installer.sh + exit 0 +fi + +# Navigate to the app directory +cd "$APP_DIR" + +# Run the application +echo -e "${GREEN}Starting Transmission RSS Manager...${NC}" +echo -e "${GREEN}The web interface will be available at: http://localhost:5000${NC}" +echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" + +dotnet run \ No newline at end of file diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs new file mode 100644 index 0000000..fdfc413 --- /dev/null +++ b/src/Api/Controllers/ConfigController.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class ConfigController : ControllerBase + { + private readonly ILogger<ConfigController> _logger; + private readonly IConfigService _configService; + + public ConfigController( + ILogger<ConfigController> logger, + IConfigService configService) + { + _logger = logger; + _configService = configService; + } + + [HttpGet] + public IActionResult GetConfig() + { + var config = _configService.GetConfiguration(); + + // Create a sanitized config without sensitive information + var sanitizedConfig = new + { + transmission = new + { + host = config.Transmission.Host, + port = config.Transmission.Port, + useHttps = config.Transmission.UseHttps, + hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username) + }, + autoDownloadEnabled = config.AutoDownloadEnabled, + checkIntervalMinutes = config.CheckIntervalMinutes, + downloadDirectory = config.DownloadDirectory, + mediaLibraryPath = config.MediaLibraryPath, + postProcessing = config.PostProcessing + }; + + return Ok(sanitizedConfig); + } + + [HttpPut] + public async Task<IActionResult> UpdateConfig([FromBody] AppConfig config) + { + var currentConfig = _configService.GetConfiguration(); + + // If password is empty, keep the existing one + if (string.IsNullOrEmpty(config.Transmission.Password) && !string.IsNullOrEmpty(currentConfig.Transmission.Password)) + { + config.Transmission.Password = currentConfig.Transmission.Password; + } + + await _configService.SaveConfigurationAsync(config); + return Ok(new { success = true }); + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/FeedsController.cs b/src/Api/Controllers/FeedsController.cs new file mode 100644 index 0000000..bbc245b --- /dev/null +++ b/src/Api/Controllers/FeedsController.cs @@ -0,0 +1,84 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class FeedsController : ControllerBase + { + private readonly ILogger<FeedsController> _logger; + private readonly IRssFeedManager _rssFeedManager; + + public FeedsController( + ILogger<FeedsController> logger, + IRssFeedManager rssFeedManager) + { + _logger = logger; + _rssFeedManager = rssFeedManager; + } + + [HttpGet] + public async Task<IActionResult> GetFeeds() + { + var feeds = await _rssFeedManager.GetFeedsAsync(); + return Ok(feeds); + } + + [HttpGet("items")] + public async Task<IActionResult> GetAllItems() + { + var items = await _rssFeedManager.GetAllItemsAsync(); + return Ok(items); + } + + [HttpGet("matched")] + public async Task<IActionResult> GetMatchedItems() + { + var items = await _rssFeedManager.GetMatchedItemsAsync(); + return Ok(items); + } + + [HttpPost] + public async Task<IActionResult> AddFeed([FromBody] RssFeed feed) + { + await _rssFeedManager.AddFeedAsync(feed); + return Ok(feed); + } + + [HttpPut("{id}")] + public async Task<IActionResult> UpdateFeed(string id, [FromBody] RssFeed feed) + { + if (id != feed.Id) + { + return BadRequest("Feed ID mismatch"); + } + + await _rssFeedManager.UpdateFeedAsync(feed); + return Ok(feed); + } + + [HttpDelete("{id}")] + public async Task<IActionResult> DeleteFeed(string id) + { + await _rssFeedManager.RemoveFeedAsync(id); + return Ok(); + } + + [HttpPost("refresh")] + public async Task<IActionResult> RefreshFeeds() + { + await _rssFeedManager.RefreshFeedsAsync(HttpContext.RequestAborted); + return Ok(new { success = true }); + } + + [HttpPost("download/{id}")] + public async Task<IActionResult> DownloadItem(string id) + { + await _rssFeedManager.MarkItemAsDownloadedAsync(id); + return Ok(new { success = true }); + } + } +} \ No newline at end of file diff --git a/src/Api/Controllers/TorrentsController.cs b/src/Api/Controllers/TorrentsController.cs new file mode 100644 index 0000000..66e9bcf --- /dev/null +++ b/src/Api/Controllers/TorrentsController.cs @@ -0,0 +1,89 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +namespace TransmissionRssManager.Api.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class TorrentsController : ControllerBase + { + private readonly ILogger<TorrentsController> _logger; + private readonly ITransmissionClient _transmissionClient; + private readonly IConfigService _configService; + private readonly IPostProcessor _postProcessor; + + public TorrentsController( + ILogger<TorrentsController> logger, + ITransmissionClient transmissionClient, + IConfigService configService, + IPostProcessor postProcessor) + { + _logger = logger; + _transmissionClient = transmissionClient; + _configService = configService; + _postProcessor = postProcessor; + } + + [HttpGet] + public async Task<IActionResult> GetTorrents() + { + var torrents = await _transmissionClient.GetTorrentsAsync(); + return Ok(torrents); + } + + [HttpPost] + public async Task<IActionResult> AddTorrent([FromBody] AddTorrentRequest request) + { + var config = _configService.GetConfiguration(); + string downloadDir = request.DownloadDir ?? config.DownloadDirectory; + + var torrentId = await _transmissionClient.AddTorrentAsync(request.Url, downloadDir); + return Ok(new { id = torrentId }); + } + + [HttpDelete("{id}")] + public async Task<IActionResult> RemoveTorrent(int id, [FromQuery] bool deleteLocalData = false) + { + await _transmissionClient.RemoveTorrentAsync(id, deleteLocalData); + return Ok(); + } + + [HttpPost("{id}/start")] + public async Task<IActionResult> StartTorrent(int id) + { + await _transmissionClient.StartTorrentAsync(id); + return Ok(); + } + + [HttpPost("{id}/stop")] + public async Task<IActionResult> StopTorrent(int id) + { + await _transmissionClient.StopTorrentAsync(id); + return Ok(); + } + + [HttpPost("{id}/process")] + public async Task<IActionResult> ProcessTorrent(int id) + { + var torrents = await _transmissionClient.GetTorrentsAsync(); + var torrent = torrents.Find(t => t.Id == id); + + if (torrent == null) + { + return NotFound(); + } + + await _postProcessor.ProcessTorrentAsync(torrent); + return Ok(); + } + } + + public class AddTorrentRequest + { + public string Url { get; set; } + public string DownloadDir { get; set; } + } +} \ No newline at end of file diff --git a/src/Api/Program.cs b/src/Api/Program.cs new file mode 100644 index 0000000..0484445 --- /dev/null +++ b/src/Api/Program.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Add custom services +builder.Services.AddSingleton<IConfigService, ConfigService>(); +builder.Services.AddSingleton<ITransmissionClient, TransmissionClient>(); +builder.Services.AddSingleton<IRssFeedManager, RssFeedManager>(); +builder.Services.AddSingleton<IPostProcessor, PostProcessor>(); + +// Add background services +builder.Services.AddHostedService<RssFeedBackgroundService>(); +builder.Services.AddHostedService<PostProcessingBackgroundService>(); + +var app = builder.Build(); + +// Configure middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/src/Core/Interfaces.cs b/src/Core/Interfaces.cs new file mode 100644 index 0000000..fe1cb70 --- /dev/null +++ b/src/Core/Interfaces.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace TransmissionRssManager.Core +{ + public class RssFeedItem + { + public string Id { get; set; } + public string Title { get; set; } + public string Link { get; set; } + public string Description { get; set; } + public DateTime PublishDate { get; set; } + public string TorrentUrl { get; set; } + public bool IsDownloaded { get; set; } + public bool IsMatched { get; set; } + public string MatchedRule { get; set; } + } + + public class TorrentInfo + { + public int Id { get; set; } + public string Name { get; set; } + public string Status { get; set; } + public double PercentDone { get; set; } + public long TotalSize { get; set; } + public string DownloadDir { get; set; } + public bool IsFinished => PercentDone >= 1.0; + } + + public class RssFeed + { + public string Id { get; set; } + public string Url { get; set; } + public string Name { get; set; } + public List<string> Rules { get; set; } = new List<string>(); + public bool AutoDownload { get; set; } + public DateTime LastChecked { get; set; } + } + + public class AppConfig + { + public TransmissionConfig Transmission { get; set; } = new TransmissionConfig(); + public List<RssFeed> Feeds { get; set; } = new List<RssFeed>(); + public bool AutoDownloadEnabled { get; set; } + public int CheckIntervalMinutes { get; set; } = 30; + public string DownloadDirectory { get; set; } + public string MediaLibraryPath { get; set; } + public PostProcessingConfig PostProcessing { get; set; } = new PostProcessingConfig(); + } + + public class TransmissionConfig + { + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 9091; + public string Username { get; set; } + public string Password { get; set; } + public bool UseHttps { get; set; } = false; + public string Url => $"{(UseHttps ? "https" : "http")}://{Host}:{Port}/transmission/rpc"; + } + + public class PostProcessingConfig + { + public bool Enabled { get; set; } = false; + public bool ExtractArchives { get; set; } = true; + public bool OrganizeMedia { get; set; } = true; + public int MinimumSeedRatio { get; set; } = 1; + public List<string> MediaExtensions { get; set; } = new List<string> { ".mp4", ".mkv", ".avi" }; + } + + public interface IConfigService + { + AppConfig GetConfiguration(); + Task SaveConfigurationAsync(AppConfig config); + } + + public interface ITransmissionClient + { + Task<List<TorrentInfo>> GetTorrentsAsync(); + Task<int> AddTorrentAsync(string torrentUrl, string downloadDir); + Task RemoveTorrentAsync(int id, bool deleteLocalData); + Task StartTorrentAsync(int id); + Task StopTorrentAsync(int id); + } + + public interface IRssFeedManager + { + Task<List<RssFeedItem>> GetAllItemsAsync(); + Task<List<RssFeedItem>> GetMatchedItemsAsync(); + Task<List<RssFeed>> GetFeedsAsync(); + Task AddFeedAsync(RssFeed feed); + Task RemoveFeedAsync(string feedId); + Task UpdateFeedAsync(RssFeed feed); + Task RefreshFeedsAsync(CancellationToken cancellationToken); + Task MarkItemAsDownloadedAsync(string itemId); + } + + public interface IPostProcessor + { + Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken); + Task ProcessTorrentAsync(TorrentInfo torrent); + } +} \ No newline at end of file diff --git a/src/Infrastructure/install-script.sh b/src/Infrastructure/install-script.sh new file mode 100755 index 0000000..6c7ed1a --- /dev/null +++ b/src/Infrastructure/install-script.sh @@ -0,0 +1,212 @@ +#!/bin/bash + +# TransmissionRssManager Installer Script for Linux +# This script installs the TransmissionRssManager application and its dependencies + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Error handling +set -e +trap 'echo -e "${RED}An error occurred. Installation failed.${NC}"; exit 1' ERR + +# Check if script is run as root +if [ "$EUID" -eq 0 ]; then + echo -e "${YELLOW}Warning: It's recommended to run this script as a regular user with sudo privileges, not as root.${NC}" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Detect Linux distribution +if [ -f /etc/os-release ]; then + . /etc/os-release + DISTRO=$ID +else + echo -e "${RED}Cannot detect Linux distribution. Exiting.${NC}" + exit 1 +fi + +echo -e "${GREEN}Installing TransmissionRssManager on $PRETTY_NAME...${NC}" + +# Install .NET SDK and runtime +install_dotnet() { + echo -e "${GREEN}Installing .NET SDK...${NC}" + + case $DISTRO in + ubuntu|debian|linuxmint) + # Add Microsoft package repository + wget -O packages-microsoft-prod.deb https://packages.microsoft.com/config/$DISTRO/$VERSION_ID/packages-microsoft-prod.deb + sudo dpkg -i packages-microsoft-prod.deb + rm packages-microsoft-prod.deb + + # Install .NET SDK + sudo apt-get update + sudo apt-get install -y apt-transport-https + sudo apt-get update + sudo apt-get install -y dotnet-sdk-7.0 + ;; + fedora|rhel|centos) + # Add Microsoft package repository + sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm + + # Install .NET SDK + sudo yum install -y dotnet-sdk-7.0 + ;; + opensuse*|sles) + # Install .NET SDK from zypper + sudo zypper install -y dotnet-sdk-7.0 + ;; + arch|manjaro) + # Install .NET SDK from pacman + sudo pacman -Sy dotnet-sdk aspnet-runtime --noconfirm + ;; + *) + echo -e "${YELLOW}Unsupported distribution for automatic .NET installation.${NC}" + echo -e "${YELLOW}Please install .NET SDK 7.0 manually from https://dotnet.microsoft.com/download${NC}" + read -p "Press Enter to continue once .NET SDK is installed..." + ;; + esac + + # Verify .NET installation + dotnet --version + if [ $? -ne 0 ]; then + echo -e "${RED}.NET SDK installation failed. Please install .NET SDK 7.0 manually.${NC}" + exit 1 + fi +} + +# Install dependencies +install_dependencies() { + echo -e "${GREEN}Installing dependencies...${NC}" + + case $DISTRO in + ubuntu|debian|linuxmint) + sudo apt-get update + sudo apt-get install -y unzip p7zip-full unrar-free libssl-dev zlib1g-dev libicu-dev build-essential + ;; + fedora|rhel|centos) + sudo yum install -y unzip p7zip unrar openssl-devel zlib-devel libicu-devel gcc-c++ make + ;; + opensuse*|sles) + sudo zypper install -y unzip p7zip unrar libopenssl-devel zlib-devel libicu-devel gcc-c++ make + ;; + arch|manjaro) + sudo pacman -Sy unzip p7zip unrar openssl zlib icu gcc make --noconfirm + ;; + *) + echo -e "${YELLOW}Unsupported distribution for automatic dependency installation.${NC}" + echo -e "${YELLOW}Please make sure the following are installed: unzip, p7zip, unrar, ssl, zlib, icu, gcc/g++ and make.${NC}" + ;; + esac + + # Install Entity Framework Core CLI tools if needed (version 7.x) + if ! command -v dotnet-ef &> /dev/null; then + echo -e "${GREEN}Installing Entity Framework Core tools compatible with .NET 7...${NC}" + dotnet tool install --global dotnet-ef --version 7.0.15 + fi +} + +# Check if .NET is already installed +if command -v dotnet >/dev/null 2>&1; then + dotnet_version=$(dotnet --version) + echo -e "${GREEN}.NET SDK version $dotnet_version is already installed.${NC}" +else + install_dotnet +fi + +# Install dependencies +install_dependencies + +# Create installation directory +INSTALL_DIR="$HOME/.local/share/transmission-rss-manager" +mkdir -p "$INSTALL_DIR" + +# Clone or download the application +echo -e "${GREEN}Downloading TransmissionRssManager...${NC}" +if [ -d "/opt/develop/transmission-rss-manager/TransmissionRssManager" ]; then + # We're running from the development directory + cp -r /opt/develop/transmission-rss-manager/TransmissionRssManager/* "$INSTALL_DIR/" +else + # Download and extract release + wget -O transmission-rss-manager.zip https://github.com/yourusername/transmission-rss-manager/releases/latest/download/transmission-rss-manager.zip + unzip transmission-rss-manager.zip -d "$INSTALL_DIR" + rm transmission-rss-manager.zip +fi + +# Install required NuGet packages (with versions compatible with .NET 7) +echo -e "${GREEN}Installing required NuGet packages...${NC}" +cd "$INSTALL_DIR" +dotnet add package Microsoft.AspNetCore.OpenApi --version 7.0.13 +dotnet add package Swashbuckle.AspNetCore --version 6.5.0 +dotnet add package System.ServiceModel.Syndication --version 7.0.0 + +# Build the application +echo -e "${GREEN}Building TransmissionRssManager...${NC}" +dotnet build -c Release + +# Create configuration directory +CONFIG_DIR="$HOME/.config/transmission-rss-manager" +mkdir -p "$CONFIG_DIR" + +# Create desktop entry +DESKTOP_FILE="$HOME/.local/share/applications/transmission-rss-manager.desktop" +echo "[Desktop Entry] +Name=Transmission RSS Manager +Comment=RSS Feed Manager for Transmission BitTorrent Client +Exec=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll +Icon=transmission +Terminal=false +Type=Application +Categories=Network;P2P;" > "$DESKTOP_FILE" + +# Create systemd service for user +SERVICE_DIR="$HOME/.config/systemd/user" +mkdir -p "$SERVICE_DIR" + +echo "[Unit] +Description=Transmission RSS Manager +After=network.target + +[Service] +ExecStart=dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll +Restart=on-failure +RestartSec=10 +SyslogIdentifier=transmission-rss-manager + +[Install] +WantedBy=default.target" > "$SERVICE_DIR/transmission-rss-manager.service" + +# Reload systemd +systemctl --user daemon-reload + +# Create launcher script +LAUNCHER="$HOME/.local/bin/transmission-rss-manager" +mkdir -p "$HOME/.local/bin" + +echo "#!/bin/bash +dotnet $INSTALL_DIR/bin/Release/net7.0/TransmissionRssManager.dll" > "$LAUNCHER" +chmod +x "$LAUNCHER" + +echo -e "${GREEN}Installation completed!${NC}" +echo -e "${GREEN}You can run TransmissionRssManager in these ways:${NC}" +echo -e " * Command: ${YELLOW}transmission-rss-manager${NC}" +echo -e " * Service: ${YELLOW}systemctl --user start transmission-rss-manager${NC}" +echo -e " * Enable service on startup: ${YELLOW}systemctl --user enable transmission-rss-manager${NC}" +echo -e " * Web interface will be available at: ${YELLOW}http://localhost:5000${NC}" + +# Start the application +read -p "Do you want to start the application now? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + systemctl --user start transmission-rss-manager + echo -e "${GREEN}TransmissionRssManager service started.${NC}" + echo -e "${GREEN}Open http://localhost:5000 in your browser.${NC}" +else + echo -e "${YELLOW}You can start the application later using: systemctl --user start transmission-rss-manager${NC}" +fi \ No newline at end of file diff --git a/src/Infrastructure/packages-microsoft-prod.deb b/src/Infrastructure/packages-microsoft-prod.deb new file mode 100644 index 0000000..bdb822a Binary files /dev/null and b/src/Infrastructure/packages-microsoft-prod.deb differ diff --git a/src/Services/ConfigService.cs b/src/Services/ConfigService.cs new file mode 100644 index 0000000..fce7b78 --- /dev/null +++ b/src/Services/ConfigService.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class ConfigService : IConfigService + { + private readonly ILogger<ConfigService> _logger; + private readonly string _configPath; + private AppConfig _cachedConfig; + + public ConfigService(ILogger<ConfigService> logger) + { + _logger = logger; + + // Get config directory + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager"); + + // Ensure directory exists + if (!Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + + _configPath = Path.Combine(configDir, "config.json"); + _cachedConfig = LoadConfiguration(); + } + + public AppConfig GetConfiguration() + { + return _cachedConfig; + } + + public async Task SaveConfigurationAsync(AppConfig config) + { + _cachedConfig = config; + + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + string json = JsonSerializer.Serialize(config, options); + await File.WriteAllTextAsync(_configPath, json); + + _logger.LogInformation("Configuration saved successfully"); + } + + private AppConfig LoadConfiguration() + { + if (!File.Exists(_configPath)) + { + _logger.LogInformation("No configuration file found, creating default"); + var defaultConfig = CreateDefaultConfig(); + SaveConfigurationAsync(defaultConfig).Wait(); + return defaultConfig; + } + + try + { + string json = File.ReadAllText(_configPath); + var config = JsonSerializer.Deserialize<AppConfig>(json); + + if (config == null) + { + _logger.LogWarning("Failed to deserialize config, creating default"); + return CreateDefaultConfig(); + } + + return config; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading configuration"); + return CreateDefaultConfig(); + } + } + + private AppConfig CreateDefaultConfig() + { + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + return new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "" + }, + AutoDownloadEnabled = false, + CheckIntervalMinutes = 30, + DownloadDirectory = Path.Combine(homeDir, "Downloads"), + MediaLibraryPath = Path.Combine(homeDir, "Media"), + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1, + MediaExtensions = new System.Collections.Generic.List<string> { ".mp4", ".mkv", ".avi" } + } + }; + } + } +} \ No newline at end of file diff --git a/src/Services/PostProcessor.cs b/src/Services/PostProcessor.cs new file mode 100644 index 0000000..8b6aeb5 --- /dev/null +++ b/src/Services/PostProcessor.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class PostProcessor : IPostProcessor + { + private readonly ILogger<PostProcessor> _logger; + private readonly IConfigService _configService; + private readonly ITransmissionClient _transmissionClient; + + public PostProcessor( + ILogger<PostProcessor> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + } + + public async Task ProcessCompletedDownloadsAsync(CancellationToken cancellationToken) + { + var config = _configService.GetConfiguration(); + + if (!config.PostProcessing.Enabled) + { + return; + } + + _logger.LogInformation("Processing completed downloads"); + + var torrents = await _transmissionClient.GetTorrentsAsync(); + var completedTorrents = torrents.Where(t => t.IsFinished).ToList(); + + foreach (var torrent in completedTorrents) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Post-processing cancelled"); + return; + } + + try + { + await ProcessTorrentAsync(torrent); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error processing torrent: {torrent.Name}"); + } + } + } + + public async Task ProcessTorrentAsync(TorrentInfo torrent) + { + _logger.LogInformation($"Processing completed torrent: {torrent.Name}"); + + var config = _configService.GetConfiguration(); + var downloadDir = torrent.DownloadDir; + var torrentPath = Path.Combine(downloadDir, torrent.Name); + + // Check if the file/directory exists + if (!Directory.Exists(torrentPath) && !File.Exists(torrentPath)) + { + _logger.LogWarning($"Downloaded path not found: {torrentPath}"); + return; + } + + // Handle archives if enabled + if (config.PostProcessing.ExtractArchives && IsArchive(torrentPath)) + { + await ExtractArchiveAsync(torrentPath, downloadDir); + } + + // Organize media files if enabled + if (config.PostProcessing.OrganizeMedia) + { + await OrganizeMediaAsync(torrentPath, config.MediaLibraryPath); + } + } + + private bool IsArchive(string path) + { + if (!File.Exists(path)) + { + return false; + } + + var extension = Path.GetExtension(path).ToLowerInvariant(); + return extension == ".rar" || extension == ".zip" || extension == ".7z"; + } + + private async Task ExtractArchiveAsync(string archivePath, string outputDir) + { + _logger.LogInformation($"Extracting archive: {archivePath}"); + + try + { + var extension = Path.GetExtension(archivePath).ToLowerInvariant(); + var extractDir = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(archivePath)); + + // Create extraction directory if it doesn't exist + if (!Directory.Exists(extractDir)) + { + Directory.CreateDirectory(extractDir); + } + + var processStartInfo = new ProcessStartInfo + { + FileName = extension switch + { + ".rar" => "unrar", + ".zip" => "unzip", + ".7z" => "7z", + _ => throw new Exception($"Unsupported archive format: {extension}") + }, + Arguments = extension switch + { + ".rar" => $"x -o+ \"{archivePath}\" \"{extractDir}\"", + ".zip" => $"-o \"{archivePath}\" -d \"{extractDir}\"", + ".7z" => $"x \"{archivePath}\" -o\"{extractDir}\"", + _ => throw new Exception($"Unsupported archive format: {extension}") + }, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = new Process + { + StartInfo = processStartInfo + }; + + process.Start(); + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(); + _logger.LogError($"Error extracting archive: {error}"); + return; + } + + _logger.LogInformation($"Archive extracted to: {extractDir}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting archive: {archivePath}"); + } + } + + private async Task OrganizeMediaAsync(string path, string mediaLibraryPath) + { + _logger.LogInformation($"Organizing media: {path}"); + + var config = _configService.GetConfiguration(); + var mediaExtensions = config.PostProcessing.MediaExtensions; + + // Ensure media library path exists + if (!Directory.Exists(mediaLibraryPath)) + { + Directory.CreateDirectory(mediaLibraryPath); + } + + try + { + if (File.Exists(path)) + { + // Single file + var extension = Path.GetExtension(path).ToLowerInvariant(); + if (mediaExtensions.Contains(extension)) + { + await CopyFileToMediaLibraryAsync(path, mediaLibraryPath); + } + } + else if (Directory.Exists(path)) + { + // Directory - find all media files recursively + var mediaFiles = Directory.GetFiles(path, "*.*", SearchOption.AllDirectories) + .Where(f => mediaExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) + .ToList(); + + foreach (var mediaFile in mediaFiles) + { + await CopyFileToMediaLibraryAsync(mediaFile, mediaLibraryPath); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error organizing media: {path}"); + } + } + + private async Task CopyFileToMediaLibraryAsync(string filePath, string mediaLibraryPath) + { + var fileName = Path.GetFileName(filePath); + var destinationPath = Path.Combine(mediaLibraryPath, fileName); + + // If destination file already exists, add a unique identifier + if (File.Exists(destinationPath)) + { + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + var extension = Path.GetExtension(fileName); + var uniqueId = Guid.NewGuid().ToString().Substring(0, 8); + + destinationPath = Path.Combine(mediaLibraryPath, $"{fileNameWithoutExt}_{uniqueId}{extension}"); + } + + _logger.LogInformation($"Copying media file to library: {destinationPath}"); + + try + { + using var sourceStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true); + using var destinationStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); + + await sourceStream.CopyToAsync(destinationStream); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error copying file to media library: {filePath}"); + } + } + } + + public class PostProcessingBackgroundService : BackgroundService + { + private readonly ILogger<PostProcessingBackgroundService> _logger; + private readonly IPostProcessor _postProcessor; + private readonly IConfigService _configService; + + public PostProcessingBackgroundService( + ILogger<PostProcessingBackgroundService> logger, + IPostProcessor postProcessor, + IConfigService configService) + { + _logger = logger; + _postProcessor = postProcessor; + _configService = configService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Post-processing background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _postProcessor.ProcessCompletedDownloadsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing completed downloads"); + } + + // Check every 5 minutes + await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); + } + } + } +} \ No newline at end of file diff --git a/src/Services/RssFeedManager.cs b/src/Services/RssFeedManager.cs new file mode 100644 index 0000000..4d0113a --- /dev/null +++ b/src/Services/RssFeedManager.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.ServiceModel.Syndication; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class RssFeedManager : IRssFeedManager + { + private readonly ILogger<RssFeedManager> _logger; + private readonly IConfigService _configService; + private readonly ITransmissionClient _transmissionClient; + private readonly HttpClient _httpClient; + private readonly string _dataPath; + private List<RssFeedItem> _items = new List<RssFeedItem>(); + + public RssFeedManager( + ILogger<RssFeedManager> logger, + IConfigService configService, + ITransmissionClient transmissionClient) + { + _logger = logger; + _configService = configService; + _transmissionClient = transmissionClient; + _httpClient = new HttpClient(); + + // Create data directory + string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string dataDir = Path.Combine(homeDir, ".local", "share", "transmission-rss-manager"); + + if (!Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + + _dataPath = Path.Combine(dataDir, "rss-items.json"); + LoadItems(); + } + + public Task<List<RssFeedItem>> GetAllItemsAsync() + { + return Task.FromResult(_items.OrderByDescending(i => i.PublishDate).ToList()); + } + + public Task<List<RssFeedItem>> GetMatchedItemsAsync() + { + return Task.FromResult(_items.Where(i => i.IsMatched).OrderByDescending(i => i.PublishDate).ToList()); + } + + public Task<List<RssFeed>> GetFeedsAsync() + { + var config = _configService.GetConfiguration(); + return Task.FromResult(config.Feeds); + } + + public async Task AddFeedAsync(RssFeed feed) + { + feed.Id = Guid.NewGuid().ToString(); + feed.LastChecked = DateTime.MinValue; + + var config = _configService.GetConfiguration(); + config.Feeds.Add(feed); + await _configService.SaveConfigurationAsync(config); + + // Initial fetch of feed items + await FetchFeedAsync(feed); + } + + public async Task RemoveFeedAsync(string feedId) + { + var config = _configService.GetConfiguration(); + var feed = config.Feeds.FirstOrDefault(f => f.Id == feedId); + + if (feed != null) + { + config.Feeds.Remove(feed); + await _configService.SaveConfigurationAsync(config); + + // Remove items from this feed + _items.RemoveAll(i => i.Id.StartsWith(feedId)); + await SaveItemsAsync(); + } + } + + public async Task UpdateFeedAsync(RssFeed feed) + { + var config = _configService.GetConfiguration(); + var index = config.Feeds.FindIndex(f => f.Id == feed.Id); + + if (index != -1) + { + config.Feeds[index] = feed; + await _configService.SaveConfigurationAsync(config); + } + } + + public async Task RefreshFeedsAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting RSS feed refresh"); + var config = _configService.GetConfiguration(); + + foreach (var feed in config.Feeds) + { + if (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("RSS refresh cancelled"); + return; + } + + try + { + await FetchFeedAsync(feed); + + // Update last checked time + feed.LastChecked = DateTime.Now; + await _configService.SaveConfigurationAsync(config); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error refreshing feed: {feed.Name}"); + } + } + + // Check for matches and auto-download if enabled + await ProcessMatchesAsync(); + } + + public async Task MarkItemAsDownloadedAsync(string itemId) + { + var item = _items.FirstOrDefault(i => i.Id == itemId); + + if (item != null) + { + item.IsDownloaded = true; + await SaveItemsAsync(); + } + } + + private async Task FetchFeedAsync(RssFeed feed) + { + _logger.LogInformation($"Fetching feed: {feed.Name}"); + + try + { + var response = await _httpClient.GetStringAsync(feed.Url); + using var stringReader = new StringReader(response); + using var xmlReader = XmlReader.Create(stringReader); + + var syndicationFeed = SyndicationFeed.Load(xmlReader); + + foreach (var item in syndicationFeed.Items) + { + var link = item.Links.FirstOrDefault()?.Uri.ToString() ?? ""; + var torrentUrl = ExtractTorrentUrl(link, item.Title.Text); + + // Create a unique ID for this item + var itemId = $"{feed.Id}:{item.Id ?? Guid.NewGuid().ToString()}"; + + // Check if we already have this item + if (_items.Any(i => i.Id == itemId)) + { + continue; + } + + var feedItem = new RssFeedItem + { + Id = itemId, + Title = item.Title.Text, + Link = link, + Description = item.Summary?.Text ?? "", + PublishDate = item.PublishDate.DateTime, + TorrentUrl = torrentUrl, + IsDownloaded = false + }; + + // Check if this item matches any rules + CheckForMatches(feedItem, feed.Rules); + + _items.Add(feedItem); + } + + await SaveItemsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error fetching feed: {feed.Name}"); + throw; + } + } + + private string ExtractTorrentUrl(string link, string title) + { + // Try to find a .torrent link + if (link.EndsWith(".torrent", StringComparison.OrdinalIgnoreCase)) + { + return link; + } + + // If it's a magnet link, return it + if (link.StartsWith("magnet:", StringComparison.OrdinalIgnoreCase)) + { + return link; + } + + // Return the link as is, we'll try to find the torrent on the page + return link; + } + + private void CheckForMatches(RssFeedItem item, List<string> rules) + { + foreach (var rule in rules) + { + try + { + if (Regex.IsMatch(item.Title, rule, RegexOptions.IgnoreCase)) + { + item.IsMatched = true; + item.MatchedRule = rule; + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Invalid regex rule: {rule}"); + } + } + } + + private async Task ProcessMatchesAsync() + { + var config = _configService.GetConfiguration(); + + if (!config.AutoDownloadEnabled) + { + return; + } + + var matchedItems = _items.Where(i => i.IsMatched && !i.IsDownloaded).ToList(); + + foreach (var item in matchedItems) + { + try + { + _logger.LogInformation($"Auto-downloading: {item.Title}"); + + var torrentId = await _transmissionClient.AddTorrentAsync( + item.TorrentUrl, + config.DownloadDirectory); + + item.IsDownloaded = true; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error downloading torrent: {item.Title}"); + } + } + + await SaveItemsAsync(); + } + + private void LoadItems() + { + if (!File.Exists(_dataPath)) + { + _items = new List<RssFeedItem>(); + return; + } + + try + { + var json = File.ReadAllText(_dataPath); + var items = JsonSerializer.Deserialize<List<RssFeedItem>>(json); + _items = items ?? new List<RssFeedItem>(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading RSS items"); + _items = new List<RssFeedItem>(); + } + } + + private async Task SaveItemsAsync() + { + try + { + var options = new JsonSerializerOptions + { + WriteIndented = true + }; + + var json = JsonSerializer.Serialize(_items, options); + await File.WriteAllTextAsync(_dataPath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving RSS items"); + } + } + } + + public class RssFeedBackgroundService : BackgroundService + { + private readonly ILogger<RssFeedBackgroundService> _logger; + private readonly IRssFeedManager _rssFeedManager; + private readonly IConfigService _configService; + + public RssFeedBackgroundService( + ILogger<RssFeedBackgroundService> logger, + IRssFeedManager rssFeedManager, + IConfigService configService) + { + _logger = logger; + _rssFeedManager = rssFeedManager; + _configService = configService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("RSS feed background service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await _rssFeedManager.RefreshFeedsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error refreshing RSS feeds"); + } + + var config = _configService.GetConfiguration(); + var interval = TimeSpan.FromMinutes(config.CheckIntervalMinutes); + + _logger.LogInformation($"Next refresh in {interval.TotalMinutes} minutes"); + await Task.Delay(interval, stoppingToken); + } + } + } +} \ No newline at end of file diff --git a/src/Services/TransmissionClient.cs b/src/Services/TransmissionClient.cs new file mode 100644 index 0000000..eeeeed9 --- /dev/null +++ b/src/Services/TransmissionClient.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using System.Linq; +using Microsoft.Extensions.Logging; +using TransmissionRssManager.Core; + +namespace TransmissionRssManager.Services +{ + public class TransmissionClient : ITransmissionClient + { + private readonly ILogger<TransmissionClient> _logger; + private readonly IConfigService _configService; + private readonly HttpClient _httpClient; + private string _sessionId = string.Empty; + + public TransmissionClient(ILogger<TransmissionClient> logger, IConfigService configService) + { + _logger = logger; + _configService = configService; + + // Configure the main HttpClient with handler that ignores certificate errors + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true + }; + _httpClient = new HttpClient(handler); + _httpClient.Timeout = TimeSpan.FromSeconds(10); + + _logger.LogInformation("TransmissionClient initialized with certificate validation disabled"); + } + + public async Task<List<TorrentInfo>> GetTorrentsAsync() + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-get", + arguments = new + { + fields = new[] { "id", "name", "status", "percentDone", "totalSize", "downloadDir" } + } + }; + + var response = await SendRequestAsync<TorrentGetResponse>(config.Transmission.Url, request); + + _logger.LogInformation($"Transmission torrent response: {response != null}, Arguments: {response?.Arguments != null}, Result: {response?.Result}"); + + if (response?.Arguments?.Torrents == null) + { + _logger.LogWarning("No torrents found in response"); + return new List<TorrentInfo>(); + } + + _logger.LogInformation($"Found {response.Arguments.Torrents.Count} torrents in response"); + + var torrents = new List<TorrentInfo>(); + foreach (var torrent in response.Arguments.Torrents) + { + _logger.LogInformation($"Processing torrent: {torrent.Id} - {torrent.Name}"); + torrents.Add(new TorrentInfo + { + Id = torrent.Id, + Name = torrent.Name, + Status = GetStatusText(torrent.Status), + PercentDone = torrent.PercentDone, + TotalSize = torrent.TotalSize, + DownloadDir = torrent.DownloadDir + }); + } + + return torrents; + } + + public async Task<int> AddTorrentAsync(string torrentUrl, string downloadDir) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-add", + arguments = new + { + filename = torrentUrl, + downloadDir = downloadDir + } + }; + + var response = await SendRequestAsync<TorrentAddResponse>(config.Transmission.Url, request); + + if (response?.Arguments?.TorrentAdded != null) + { + return response.Arguments.TorrentAdded.Id; + } + else if (response?.Arguments?.TorrentDuplicate != null) + { + return response.Arguments.TorrentDuplicate.Id; + } + + throw new Exception("Failed to add torrent"); + } + + public async Task RemoveTorrentAsync(int id, bool deleteLocalData) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-remove", + arguments = new + { + ids = new[] { id }, + deleteLocalData = deleteLocalData + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + public async Task StartTorrentAsync(int id) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-start", + arguments = new + { + ids = new[] { id } + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + public async Task StopTorrentAsync(int id) + { + var config = _configService.GetConfiguration(); + var request = new + { + method = "torrent-stop", + arguments = new + { + ids = new[] { id } + } + }; + + await SendRequestAsync<object>(config.Transmission.Url, request); + } + + private async Task<T> SendRequestAsync<T>(string url, object requestData) + { + var config = _configService.GetConfiguration(); + var jsonContent = JsonSerializer.Serialize(requestData); + var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + // Always create a fresh HttpClient to avoid connection issues + using var httpClient = new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true + }); + + // Ensure we have a valid URL by reconstructing it explicitly + var protocol = config.Transmission.UseHttps ? "https" : "http"; + var serverUrl = $"{protocol}://{config.Transmission.Host}:{config.Transmission.Port}/transmission/rpc"; + + var request = new HttpRequestMessage(HttpMethod.Post, serverUrl) + { + Content = content + }; + + // Add session ID if we have one + if (!string.IsNullOrEmpty(_sessionId)) + { + request.Headers.Add("X-Transmission-Session-Id", _sessionId); + } + + // Add authentication if provided + if (!string.IsNullOrEmpty(config.Transmission.Username) && !string.IsNullOrEmpty(config.Transmission.Password)) + { + var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{config.Transmission.Username}:{config.Transmission.Password}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + + try + { + // Set timeout to avoid hanging indefinitely on connection issues + httpClient.Timeout = TimeSpan.FromSeconds(10); + + _logger.LogInformation($"Connecting to Transmission at {serverUrl} with auth: {!string.IsNullOrEmpty(config.Transmission.Username)}"); + var response = await httpClient.SendAsync(request); + + // Check if we need a new session ID + if (response.StatusCode == System.Net.HttpStatusCode.Conflict) + { + if (response.Headers.TryGetValues("X-Transmission-Session-Id", out var sessionIds)) + { + _sessionId = sessionIds.FirstOrDefault() ?? string.Empty; + _logger.LogInformation($"Got new Transmission session ID: {_sessionId}"); + + // Retry request with new session ID + return await SendRequestAsync<T>(url, requestData); + } + } + + response.EnsureSuccessStatusCode(); + + var resultContent = await response.Content.ReadAsStringAsync(); + _logger.LogInformation($"Received successful response from Transmission: {resultContent.Substring(0, Math.Min(resultContent.Length, 500))}"); + + // Configure JSON deserializer to be case insensitive + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + return JsonSerializer.Deserialize<T>(resultContent, options); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error communicating with Transmission at {serverUrl}: {ex.Message}"); + throw new Exception($"Failed to connect to Transmission at {config.Transmission.Host}:{config.Transmission.Port}. Error: {ex.Message}", ex); + } + } + + private string GetStatusText(int status) + { + return status switch + { + 0 => "Stopped", + 1 => "Queued", + 2 => "Verifying", + 3 => "Downloading", + 4 => "Seeding", + 5 => "Queued", + 6 => "Checking", + _ => "Unknown" + }; + } + + // Transmission response classes with proper JSON attribute names + private class TorrentGetResponse + { + [System.Text.Json.Serialization.JsonPropertyName("arguments")] + public TorrentGetArguments Arguments { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("result")] + public string Result { get; set; } + } + + private class TorrentGetArguments + { + [System.Text.Json.Serialization.JsonPropertyName("torrents")] + public List<TransmissionTorrent> Torrents { get; set; } + } + + private class TransmissionTorrent + { + [System.Text.Json.Serialization.JsonPropertyName("id")] + public int Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("status")] + public int Status { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("percentDone")] + public double PercentDone { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("totalSize")] + public long TotalSize { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("downloadDir")] + public string DownloadDir { get; set; } + } + + private class TorrentAddResponse + { + [System.Text.Json.Serialization.JsonPropertyName("arguments")] + public TorrentAddArguments Arguments { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("result")] + public string Result { get; set; } + } + + private class TorrentAddArguments + { + [System.Text.Json.Serialization.JsonPropertyName("torrent-added")] + public TorrentAddInfo TorrentAdded { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("torrent-duplicate")] + public TorrentAddInfo TorrentDuplicate { get; set; } + } + + private class TorrentAddInfo + { + [System.Text.Json.Serialization.JsonPropertyName("id")] + public int Id { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("name")] + public string Name { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("hashString")] + public string HashString { get; set; } + } + } +} \ No newline at end of file diff --git a/src/Web/wwwroot/css/styles.css b/src/Web/wwwroot/css/styles.css new file mode 100644 index 0000000..6d8fb25 --- /dev/null +++ b/src/Web/wwwroot/css/styles.css @@ -0,0 +1,171 @@ +:root { + --primary-color: #0d6efd; + --secondary-color: #6c757d; + --dark-color: #212529; + --light-color: #f8f9fa; + --success-color: #198754; + --danger-color: #dc3545; + --warning-color: #ffc107; + --info-color: #0dcaf0; +} + +body { + padding-bottom: 2rem; +} + +.navbar { + margin-bottom: 1rem; +} + +.page-content { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.card { + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.card-header { + background-color: var(--light-color); + font-weight: 500; +} + +.table { + margin-bottom: 0; +} + +.progress { + height: 10px; +} + +.badge { + padding: 0.35em 0.65em; +} + +.badge-downloading { + background-color: var(--info-color); + color: var(--dark-color); +} + +.badge-seeding { + background-color: var(--success-color); +} + +.badge-stopped { + background-color: var(--secondary-color); +} + +.badge-checking { + background-color: var(--warning-color); + color: var(--dark-color); +} + +.badge-queued { + background-color: var(--secondary-color); +} + +.btn-icon { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.feed-item { + border-left: 3px solid transparent; + padding: 10px; + margin-bottom: 10px; + background-color: #f8f9fa; + border-radius: 4px; +} + +.feed-item:hover { + background-color: #e9ecef; +} + +.feed-item.matched { + border-left-color: var(--success-color); +} + +.feed-item.downloaded { + opacity: 0.7; +} + +.feed-item-title { + font-weight: 500; + margin-bottom: 5px; +} + +.feed-item-date { + font-size: 0.85rem; + color: var(--secondary-color); +} + +.feed-item-buttons { + margin-top: 10px; +} + +.torrent-item { + margin-bottom: 15px; + padding: 15px; + border-radius: 4px; + background-color: #f8f9fa; +} + +.torrent-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.torrent-item-title { + font-weight: 500; + margin-right: 10px; +} + +.torrent-item-progress { + margin: 10px 0; +} + +.torrent-item-details { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + color: var(--secondary-color); +} + +.torrent-item-buttons { + margin-top: 10px; +} + +/* Responsive tweaks */ +@media (max-width: 768px) { + .container { + padding-left: 10px; + padding-right: 10px; + } + + .card-body { + padding: 1rem; + } + + .torrent-item-header { + flex-direction: column; + align-items: flex-start; + } + + .torrent-item-buttons { + display: flex; + flex-wrap: wrap; + gap: 5px; + } + + .torrent-item-buttons .btn { + flex: 1; + } +} \ No newline at end of file diff --git a/src/Web/wwwroot/index.html b/src/Web/wwwroot/index.html new file mode 100644 index 0000000..3777755 --- /dev/null +++ b/src/Web/wwwroot/index.html @@ -0,0 +1,283 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Transmission RSS Manager + + + + + + +
+
+

Dashboard

+
+
+
+
System Status
+
+
Loading...
+
+
+
+
+
+
Recent Matches
+
+
Loading...
+
+
+
+
+
+
+
+
Active Torrents
+
+
Loading...
+
+
+
+
+
+ +
+

RSS Feeds

+
+ + +
+
Loading...
+ +
+

Feed Items

+ +
+
+
Loading...
+
+
+
Loading...
+
+
+
+
+ +
+

Torrents

+
+ + +
+
Loading...
+
+ +
+

Settings

+
+
+
Transmission Settings
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
RSS Settings
+
+
+
+ + +
+
+
+ + +
+
+
+ +
+
Directories
+
+
+ + +
+
+ + +
+
+
+ +
+
Post Processing
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js new file mode 100644 index 0000000..adb594a --- /dev/null +++ b/src/Web/wwwroot/js/app.js @@ -0,0 +1,916 @@ +document.addEventListener('DOMContentLoaded', function() { + // Initialize navigation + initNavigation(); + + // Initialize event listeners + initEventListeners(); + + // Load initial dashboard data + loadDashboardData(); + + // Initialize Bootstrap tooltips + const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); + tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip)); +}); + +function initNavigation() { + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const page = this.getAttribute('data-page'); + showPage(page); + }); + }); + + // Set active page from URL hash or default to dashboard + const hash = window.location.hash.substring(1); + showPage(hash || 'dashboard'); +} + +function showPage(page) { + // Hide all pages + const pages = document.querySelectorAll('.page-content'); + pages.forEach(p => p.classList.add('d-none')); + + // Remove active class from all nav links + const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + navLinks.forEach(link => link.classList.remove('active')); + + // Show selected page + const selectedPage = document.getElementById(`page-${page}`); + if (selectedPage) { + selectedPage.classList.remove('d-none'); + + // Set active class on nav link + const activeNav = document.querySelector(`.nav-link[data-page="${page}"]`); + if (activeNav) { + activeNav.classList.add('active'); + } + + // Update URL hash + window.location.hash = page; + + // Load page-specific data + loadPageData(page); + } +} + +function loadPageData(page) { + switch (page) { + case 'dashboard': + loadDashboardData(); + break; + case 'feeds': + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + break; + case 'torrents': + loadTorrents(); + break; + case 'settings': + loadSettings(); + break; + } +} + +function initEventListeners() { + // RSS Feeds page + document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); + document.getElementById('btn-refresh-feeds').addEventListener('click', refreshFeeds); + document.getElementById('save-feed-btn').addEventListener('click', saveFeed); + + // Torrents page + document.getElementById('btn-add-torrent').addEventListener('click', showAddTorrentModal); + document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents); + document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent); + + // Settings page + document.getElementById('settings-form').addEventListener('submit', saveSettings); +} + +// Dashboard +function loadDashboardData() { + loadSystemStatus(); + loadRecentMatches(); + loadActiveTorrents(); +} + +function loadSystemStatus() { + const statusElement = document.getElementById('system-status'); + statusElement.innerHTML = '
'; + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Create system status HTML + let html = '
    '; + html += `
  • Auto Download ${config.autoDownloadEnabled ? 'Enabled' : 'Disabled'}
  • `; + html += `
  • Check Interval ${config.checkIntervalMinutes} minutes
  • `; + html += `
  • Transmission Connection ${config.transmission.host ? config.transmission.host + ':' + config.transmission.port : 'Not configured'}
  • `; + html += `
  • Post Processing ${config.postProcessing.enabled ? 'Enabled' : 'Disabled'}
  • `; + html += '
'; + + statusElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading system status:', error); + statusElement.innerHTML = '
Error loading system status
'; + }); +} + +function loadRecentMatches() { + const matchesElement = document.getElementById('recent-matches'); + matchesElement.innerHTML = '
'; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + // Sort by publish date descending and take the first 5 + const recentItems = items.sort((a, b) => new Date(b.publishDate) - new Date(a.publishDate)).slice(0, 5); + + if (recentItems.length === 0) { + matchesElement.innerHTML = '
No matched items yet
'; + return; + } + + let html = '
'; + recentItems.forEach(item => { + const date = new Date(item.publishDate); + html += ` +
+
${item.title}
+ ${formatDate(date)} +
+ + Matched rule: ${item.matchedRule} + ${item.isDownloaded ? 'Downloaded' : 'Not Downloaded'} + +
`; + }); + html += '
'; + + matchesElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading recent matches:', error); + matchesElement.innerHTML = '
Error loading recent matches
'; + }); +} + +function loadActiveTorrents() { + const torrentsElement = document.getElementById('active-torrents'); + torrentsElement.innerHTML = '
'; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Dashboard torrents:', torrents); + + // Sort by progress ascending and filter for active torrents + const activeTorrents = torrents + .filter(t => t && t.status && (t.status === 'Downloading' || t.status === 'Seeding')) + .sort((a, b) => (a.percentDone || 0) - (b.percentDone || 0)); + + if (activeTorrents.length === 0) { + torrentsElement.innerHTML = '
No active torrents
'; + return; + } + + let html = '
'; + activeTorrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
+
+
${torrent.name}
+ ${torrentStatus} +
+
+
${progressPercent}%
+
+ Size: ${sizeInGB} GB +
`; + }); + html += '
'; + + torrentsElement.innerHTML = html; + }) + .catch(error => { + console.error('Error loading active torrents:', error); + torrentsElement.innerHTML = '
Error loading active torrents
'; + }); +} + +// RSS Feeds +function loadFeeds() { + const feedsElement = document.getElementById('feeds-list'); + feedsElement.innerHTML = '
'; + + fetch('/api/feeds') + .then(response => response.json()) + .then(feeds => { + if (feeds.length === 0) { + feedsElement.innerHTML = '
No feeds added yet
'; + return; + } + + let html = '
'; + feeds.forEach(feed => { + const lastChecked = feed.lastChecked ? new Date(feed.lastChecked) : null; + + html += `
+
+
${feed.name}
+ ${lastChecked ? 'Last checked: ' + formatDate(lastChecked) : 'Never checked'} +
+

${feed.url}

+
+ ${feed.rules.length} rules +
+ + +
+
+
`; + }); + html += '
'; + + feedsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-edit-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + editFeed(feedId); + }); + }); + + document.querySelectorAll('.btn-delete-feed').forEach(btn => { + btn.addEventListener('click', function() { + const feedId = this.getAttribute('data-feed-id'); + deleteFeed(feedId); + }); + }); + }) + .catch(error => { + console.error('Error loading feeds:', error); + feedsElement.innerHTML = '
Error loading feeds
'; + }); +} + +function loadAllItems() { + const itemsElement = document.getElementById('all-items-list'); + itemsElement.innerHTML = '
'; + + fetch('/api/feeds/items') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + itemsElement.innerHTML = '
No feed items yet
'; + return; + } + + let html = '
'; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item ${item.isMatched ? 'matched' : ''} ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
+ +
${formatDate(date)}
+ ${item.isMatched ? `
Matched rule: ${item.matchedRule}
` : ''} + ${item.isDownloaded ? '
Downloaded
' : ''} + ${!item.isDownloaded && item.isMatched ? + `
+ +
` : '' + } +
`; + }); + html += '
'; + + itemsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading feed items:', error); + itemsElement.innerHTML = '
Error loading feed items
'; + }); +} + +function loadMatchedItems() { + const matchedElement = document.getElementById('matched-items-list'); + matchedElement.innerHTML = '
'; + + fetch('/api/feeds/matched') + .then(response => response.json()) + .then(items => { + if (items.length === 0) { + matchedElement.innerHTML = '
No matched items yet
'; + return; + } + + let html = '
'; + items.forEach(item => { + const date = new Date(item.publishDate); + const classes = `feed-item matched ${item.isDownloaded ? 'downloaded' : ''}`; + + html += `
+ +
${formatDate(date)}
+
Matched rule: ${item.matchedRule}
+ ${item.isDownloaded ? '
Downloaded
' : ''} + ${!item.isDownloaded ? + `
+ +
` : '' + } +
`; + }); + html += '
'; + + matchedElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-download-matched-item').forEach(btn => { + btn.addEventListener('click', function() { + const itemId = this.getAttribute('data-item-id'); + downloadItem(itemId); + }); + }); + }) + .catch(error => { + console.error('Error loading matched items:', error); + matchedElement.innerHTML = '
Error loading matched items
'; + }); +} + +function showAddFeedModal() { + // Clear form + document.getElementById('feed-name').value = ''; + document.getElementById('feed-url').value = ''; + document.getElementById('feed-rules').value = ''; + document.getElementById('feed-auto-download').checked = false; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Add RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Add Feed'; + + // Remove feed ID data attribute + document.getElementById('save-feed-btn').removeAttribute('data-feed-id'); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); +} + +function editFeed(feedId) { + // Fetch feed data + fetch(`/api/feeds`) + .then(response => response.json()) + .then(feeds => { + const feed = feeds.find(f => f.id === feedId); + if (!feed) { + alert('Feed not found'); + return; + } + + // Populate form + document.getElementById('feed-name').value = feed.name; + document.getElementById('feed-url').value = feed.url; + document.getElementById('feed-rules').value = feed.rules.join('\n'); + document.getElementById('feed-auto-download').checked = feed.autoDownload; + + // Update modal title and button text + document.querySelector('#add-feed-modal .modal-title').textContent = 'Edit RSS Feed'; + document.getElementById('save-feed-btn').textContent = 'Save Changes'; + + // Add feed ID data attribute + document.getElementById('save-feed-btn').setAttribute('data-feed-id', feedId); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-feed-modal')); + modal.show(); + }) + .catch(error => { + console.error('Error fetching feed:', error); + alert('Error fetching feed'); + }); +} + +function saveFeed() { + const name = document.getElementById('feed-name').value.trim(); + const url = document.getElementById('feed-url').value.trim(); + const rulesText = document.getElementById('feed-rules').value.trim(); + const autoDownload = document.getElementById('feed-auto-download').checked; + + if (!name || !url) { + alert('Please enter a name and URL'); + return; + } + + // Parse rules (split by new line and remove empty lines) + const rules = rulesText.split('\n').filter(rule => rule.trim() !== ''); + + const feedId = document.getElementById('save-feed-btn').getAttribute('data-feed-id'); + const isEditing = !!feedId; + + const feedData = { + name: name, + url: url, + rules: rules, + autoDownload: autoDownload + }; + + if (isEditing) { + feedData.id = feedId; + + // Update existing feed + fetch(`/api/feeds/${feedId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to update feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + }) + .catch(error => { + console.error('Error updating feed:', error); + alert('Error updating feed'); + }); + } else { + // Add new feed + fetch('/api/feeds', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(feedData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add feed'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-feed-modal')); + modal.hide(); + + // Refresh feeds + loadFeeds(); + // Also refresh items since a new feed might have new items + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error adding feed:', error); + alert('Error adding feed'); + }); + } +} + +function deleteFeed(feedId) { + if (!confirm('Are you sure you want to delete this feed?')) { + return; + } + + fetch(`/api/feeds/${feedId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete feed'); + } + + // Refresh feeds + loadFeeds(); + // Also refresh items since items from this feed should be removed + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error deleting feed:', error); + alert('Error deleting feed'); + }); +} + +function refreshFeeds() { + const btn = document.getElementById('btn-refresh-feeds'); + btn.disabled = true; + btn.innerHTML = ' Refreshing...'; + + fetch('/api/feeds/refresh', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to refresh feeds'); + } + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + + // Refresh feed items + loadFeeds(); + loadAllItems(); + loadMatchedItems(); + }) + .catch(error => { + console.error('Error refreshing feeds:', error); + alert('Error refreshing feeds'); + + // Re-enable button + btn.disabled = false; + btn.textContent = 'Refresh Feeds'; + }); +} + +function downloadItem(itemId) { + fetch(`/api/feeds/download/${itemId}`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to download item'); + } + + // Refresh items + loadAllItems(); + loadMatchedItems(); + // Also refresh torrents since a new torrent should be added + loadTorrents(); + }) + .catch(error => { + console.error('Error downloading item:', error); + alert('Error downloading item'); + }); +} + +// Torrents +function loadTorrents() { + const torrentsElement = document.getElementById('torrents-list'); + torrentsElement.innerHTML = '
'; + + fetch('/api/torrents') + .then(response => response.json()) + .then(torrents => { + console.log('Loaded torrents:', torrents); + if (torrents.length === 0) { + torrentsElement.innerHTML = '
No torrents
'; + return; + } + + let html = '
'; + torrents.forEach(torrent => { + // Handle potential null or undefined values + if (!torrent || !torrent.name) { + console.warn('Invalid torrent data:', torrent); + return; + } + + // Safely calculate percentages and sizes with error handling + let progressPercent = 0; + try { + progressPercent = Math.round((torrent.percentDone || 0) * 100); + } catch (e) { + console.warn('Error calculating progress percent:', e); + } + + let sizeInGB = '0.00'; + try { + if (torrent.totalSize && torrent.totalSize > 0) { + sizeInGB = (torrent.totalSize / 1073741824).toFixed(2); + } + } catch (e) { + console.warn('Error calculating size in GB:', e); + } + + const torrentStatus = torrent.status || 'Unknown'; + const statusClass = torrentStatus.toLowerCase().replace(/\s+/g, '-'); + + html += `
+
+
${torrent.name}
+ ${torrentStatus} +
+
+
+
${progressPercent}%
+
+
+
+ Size: ${sizeInGB} GB + Location: ${torrent.downloadDir || 'Unknown'} +
+
+ ${torrentStatus === 'Stopped' ? + `` : + `` + } + + ${progressPercent >= 100 ? + `` : '' + } +
+
`; + }); + html += '
'; + + torrentsElement.innerHTML = html; + + // Add event listeners + document.querySelectorAll('.btn-start-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + startTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-stop-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + stopTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-remove-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + removeTorrent(torrentId); + }); + }); + + document.querySelectorAll('.btn-process-torrent').forEach(btn => { + btn.addEventListener('click', function() { + const torrentId = parseInt(this.getAttribute('data-torrent-id')); + processTorrent(torrentId); + }); + }); + }) + .catch(error => { + console.error('Error loading torrents:', error); + torrentsElement.innerHTML = '
Error loading torrents
'; + }); +} + +function showAddTorrentModal() { + // Clear form + document.getElementById('torrent-url').value = ''; + document.getElementById('torrent-download-dir').value = ''; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('add-torrent-modal')); + modal.show(); +} + +function saveTorrent() { + const url = document.getElementById('torrent-url').value.trim(); + const downloadDir = document.getElementById('torrent-download-dir').value.trim(); + + if (!url) { + alert('Please enter a torrent URL or magnet link'); + return; + } + + const torrentData = { + url: url + }; + + if (downloadDir) { + torrentData.downloadDir = downloadDir; + } + + fetch('/api/torrents', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(torrentData) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to add torrent'); + } + + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('add-torrent-modal')); + modal.hide(); + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error adding torrent:', error); + alert('Error adding torrent'); + }); +} + +function startTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/start`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to start torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error starting torrent:', error); + alert('Error starting torrent'); + }); +} + +function stopTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/stop`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to stop torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error stopping torrent:', error); + alert('Error stopping torrent'); + }); +} + +function removeTorrent(torrentId) { + if (!confirm('Are you sure you want to remove this torrent? The downloaded files will be kept.')) { + return; + } + + fetch(`/api/torrents/${torrentId}`, { + method: 'DELETE' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to remove torrent'); + } + + // Refresh torrents + loadTorrents(); + }) + .catch(error => { + console.error('Error removing torrent:', error); + alert('Error removing torrent'); + }); +} + +function processTorrent(torrentId) { + fetch(`/api/torrents/${torrentId}/process`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to process torrent'); + } + + alert('Torrent processing started'); + }) + .catch(error => { + console.error('Error processing torrent:', error); + alert('Error processing torrent'); + }); +} + +// Settings +function loadSettings() { + const form = document.getElementById('settings-form'); + + fetch('/api/config') + .then(response => response.json()) + .then(config => { + // Transmission settings + document.getElementById('transmission-host').value = config.transmission.host; + document.getElementById('transmission-port').value = config.transmission.port; + document.getElementById('transmission-use-https').checked = config.transmission.useHttps; + document.getElementById('transmission-username').value = ''; + document.getElementById('transmission-password').value = ''; + + // RSS settings + document.getElementById('auto-download-enabled').checked = config.autoDownloadEnabled; + document.getElementById('check-interval').value = config.checkIntervalMinutes; + + // Directory settings + document.getElementById('download-directory').value = config.downloadDirectory; + document.getElementById('media-library').value = config.mediaLibraryPath; + + // Post processing settings + document.getElementById('post-processing-enabled').checked = config.postProcessing.enabled; + document.getElementById('extract-archives').checked = config.postProcessing.extractArchives; + document.getElementById('organize-media').checked = config.postProcessing.organizeMedia; + document.getElementById('minimum-seed-ratio').value = config.postProcessing.minimumSeedRatio; + document.getElementById('media-extensions').value = config.postProcessing.mediaExtensions.join(', '); + }) + .catch(error => { + console.error('Error loading settings:', error); + alert('Error loading settings'); + }); +} + +function saveSettings(e) { + e.preventDefault(); + + const config = { + transmission: { + host: document.getElementById('transmission-host').value.trim(), + port: parseInt(document.getElementById('transmission-port').value), + useHttps: document.getElementById('transmission-use-https').checked, + username: document.getElementById('transmission-username').value.trim(), + password: document.getElementById('transmission-password').value.trim() + }, + autoDownloadEnabled: document.getElementById('auto-download-enabled').checked, + checkIntervalMinutes: parseInt(document.getElementById('check-interval').value), + downloadDirectory: document.getElementById('download-directory').value.trim(), + mediaLibraryPath: document.getElementById('media-library').value.trim(), + postProcessing: { + enabled: document.getElementById('post-processing-enabled').checked, + extractArchives: document.getElementById('extract-archives').checked, + organizeMedia: document.getElementById('organize-media').checked, + minimumSeedRatio: parseInt(document.getElementById('minimum-seed-ratio').value), + mediaExtensions: document.getElementById('media-extensions').value.split(',').map(ext => ext.trim()) + } + }; + + fetch('/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(config) + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to save settings'); + } + + alert('Settings saved successfully'); + }) + .catch(error => { + console.error('Error saving settings:', error); + alert('Error saving settings'); + }); +} + +// Helper functions +function formatDate(date) { + if (!date) return 'N/A'; + + // Format as "YYYY-MM-DD HH:MM" + return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`; +} + +function padZero(num) { + return num.toString().padStart(2, '0'); +} \ No newline at end of file diff --git a/working-network-version.sh b/working-network-version.sh new file mode 100755 index 0000000..77368cd --- /dev/null +++ b/working-network-version.sh @@ -0,0 +1,165 @@ +#!/bin/bash + +# Working network version with proper static file configuration + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Clean up existing test directory +echo -e "${YELLOW}Removing existing test directory...${NC}" +rm -rf "$HOME/transmission-rss-test" + +# Create and prepare test directory +echo -e "${GREEN}Creating fresh test directory...${NC}" +TEST_DIR="$HOME/transmission-rss-test" +mkdir -p "$TEST_DIR" +mkdir -p "$TEST_DIR/wwwroot/css" +mkdir -p "$TEST_DIR/wwwroot/js" + +# Copy web static files directly to wwwroot +cp -rv /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Web/wwwroot/* "$TEST_DIR/wwwroot/" + +# Create Program.cs with fixed static file configuration +cat > "$TEST_DIR/Program.cs" << 'EOL' +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using System; +using System.IO; +using TransmissionRssManager.Core; +using TransmissionRssManager.Services; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +// Add custom services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Add background services +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +var app = builder.Build(); + +// Configure middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +// Configure static files +var wwwrootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot"); +app.UseStaticFiles(new StaticFileOptions +{ + FileProvider = new PhysicalFileProvider(wwwrootPath), + RequestPath = "" +}); + +// Create default route to serve index.html +app.MapGet("/", context => +{ + context.Response.ContentType = "text/html"; + context.Response.Redirect("/index.html"); + return System.Threading.Tasks.Task.CompletedTask; +}); + +app.UseRouting(); +app.UseAuthorization(); +app.MapControllers(); + +// Log where static files are being served from +app.Logger.LogInformation($"Static files are served from: {wwwrootPath}"); + +app.Run(); +EOL + +# Create project file with System.IO +cat > "$TEST_DIR/TransmissionRssManager.csproj" << 'EOL' + + + + net7.0 + TransmissionRssManager + disable + enable + 1.0.0 + TransmissionRssManager + A C# application to manage RSS feeds and automatically download torrents via Transmission + + + + + + + + + + + + +EOL + +# Create source directories +mkdir -p "$TEST_DIR/src/Core" +mkdir -p "$TEST_DIR/src/Services" +mkdir -p "$TEST_DIR/src/Api/Controllers" + +# Copy core interfaces +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Core/Interfaces.cs "$TEST_DIR/src/Core/" + +# Copy service implementations +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/ConfigService.cs "$TEST_DIR/src/Services/" +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/TransmissionClient.cs "$TEST_DIR/src/Services/" +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/RssFeedManager.cs "$TEST_DIR/src/Services/" +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Services/PostProcessor.cs "$TEST_DIR/src/Services/" + +# Copy API controllers +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/ConfigController.cs "$TEST_DIR/src/Api/Controllers/" +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/FeedsController.cs "$TEST_DIR/src/Api/Controllers/" +cp -v /opt/develop/transmission-rss-manager/TransmissionRssManager/src/Api/Controllers/TorrentsController.cs "$TEST_DIR/src/Api/Controllers/" + +# Fix namespaces for Services +sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/RssFeedManager.cs" +sed -i 's/using Microsoft.Extensions.Hosting;/using Microsoft.Extensions.Hosting;\nusing System.Linq;/g' "$TEST_DIR/src/Services/PostProcessor.cs" + +# Get server IP +SERVER_IP=$(hostname -I | awk '{print $1}') +echo -e "${GREEN}Server IP: $SERVER_IP${NC}" + +# Build the application +cd "$TEST_DIR" +echo -e "${GREEN}Setting up NuGet packages...${NC}" +dotnet restore +if [ $? -ne 0 ]; then + echo -e "${RED}Failed to restore NuGet packages.${NC}" + exit 1 +fi + +echo -e "${GREEN}Building application...${NC}" +dotnet build +if [ $? -ne 0 ]; then + echo -e "${RED}Build failed.${NC}" + exit 1 +fi + +# Run with explicit host binding +echo -e "${GREEN}Starting application on all interfaces with explicit binding...${NC}" +echo -e "${GREEN}The web interface will be available at:${NC}" +echo -e "${GREEN}- Local: http://localhost:5000${NC}" +echo -e "${GREEN}- Network: http://${SERVER_IP}:5000${NC}" +echo -e "${YELLOW}Press Ctrl+C to stop the application${NC}" + +cd "$TEST_DIR" +dotnet run --urls="http://0.0.0.0:5000" \ No newline at end of file