commit 756c6fdd6f49d424c2bf840521f2285a22136375 Author: masterdraco Date: Wed Feb 26 08:48:34 2025 +0100 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/full-server-implementation.txt b/full-server-implementation.txt new file mode 100644 index 0000000..3eb8474 --- /dev/null +++ b/full-server-implementation.txt @@ -0,0 +1,119 @@ +// server.js - Main application server file +const express = require('express'); +const bodyParser = require('body-parser'); +const path = require('path'); +const fs = require('fs').promises; +const cors = require('cors'); +const Transmission = require('transmission'); + +// Import custom modules +const PostProcessor = require('./postProcessor'); +const RssFeedManager = require('./rssFeedManager'); + +// Initialize Express app +const app = express(); +const PORT = process.env.PORT || 3000; + +// Load configuration +let config = { + transmissionConfig: { + host: 'localhost', + port: 9091, + username: '', + password: '', + path: '/transmission/rpc' + }, + remoteConfig: { + isRemote: false, + directoryMapping: {} + }, + destinationPaths: { + movies: '/mnt/media/movies', + tvShows: '/mnt/media/tvshows', + music: '/mnt/media/music', + books: '/mnt/media/books', + software: '/mnt/media/software' + }, + seedingRequirements: { + minRatio: 1.0, + minTimeMinutes: 60, + checkIntervalSeconds: 300 + }, + processingOptions: { + extractArchives: true, + deleteArchives: true, + createCategoryFolders: true, + ignoreSample: true, + ignoreExtras: true, + renameFiles: true, + autoReplaceUpgrades: true, + removeDuplicates: true, + keepOnlyBestVersion: true + }, + rssFeeds: [], + rssUpdateIntervalMinutes: 60, + autoProcessing: false +}; + +// Service instances +let transmissionClient = null; +let postProcessor = null; +let rssFeedManager = null; + +// Save config function +async function saveConfig() { + try { + await fs.writeFile( + path.join(__dirname, 'config.json'), + JSON.stringify(config, null, 2), + 'utf8' + ); + console.log('Configuration saved'); + return true; + } catch (err) { + console.error('Error saving config:', err.message); + return false; + } +} + +// Load config function +async function loadConfig() { + try { + const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8'); + const loadedConfig = JSON.parse(data); + config = { ...config, ...loadedConfig }; + console.log('Configuration loaded'); + return true; + } catch (err) { + console.error('Error loading config, using defaults:', err.message); + // Save default config + await saveConfig(); + return false; + } +} + +// Initialize Transmission client +function initTransmission() { + transmissionClient = new Transmission({ + host: config.transmissionConfig.host, + port: config.transmissionConfig.port, + username: config.transmissionConfig.username, + password: config.transmissionConfig.password, + url: config.transmissionConfig.path + }); + + console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`); + return transmissionClient; +} + +// Initialize post processor +function initPostProcessor() { + if (postProcessor) { + postProcessor.stop(); + } + + postProcessor = new PostProcessor({ + transmissionConfig: config.transmissionConfig, + remoteConfig: config.remoteConfig, + destinationPaths: config.destinationPaths, + see \ No newline at end of file diff --git a/install-script.sh b/install-script.sh new file mode 100755 index 0000000..62547a3 --- /dev/null +++ b/install-script.sh @@ -0,0 +1,870 @@ +#!/bin/bash +# Transmission RSS Manager Installation Script for Ubuntu +# This script installs all necessary dependencies and sets up the program + +# 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}==================================================${NC}" +echo + +# Check if script is run with sudo +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Please run as root (use sudo)${NC}" + exit 1 +fi + +# Get current directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +# Configuration variables +INSTALL_DIR="/opt/transmission-rss-manager" +SERVICE_NAME="transmission-rss-manager" +USER=$(logname || echo $SUDO_USER) +PORT=3000 + +# Transmission configuration variables +TRANSMISSION_REMOTE=false +TRANSMISSION_HOST="localhost" +TRANSMISSION_PORT=9091 +TRANSMISSION_USER="" +TRANSMISSION_PASS="" +TRANSMISSION_RPC_PATH="/transmission/rpc" +TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads" +TRANSMISSION_DIR_MAPPING="{}" + +# Ask user for configuration +echo -e "${BOLD}Installation Configuration:${NC}" +echo -e "Please provide the following configuration parameters:" +echo + +read -p "Installation directory [$INSTALL_DIR]: " input_install_dir +INSTALL_DIR=${input_install_dir:-$INSTALL_DIR} + +read -p "Web interface port [$PORT]: " input_port +PORT=${input_port:-$PORT} + +read -p "Run as user [$USER]: " input_user +USER=${input_user:-$USER} + +echo +echo -e "${BOLD}Transmission Configuration:${NC}" +echo -e "Configure connection to your Transmission client:" +echo + +read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote +if [[ $input_remote =~ ^[Yy]$ ]]; then + TRANSMISSION_REMOTE=true + + read -p "Remote Transmission host [localhost]: " input_trans_host + TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST} + + read -p "Remote Transmission port [9091]: " input_trans_port + TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT} + + read -p "Remote Transmission username []: " input_trans_user + TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER} + + read -p "Remote Transmission password []: " input_trans_pass + TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS} + + read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path + TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH} + + # Configure directory mapping for remote setup + echo + echo -e "${YELLOW}Directory Mapping Configuration${NC}" + echo -e "When using a remote Transmission server, you need to map paths between servers." + echo -e "For each directory on the remote server, specify the corresponding local directory." + echo + + # Get remote download directory + read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR + REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"} + + # Get local directory that corresponds to remote download directory + read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR + LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"} + + # Create mapping JSON + TRANSMISSION_DIR_MAPPING=$(cat < /dev/null; then + echo "Installing Node.js and npm..." + apt-get install -y ca-certificates curl gnupg + mkdir -p /etc/apt/keyrings + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list + apt-get update + apt-get install -y nodejs +fi + +# Install additional dependencies +echo "Installing additional dependencies..." +apt-get install -y unrar unzip p7zip-full nginx + +# Create installation directory +echo -e "${YELLOW}Creating installation directory...${NC}" +mkdir -p $INSTALL_DIR +mkdir -p $INSTALL_DIR/logs + +# Copy application files if they exist +echo -e "${YELLOW}Copying application files...${NC}" +cp $SCRIPT_DIR/*.js $INSTALL_DIR/ 2>/dev/null || : +cp $SCRIPT_DIR/*.css $INSTALL_DIR/ 2>/dev/null || : +cp $SCRIPT_DIR/*.html $INSTALL_DIR/ 2>/dev/null || : +cp $SCRIPT_DIR/*.md $INSTALL_DIR/ 2>/dev/null || : + +# Create package.json +echo -e "${YELLOW}Creating package.json...${NC}" +cat > $INSTALL_DIR/package.json << EOF +{ + "name": "transmission-rss-manager", + "version": "1.0.0", + "description": "Transmission RSS Manager with post-processing capabilities", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.18.2", + "body-parser": "^1.20.2", + "transmission": "^0.4.10", + "adm-zip": "^0.5.10", + "node-fetch": "^2.6.9", + "xml2js": "^0.5.0", + "cors": "^2.8.5" + } +} +EOF + +# Create server.js +echo -e "${YELLOW}Creating server.js...${NC}" +cat > $INSTALL_DIR/server.js << EOF +const express = require("express"); +const bodyParser = require("body-parser"); +const path = require("path"); +const fs = require("fs").promises; +const cors = require("cors"); +const Transmission = require("transmission"); + +// Initialize Express app +const app = express(); +const PORT = process.env.PORT || $PORT; + +// Load configuration +let config = { + 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", + software: "$MEDIA_DIR/software" + }, + seedingRequirements: { + minRatio: 1.0, + minTimeMinutes: 60, + checkIntervalSeconds: 300 + }, + processingOptions: { + extractArchives: true, + deleteArchives: true, + createCategoryFolders: true, + ignoreSample: true, + ignoreExtras: true, + renameFiles: true, + autoReplaceUpgrades: true, + removeDuplicates: true, + keepOnlyBestVersion: true + }, + downloadDir: "$TRANSMISSION_DOWNLOAD_DIR" +}; + +// Save config function +async function saveConfig() { + try { + await fs.writeFile( + path.join(__dirname, 'config.json'), + JSON.stringify(config, null, 2), + 'utf8' + ); + console.log('Configuration saved'); + } catch (err) { + console.error('Error saving config:', err.message); + } +} + +// Initialize config on startup +async function loadConfig() { + try { + const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8'); + const loadedConfig = JSON.parse(data); + config = { ...config, ...loadedConfig }; + console.log('Configuration loaded'); + } catch (err) { + console.error('Error loading config, using defaults:', err.message); + // Save default config + await saveConfig(); + } +} + +// Initialize Transmission client +let transmissionClient = null; + +function initTransmission() { + transmissionClient = new Transmission({ + host: config.transmissionConfig.host, + port: config.transmissionConfig.port, + username: config.transmissionConfig.username, + password: config.transmissionConfig.password, + url: config.transmissionConfig.path + }); +} + +// Enable CORS +app.use(cors()); + +// Parse JSON bodies +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); + +// API routes - defined BEFORE static files +app.get("/api/status", (req, res) => { + res.setHeader("Content-Type", "application/json"); + res.send(JSON.stringify({ + status: "running", + version: "1.0.0", + transmissionConnected: !!transmissionClient + })); +}); + +app.post("/api/transmission/test", (req, res) => { + const { host, port, username, password } = req.body; + + // Create a test client with provided credentials + const testClient = new Transmission({ + host: host || config.transmissionConfig.host, + port: port || config.transmissionConfig.port, + username: username || config.transmissionConfig.username, + password: password || config.transmissionConfig.password, + url: config.transmissionConfig.path + }); + + // Test connection + testClient.sessionStats((err, result) => { + if (err) { + return res.json({ + success: false, + message: \`Connection failed: \${err.message}\` + }); + } + + // If successful, update config + config.transmissionConfig = { + host: host || config.transmissionConfig.host, + port: port || config.transmissionConfig.port, + username: username || config.transmissionConfig.username, + password: password || config.transmissionConfig.password, + path: config.transmissionConfig.path + }; + + // Save updated config + saveConfig(); + + // Reinitialize client + initTransmission(); + + res.json({ + success: true, + message: "Connected to Transmission successfully!", + data: { + version: result.version || "Unknown", + rpcVersion: result.rpcVersion || "Unknown" + } + }); + }); +}); + +// Get configuration +app.get("/api/config", (req, res) => { + // Don't send password in response + const safeConfig = { ...config }; + if (safeConfig.transmissionConfig) { + safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' }; + } + res.json(safeConfig); +}); + +// Update configuration +app.post("/api/config", async (req, res) => { + try { + // Merge new config with existing config + config = { + ...config, + ...req.body, + // Preserve password if not provided + transmissionConfig: { + ...config.transmissionConfig, + ...req.body.transmissionConfig, + password: req.body.transmissionConfig?.password || config.transmissionConfig.password + } + }; + + await saveConfig(); + initTransmission(); + + res.json({ success: true, message: 'Configuration updated' }); + } catch (err) { + res.status(500).json({ success: false, message: err.message }); + } +}); + +// Serve static files +app.use(express.static(path.join(__dirname, "public"))); + +// Catch-all route AFTER static files +app.get("*", (req, res) => { + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +// Initialize the application +async function init() { + await loadConfig(); + initTransmission(); + + // Start the server + app.listen(PORT, () => { + console.log(\`Transmission RSS Manager running on port \${PORT}\`); + }); +} + +// Start the application +init().catch(err => { + console.error('Failed to initialize application:', err); +}); +EOF + +# Create public directory and index.html +mkdir -p $INSTALL_DIR/public +echo -e "${YELLOW}Creating index.html...${NC}" +cat > $INSTALL_DIR/public/index.html << EOF + + + + + + Transmission RSS Manager + + + +
+

Transmission RSS Manager

+ +
+
Connection
+
Configuration
+
Status
+
+ +
+ Checking connection... +
+ +
+
+

Connection Settings

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

System Configuration

+

Configuration options will be available after connecting to Transmission.

+
+
+ +
+
+

System Status

+
+

Loading system information...

+
+
+
+
+ + + + +EOF + +# Create directories for media +echo -e "${YELLOW}Creating media directories...${NC}" +mkdir -p $MEDIA_DIR/movies +mkdir -p $MEDIA_DIR/tvshows +mkdir -p $MEDIA_DIR/music +mkdir -p $MEDIA_DIR/books +mkdir -p $MEDIA_DIR/software + +# Set correct permissions +echo -e "${YELLOW}Setting permissions...${NC}" +chown -R $USER:$USER $INSTALL_DIR +chown -R $USER:$USER $MEDIA_DIR +chmod -R 755 $INSTALL_DIR +chmod -R 755 $MEDIA_DIR + +# Create config file +echo -e "${YELLOW}Creating configuration file...${NC}" +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", + "software": "$MEDIA_DIR/software" + }, + "seedingRequirements": { + "minRatio": 1.0, + "minTimeMinutes": 60, + "checkIntervalSeconds": 300 + }, + "processingOptions": { + "extractArchives": true, + "deleteArchives": true, + "createCategoryFolders": true, + "ignoreSample": true, + "ignoreExtras": true, + "renameFiles": true, + "autoReplaceUpgrades": true, + "removeDuplicates": true, + "keepOnlyBestVersion": true + }, + "downloadDir": "$TRANSMISSION_DOWNLOAD_DIR" +} +EOF + +# Install Node.js dependencies +echo -e "${YELLOW}Installing Node.js dependencies...${NC}" +cd $INSTALL_DIR +npm install + +# Create systemd service +echo -e "${YELLOW}Creating systemd service...${NC}" +cat > /etc/systemd/system/$SERVICE_NAME.service << EOF +[Unit] +Description=Transmission RSS Manager +After=network.target +Wants=transmission-daemon.service + +[Service] +ExecStart=/usr/bin/node $INSTALL_DIR/server.js +WorkingDirectory=$INSTALL_DIR +Restart=always +User=$USER +Environment=NODE_ENV=production PORT=$PORT + +[Install] +WantedBy=multi-user.target +EOF + +# Create Nginx configuration +echo -e "${YELLOW}Creating Nginx configuration...${NC}" +cat > /etc/nginx/sites-available/$SERVICE_NAME << EOF +server { + listen 80; + listen [::]:80; + + # Change this to your domain if you have one + server_name _; + + # Main location for static files + location / { + proxy_pass http://localhost:$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; + } + + # Specific location for API calls + location /api/ { + proxy_pass http://localhost:$PORT/api/; + proxy_http_version 1.1; + proxy_set_header Host \$host; + 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 + +# Enable Nginx site +ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/ + +# Test Nginx configuration +nginx -t + +# Reload Nginx +systemctl reload nginx + +# Enable and start the service +echo -e "${YELLOW}Starting service...${NC}" +systemctl daemon-reload +systemctl enable $SERVICE_NAME +systemctl start $SERVICE_NAME + +# Print completion message +echo +echo -e "${GREEN}${BOLD}Installation Complete!${NC}" +echo +echo -e "${BOLD}The Transmission RSS Manager has been installed to:${NC} $INSTALL_DIR" +echo -e "${BOLD}Web interface is available at:${NC} http://localhost (or your server IP)" +echo -e "${BOLD}Service status:${NC} $(systemctl is-active $SERVICE_NAME)" +echo +echo -e "${BOLD}To view logs:${NC} journalctl -u $SERVICE_NAME -f" +echo -e "${BOLD}To restart service:${NC} sudo systemctl restart $SERVICE_NAME" +echo + +if [ "$TRANSMISSION_REMOTE" = true ]; then + echo -e "${YELLOW}Remote Transmission Configuration:${NC}" + echo -e "Host: $TRANSMISSION_HOST:$TRANSMISSION_PORT" + echo -e "Directory Mapping: Remote paths will be mapped to local paths for processing" + echo +fi + +# Final check +if systemctl is-active --quiet $SERVICE_NAME; then + echo -e "${GREEN}Service is running successfully!${NC}" +else + echo -e "${RED}Service failed to start. Check logs with: journalctl -u $SERVICE_NAME -f${NC}" +fi + +echo -e "${BOLD}==================================================${NC}" diff --git a/post-processor-implementation.txt b/post-processor-implementation.txt new file mode 100644 index 0000000..dbef91e --- /dev/null +++ b/post-processor-implementation.txt @@ -0,0 +1,982 @@ +// postProcessor.js - Handles post-download processing +const fs = require('fs').promises; +const path = require('path'); +const childProcess = require('child_process'); +const util = require('util'); +const exec = util.promisify(childProcess.exec); +const Transmission = require('transmission'); +const AdmZip = require('adm-zip'); + +class PostProcessor { + constructor(config = {}) { + this.config = { + // Transmission connection details + transmissionConfig: config.transmissionConfig || {}, + + // Remote configuration + remoteConfig: { + isRemote: false, + directoryMapping: {}, + ...config.remoteConfig + }, + + // Destination paths + destinationPaths: { + movies: '/mnt/media/movies', + tvShows: '/mnt/media/tvshows', + music: '/mnt/media/music', + books: '/mnt/media/books', + software: '/mnt/media/software', + ...config.destinationPaths + }, + + // Seeding requirements + seedingRequirements: { + minRatio: 1.0, // Minimum ratio to achieve + minTimeMinutes: 60, // Minimum seeding time in minutes + checkIntervalSeconds: 300, // Check interval in seconds + ...config.seedingRequirements + }, + + // Processing options + processingOptions: { + extractArchives: true, // Extract rar, zip, etc. + deleteArchives: true, // Delete archives after extraction + createCategoryFolders: true, // Create category subfolders + ignoreSample: true, // Ignore sample files + ignoreExtras: true, // Ignore extra/bonus content + renameFiles: true, // Rename files to clean names + autoReplaceUpgrades: true, // Automatically replace with better quality + removeDuplicates: true, // Remove duplicate content + keepOnlyBestVersion: true, // Keep only the best version when duplicates exist + ...config.processingOptions + }, + + // File patterns + filePatterns: { + videoExtensions: ['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.m4v', '.mpg', '.mpeg'], + audioExtensions: ['.mp3', '.flac', '.m4a', '.wav', '.ogg', '.aac'], + archiveExtensions: ['.rar', '.zip', '.7z', '.tar', '.gz'], + ignorePatterns: ['sample', 'trailer', 'extra', 'bonus', 'behind.the.scenes', 'featurette'], + ...config.filePatterns + } + }; + + this.transmissionClient = new Transmission(this.config.transmissionConfig); + this.processQueue = []; + this.isProcessing = false; + this.processingIntervalId = null; + + // Initialize the database + this.managedContentDatabase = this.loadManagedContentDatabase(); + } + + // Start processing monitor + start() { + if (this.processingIntervalId) { + this.stop(); + } + + this.processingIntervalId = setInterval(() => { + this.checkCompletedTorrents(); + }, this.config.seedingRequirements.checkIntervalSeconds * 1000); + + console.log('Post-processor started'); + this.checkCompletedTorrents(); // Do an initial check + + return this; + } + + // Stop processing monitor + stop() { + if (this.processingIntervalId) { + clearInterval(this.processingIntervalId); + this.processingIntervalId = null; + } + + console.log('Post-processor stopped'); + return this; + } + + // Check for completed torrents that meet seeding requirements + async checkCompletedTorrents() { + if (this.isProcessing) { + return; // Don't start a new check if already processing + } + + try { + this.isProcessing = true; + + // Get detailed info about all torrents + const response = await this.getTorrents([ + 'id', 'name', 'status', 'downloadDir', 'percentDone', 'uploadRatio', + 'addedDate', 'doneDate', 'downloadedEver', 'files', 'labels', + 'secondsSeeding', 'isFinished' + ]); + + if (!response || !response.arguments || !response.arguments.torrents) { + throw new Error('Invalid response from Transmission'); + } + + const torrents = response.arguments.torrents; + + // Find completed torrents that are seeding + for (const torrent of torrents) { + // Skip if not finished downloading or already processed + if (!torrent.isFinished || this.processQueue.some(item => item.id === torrent.id)) { + continue; + } + + // Calculate seeding time in minutes + const seedingTimeMinutes = torrent.secondsSeeding / 60; + + // Check if seeding requirements are met + const ratioMet = torrent.uploadRatio >= this.config.seedingRequirements.minRatio; + const timeMet = seedingTimeMinutes >= this.config.seedingRequirements.minTimeMinutes; + + // If either requirement is met, queue for processing + if (ratioMet || timeMet) { + this.processQueue.push({ + id: torrent.id, + name: torrent.name, + downloadDir: torrent.downloadDir, + files: torrent.files, + category: this.detectCategory(torrent), + ratioMet, + timeMet, + seedingTimeMinutes + }); + + console.log(`Queued torrent for processing: ${torrent.name}`); + } + } + + // Process the queue + if (this.processQueue.length > 0) { + await this.processNextInQueue(); + } + } catch (error) { + console.error('Error checking completed torrents:', error); + } finally { + this.isProcessing = false; + } + } + + // Process next item in queue + async processNextInQueue() { + if (this.processQueue.length === 0) { + return; + } + + const item = this.processQueue.shift(); + + try { + console.log(`Processing: ${item.name}`); + + // 1. Determine target directory + const targetDir = this.getTargetDirectory(item); + + // 2. Process files (copy/extract) + await this.processFiles(item, targetDir); + + // 3. Update status to indicate processing is complete + console.log(`Processing complete for: ${item.name}`); + + // 4. Optionally remove torrent from Transmission (commented out for safety) + // await this.removeTorrent(item.id, false); + + } catch (error) { + console.error(`Error processing ${item.name}:`, error); + + // Put back in queue to retry later + this.processQueue.push(item); + } + + // Process next item + if (this.processQueue.length > 0) { + await this.processNextInQueue(); + } + } + + // Get torrents from Transmission + async getTorrents(fields) { + return new Promise((resolve, reject) => { + this.transmissionClient.get(fields, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Remove torrent from Transmission + async removeTorrent(id, deleteLocalData = false) { + return new Promise((resolve, reject) => { + this.transmissionClient.remove(id, deleteLocalData, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Determine the category of a torrent + detectCategory(torrent) { + // First check if torrent has labels + if (torrent.labels && torrent.labels.length > 0) { + const label = torrent.labels[0].toLowerCase(); + + if (label.includes('movie')) return 'movies'; + if (label.includes('tv') || label.includes('show')) return 'tvShows'; + if (label.includes('music') || label.includes('audio')) return 'music'; + if (label.includes('book') || label.includes('ebook')) return 'books'; + if (label.includes('software') || label.includes('app')) return 'software'; + } + + // Then check the name for common patterns + const name = torrent.name.toLowerCase(); + + // Check for TV show patterns (e.g., S01E01, Season 1, etc.) + if (/s\d{1,2}e\d{1,2}/i.test(name) || /season\s\d{1,2}/i.test(name)) { + return 'tvShows'; + } + + // Check file extensions to help determine type + const fileExtensions = torrent.files.map(file => { + const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); + return ext; + }); + + const hasVideoFiles = fileExtensions.some(ext => + this.config.filePatterns.videoExtensions.includes(ext) + ); + + const hasAudioFiles = fileExtensions.some(ext => + this.config.filePatterns.audioExtensions.includes(ext) + ); + + if (hasVideoFiles) { + // Assume movies if has video but not detected as TV show + return 'movies'; + } + + if (hasAudioFiles) { + return 'music'; + } + + // Default fallback based on common terms + if (name.includes('movie') || name.includes('film')) return 'movies'; + if (name.includes('album') || name.includes('discography')) return 'music'; + if (name.includes('book') || name.includes('epub') || name.includes('pdf')) return 'books'; + if (name.includes('software') || name.includes('program') || name.includes('app')) return 'software'; + + // Ultimate fallback + return 'movies'; + } + + // Get target directory path based on category + getTargetDirectory(item) { + const baseDir = this.config.destinationPaths[item.category] || this.config.destinationPaths.movies; + + if (!this.config.processingOptions.createCategoryFolders) { + return baseDir; + } + + // For movies, we might want to organize by name + if (item.category === 'movies') { + // Extract name and year if possible + const mediaInfo = this.extractMediaInfo(item.name); + if (mediaInfo.title) { + return `${baseDir}/${mediaInfo.title}${mediaInfo.year ? ` (${mediaInfo.year})` : ''}`; + } + } + + // For TV shows, organize by show name + if (item.category === 'tvShows') { + // Try to extract show name from common patterns + const match = item.name.match(/^(.+?)(?:\s*S\d{1,2}|\s*Season\s*\d{1,2}|\s*\(\d{4}\))/i); + if (match) { + const showName = match[1].replace(/\./g, ' ').trim(); + return `${baseDir}/${showName}`; + } + } + + // Fallback to using the item name directly + return `${baseDir}/${item.name}`; + } + + // Map a remote path to a local path + mapRemotePathToLocal(remotePath) { + if (!this.config.remoteConfig.isRemote) { + return remotePath; // Not a remote setup, return as is + } + + const mapping = this.config.remoteConfig.directoryMapping; + + // Check for exact match + if (mapping[remotePath]) { + return mapping[remotePath]; + } + + // Check for parent directory match + for (const [remote, local] of Object.entries(mapping)) { + if (remotePath.startsWith(remote)) { + const relativePath = remotePath.slice(remote.length); + return local + relativePath; + } + } + + // No mapping found, return original path but log warning + console.warn(`No directory mapping found for remote path: ${remotePath}`); + return remotePath; + } + + // Map a local path to a remote path + mapLocalPathToRemote(localPath) { + if (!this.config.remoteConfig.isRemote) { + return localPath; // Not a remote setup, return as is + } + + const mapping = this.config.remoteConfig.directoryMapping; + const reversedMapping = {}; + + // Create reversed mapping (local -> remote) + for (const [remote, local] of Object.entries(mapping)) { + reversedMapping[local] = remote; + } + + // Check for exact match + if (reversedMapping[localPath]) { + return reversedMapping[localPath]; + } + + // Check for parent directory match + for (const [local, remote] of Object.entries(reversedMapping)) { + if (localPath.startsWith(local)) { + const relativePath = localPath.slice(local.length); + return remote + relativePath; + } + } + + // No mapping found, return original path but log warning + console.warn(`No directory mapping found for local path: ${localPath}`); + return localPath; + } + + // Process files (copy or extract) + async processFiles(item, targetDir) { + // Check for existing versions of the same content + const existingVersion = await this.findExistingVersion(item); + + if (existingVersion) { + console.log(`Found existing version: ${existingVersion.path}`); + + // Check if new version is better quality + const isUpgrade = await this.isQualityUpgrade(item, existingVersion); + + if (isUpgrade) { + console.log(`New version is a quality upgrade. Replacing...`); + + // If auto-replace is enabled, remove the old version + if (this.config.processingOptions.autoReplaceUpgrades) { + await this.removeExistingVersion(existingVersion); + } + } else if (this.config.processingOptions.keepOnlyBestVersion) { + // If not an upgrade and we only want to keep the best version, stop processing + console.log(`New version is not a quality upgrade. Skipping...`); + return false; + } + } + + // If checking for duplicates, verify this isn't a duplicate + if (this.config.processingOptions.removeDuplicates) { + const duplicates = await this.findDuplicates(item); + + if (duplicates.length > 0) { + console.log(`Found ${duplicates.length} duplicate(s).`); + + // Keep only the best version if enabled + if (this.config.processingOptions.keepOnlyBestVersion) { + // Determine the best version among all (including the new one) + const allVersions = [...duplicates, { item, quality: this.getQualityRank(item) }]; + const bestVersion = this.findBestQualityVersion(allVersions); + + // If the new version is the best, remove all others and continue + if (bestVersion.item === item) { + console.log(`New version is the best quality. Removing duplicates.`); + for (const duplicate of duplicates) { + await this.removeExistingVersion(duplicate); + } + } else { + // If the new version is not the best, skip processing + console.log(`A better version already exists. Skipping...`); + return false; + } + } + } + } + + try { + // Create target directory + await fs.mkdir(targetDir, { recursive: true }); + + // For each file in the torrent + for (const file of item.files) { + // Handle remote path mapping if applicable + let sourcePath; + if (this.config.remoteConfig.isRemote) { + // Map the remote download dir to local path + const localDownloadDir = this.mapRemotePathToLocal(item.downloadDir); + sourcePath = path.join(localDownloadDir, file.name); + } else { + sourcePath = path.join(item.downloadDir, file.name); + } + + const fileExt = path.extname(file.name).toLowerCase(); + + // Skip sample files if configured + if (this.config.processingOptions.ignoreSample && + this.config.filePatterns.ignorePatterns.some(pattern => + file.name.toLowerCase().includes(pattern))) { + console.log(`Skipping file (matches ignore pattern): ${file.name}`); + continue; + } + + // Handle archives if extraction is enabled + if (this.config.processingOptions.extractArchives && + this.config.filePatterns.archiveExtensions.includes(fileExt)) { + console.log(`Extracting archive: ${sourcePath} to ${targetDir}`); + + // Extract the archive + if (fileExt === '.zip') { + await this.extractZipArchive(sourcePath, targetDir); + } else if (fileExt === '.rar') { + await this.extractRarArchive(sourcePath, targetDir); + } else { + console.log(`Unsupported archive type: ${fileExt}`); + // Just copy the file as is + let destFileName = file.name; + if (this.config.processingOptions.renameFiles) { + destFileName = this.cleanFileName(file.name); + } + const destPath = path.join(targetDir, destFileName); + await fs.copyFile(sourcePath, destPath); + } + + // Delete archive after extraction if configured + if (this.config.processingOptions.deleteArchives) { + console.log(`Deleting archive after extraction: ${sourcePath}`); + try { + await fs.unlink(sourcePath); + } catch (err) { + console.error(`Error deleting archive: ${err.message}`); + } + } + } + // Copy non-archive files + else { + let destFileName = file.name; + + // Rename files if configured + if (this.config.processingOptions.renameFiles) { + destFileName = this.cleanFileName(file.name); + } + + // Create subdirectories if needed + const subDir = path.dirname(file.name); + if (subDir !== '.') { + const targetSubDir = path.join(targetDir, subDir); + await fs.mkdir(targetSubDir, { recursive: true }); + } + + const destPath = path.join(targetDir, destFileName); + console.log(`Copying file: ${sourcePath} to ${destPath}`); + + // Copy the file + await fs.copyFile(sourcePath, destPath); + } + } + + // After successful processing, update the managed content database + await this.updateManagedContent(item, targetDir); + + return true; + } catch (error) { + console.error(`Error processing files: ${error.message}`); + throw error; + } + } + + // Extract ZIP archive + async extractZipArchive(archivePath, destination) { + try { + const zip = new AdmZip(archivePath); + zip.extractAllTo(destination, true); + return true; + } catch (error) { + console.error(`Failed to extract ZIP archive: ${error.message}`); + throw error; + } + } + + // Extract RAR archive + async extractRarArchive(archivePath, destination) { + try { + // Check if unrar is available + await exec('unrar --version'); + + // Use unrar to extract the archive + const command = `unrar x -o+ "${archivePath}" "${destination}"`; + await exec(command); + + return true; + } catch (error) { + console.error(`Failed to extract RAR archive: ${error.message}`); + throw error; + } + } + + // Load managed content database + loadManagedContentDatabase() { + try { + if (typeof window !== 'undefined' && window.localStorage) { + // Browser environment + const savedData = localStorage.getItem('managed-content-database'); + return savedData ? JSON.parse(savedData) : []; + } else { + // Node.js environment + try { + const fs = require('fs'); + const path = require('path'); + const dbPath = path.join(__dirname, 'managed-content-database.json'); + + if (fs.existsSync(dbPath)) { + const data = fs.readFileSync(dbPath, 'utf8'); + return JSON.parse(data); + } + } catch (err) { + console.error('Error reading database file:', err); + } + return []; + } + } catch (error) { + console.error('Error loading managed content database:', error); + return []; + } + } + + // Save managed content database + saveManagedContentDatabase(database) { + try { + if (typeof window !== 'undefined' && window.localStorage) { + // Browser environment + localStorage.setItem('managed-content-database', JSON.stringify(database)); + } else { + // Node.js environment + const fs = require('fs'); + const path = require('path'); + const dbPath = path.join(__dirname, 'managed-content-database.json'); + fs.writeFileSync(dbPath, JSON.stringify(database, null, 2), 'utf8'); + } + } catch (error) { + console.error('Error saving managed content database:', error); + } + } + + // Update managed content database with new item + async updateManagedContent(item, targetDir) { + const database = this.loadManagedContentDatabase(); + + // Extract media info + const mediaInfo = this.extractMediaInfo(item.name); + + // Calculate quality rank + const qualityRank = this.getQualityRank(item); + + // Check if entry already exists + const existingIndex = database.findIndex(entry => + entry.title.toLowerCase() === mediaInfo.title.toLowerCase() && + (!mediaInfo.year || !entry.year || entry.year === mediaInfo.year) + ); + + // Create new entry + const newEntry = { + id: Date.now().toString(), + title: mediaInfo.title, + year: mediaInfo.year, + category: item.category, + quality: this.detectQuality(item.name), + qualityRank, + path: targetDir, + originalName: item.name, + dateAdded: new Date().toISOString(), + torrentId: item.id + }; + + // If entry exists, update or add to history + if (existingIndex !== -1) { + // If new version has better quality + if (qualityRank > database[existingIndex].qualityRank) { + // Save the old version to history + if (!database[existingIndex].history) { + database[existingIndex].history = []; + } + + database[existingIndex].history.push({ + quality: database[existingIndex].quality, + qualityRank: database[existingIndex].qualityRank, + path: database[existingIndex].path, + originalName: database[existingIndex].originalName, + dateAdded: database[existingIndex].dateAdded + }); + + // Update with new version + database[existingIndex].quality = newEntry.quality; + database[existingIndex].qualityRank = newEntry.qualityRank; + database[existingIndex].path = newEntry.path; + database[existingIndex].originalName = newEntry.originalName; + database[existingIndex].dateUpdated = new Date().toISOString(); + database[existingIndex].torrentId = newEntry.torrentId; + } + } else { + // Add new entry + database.push(newEntry); + } + + // Save updated database + this.saveManagedContentDatabase(database); + } + + // Find existing version of the same content + async findExistingVersion(item) { + const database = this.loadManagedContentDatabase(); + const mediaInfo = this.extractMediaInfo(item.name); + + // Find matching content + const match = database.find(entry => + entry.title.toLowerCase() === mediaInfo.title.toLowerCase() && + (!mediaInfo.year || !entry.year || entry.year === mediaInfo.year) + ); + + return match; + } + + // Find duplicates of the same content + async findDuplicates(item) { + const database = this.loadManagedContentDatabase(); + const mediaInfo = this.extractMediaInfo(item.name); + + // Find all matching content + const matches = database.filter(entry => + entry.title.toLowerCase() === mediaInfo.title.toLowerCase() && + (!mediaInfo.year || !entry.year || entry.year === mediaInfo.year) + ); + + return matches; + } + + // Remove an existing version + async removeExistingVersion(version) { + console.log(`Removing existing version: ${version.path}`); + + try { + // Check if directory exists + const stats = await fs.stat(version.path); + + if (stats.isDirectory()) { + // Remove directory and all contents + await this.removeDirectory(version.path); + } else { + // Just remove the file + await fs.unlink(version.path); + } + + // Remove from managed content database + const database = this.loadManagedContentDatabase(); + const updatedDatabase = database.filter(entry => entry.id !== version.id); + this.saveManagedContentDatabase(updatedDatabase); + + return true; + } catch (error) { + console.error(`Error removing existing version: ${error.message}`); + return false; + } + } + + // Remove a directory recursively + async removeDirectory(dirPath) { + try { + const items = await fs.readdir(dirPath); + + for (const item of items) { + const itemPath = path.join(dirPath, item); + const stats = await fs.stat(itemPath); + + if (stats.isDirectory()) { + await this.removeDirectory(itemPath); + } else { + await fs.unlink(itemPath); + } + } + + await fs.rmdir(dirPath); + } catch (error) { + console.error(`Error removing directory: ${error.message}`); + throw error; + } + } + + // Check if a new version is a quality upgrade + async isQualityUpgrade(newItem, existingVersion) { + const newQuality = this.getQualityRank(newItem); + const existingQuality = existingVersion.qualityRank || 0; + + return newQuality > existingQuality; + } + + // Find the best quality version among multiple versions + findBestQualityVersion(versions) { + return versions.reduce((best, current) => { + return (current.qualityRank > best.qualityRank) ? current : best; + }, versions[0]); + } + + // Calculate quality rank for an item + getQualityRank(item) { + const quality = this.detectQuality(item.name); + + if (!quality) return 0; + + const qualityStr = quality.toLowerCase(); + + if (qualityStr.includes('2160p') || qualityStr.includes('4k') || qualityStr.includes('uhd')) { + return 5; + } + + if (qualityStr.includes('1080p') || qualityStr.includes('fullhd') || + (qualityStr.includes('bluray') && !qualityStr.includes('720p'))) { + return 4; + } + + if (qualityStr.includes('720p') || qualityStr.includes('hd')) { + return 3; + } + + if (qualityStr.includes('dvdrip') || qualityStr.includes('sdbd')) { + return 2; + } + + if (qualityStr.includes('webrip') || qualityStr.includes('webdl') || qualityStr.includes('web-dl')) { + return 1; + } + + // Low quality or unknown + return 0; + } + + // Detect quality from item name + detectQuality(name) { + const lowerName = name.toLowerCase(); + + // Quality patterns + const qualityPatterns = [ + { regex: /\b(2160p|uhd|4k)\b/i, quality: '2160p/4K' }, + { regex: /\b(1080p|fullhd|fhd)\b/i, quality: '1080p' }, + { regex: /\b(720p|hd)\b/i, quality: '720p' }, + { regex: /\b(bluray|bdremux|bdrip)\b/i, quality: 'BluRay' }, + { regex: /\b(webdl|web-dl|webrip)\b/i, quality: 'WebDL' }, + { regex: /\b(dvdrip|dvd-rip)\b/i, quality: 'DVDRip' }, + { regex: /\b(hdtv)\b/i, quality: 'HDTV' }, + { regex: /\b(hdtc|hd-tc)\b/i, quality: 'HDTC' }, + { regex: /\b(hdts|hd-ts|hdcam)\b/i, quality: 'HDTS' }, + { regex: /\b(cam|camrip)\b/i, quality: 'CAM' } + ]; + + for (const pattern of qualityPatterns) { + if (pattern.regex.test(lowerName)) { + return pattern.quality; + } + } + + return 'Unknown'; + } + + // Extract media info from name + extractMediaInfo(name) { + // Year pattern (e.g., "2023" or "(2023)" or "[2023]") + const yearPattern = /[\[\(\s.]+(19|20)\d{2}[\]\)\s.]+/; + const yearMatch = name.match(yearPattern); + const year = yearMatch ? yearMatch[0].replace(/[\[\]\(\)\s.]+/g, '') : null; + + // Clean up title to get movie name + let cleanTitle = yearMatch ? name.split(yearPattern)[0].trim() : name; + + // Remove quality indicators, scene tags, etc. + const cleanupPatterns = [ + /\b(720p|1080p|2160p|4K|UHD|HDTV|WEB-DL|WEBRip|BRRip|BluRay|DVDRip)\b/gi, + /\b(HEVC|AVC|x264|x265|H\.?264|H\.?265|XVID|DIVX)\b/gi, + /\b(AAC|MP3|AC3|DTS|FLAC|OPUS)\b/gi, + /\b(AMZN|DSNP|HULU|HMAX|NF|PCOK)\b/gi, + /\b(EXTENDED|Directors\.?Cut|UNRATED|REMASTERED|PROPER|REMUX)\b/gi, + /\b(IMAX)\b/gi, + /[-\.][A-Za-z0-9]+$/ + ]; + + cleanupPatterns.forEach(pattern => { + cleanTitle = cleanTitle.replace(pattern, ''); + }); + + // Final cleanup + cleanTitle = cleanTitle + .replace(/[._-]/g, ' ') + .replace(/\s{2,}/g, ' ') + .trim(); + + return { + title: cleanTitle, + year: year + }; + } + + // Clean up file name for nicer presentation + cleanFileName(fileName) { + // Remove common scene tags + let cleaned = fileName; + + // Remove stuff in brackets + cleaned = cleaned.replace(/\[[^\]]+\]/g, ''); + + // Remove scene group names after dash + cleaned = cleaned.replace(/-[A-Za-z0-9]+$/, ''); + + // Replace dots with spaces + cleaned = cleaned.replace(/\./g, ' '); + + // Remove multiple spaces + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim(); + + // Keep the file extension + const lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex !== -1) { + const extension = fileName.substring(lastDotIndex); + cleaned = cleaned + extension; + } + + return cleaned; + } + + // Update configuration + updateConfig(newConfig) { + this.config = { + ...this.config, + ...newConfig, + transmissionConfig: { + ...this.config.transmissionConfig, + ...newConfig.transmissionConfig + }, + remoteConfig: { + ...this.config.remoteConfig, + ...newConfig.remoteConfig + }, + destinationPaths: { + ...this.config.destinationPaths, + ...newConfig.destinationPaths + }, + seedingRequirements: { + ...this.config.seedingRequirements, + ...newConfig.seedingRequirements + }, + processingOptions: { + ...this.config.processingOptions, + ...newConfig.processingOptions + }, + filePatterns: { + ...this.config.filePatterns, + ...newConfig.filePatterns + } + }; + + // Reinitialize the Transmission client + this.transmissionClient = new Transmission(this.config.transmissionConfig); + + return this; + } + + // Get library statistics + getLibraryStats() { + const database = this.loadManagedContentDatabase(); + + const stats = { + totalItems: database.length, + byCategory: {}, + byQuality: { + 'Unknown': 0, + 'CAM/TS': 0, + 'SD': 0, + '720p': 0, + '1080p': 0, + '4K/UHD': 0 + }, + totalUpgrades: 0 + }; + + // Process each item + database.forEach(item => { + // Count by category + stats.byCategory[item.category] = (stats.byCategory[item.category] || 0) + 1; + + // Count by quality rank + if (item.qualityRank >= 5) { + stats.byQuality['4K/UHD']++; + } else if (item.qualityRank >= 4) { + stats.byQuality['1080p']++; + } else if (item.qualityRank >= 3) { + stats.byQuality['720p']++; + } else if (item.qualityRank >= 1) { + stats.byQuality['SD']++; + } else if (item.qualityRank === 0 && item.quality && item.quality.toLowerCase().includes('cam')) { + stats.byQuality['CAM/TS']++; + } else { + stats.byQuality['Unknown']++; + } + + // Count upgrades + if (item.history && item.history.length > 0) { + stats.totalUpgrades += item.history.length; + } + }); + + return stats; + } + + // Get entire media library + getLibrary() { + return this.loadManagedContentDatabase(); + } + + // Search for media items + searchLibrary(query) { + if (!query) return this.getLibrary(); + + const database = this.loadManagedContentDatabase(); + const lowerQuery = query.toLowerCase(); + + return database.filter(item => + item.title.toLowerCase().includes(lowerQuery) || + (item.year && item.year.includes(lowerQuery)) || + (item.category && item.category.toLowerCase().includes(lowerQuery)) || + (item.quality && item.quality.toLowerCase().includes(lowerQuery)) + ); + } +} + +// Export for Node.js or browser +if (typeof module !== 'undefined' && module.exports) { + module.exports = PostProcessor; +} else if (typeof window !== 'undefined') { + window.PostProcessor = PostProcessor; +} diff --git a/rss-implementation.js b/rss-implementation.js new file mode 100644 index 0000000..fc9b334 --- /dev/null +++ b/rss-implementation.js @@ -0,0 +1,586 @@ +// rssFeedManager.js - Manages RSS feeds and parsing +const xml2js = require('xml2js'); +const fetch = require('node-fetch'); +const fs = require('fs').promises; +const path = require('path'); + +class RssFeedManager { + constructor(config = {}) { + this.config = { + feeds: [], + updateIntervalMinutes: 60, + maxItemsPerFeed: 100, + autoDownload: false, + downloadFilters: {}, + ...config + }; + + this.parser = new xml2js.Parser({ + explicitArray: false, + normalize: true, + normalizeTags: true + }); + + this.feedItems = []; + this.updateIntervalId = null; + } + + // Start RSS feed monitoring + start() { + if (this.updateIntervalId) { + this.stop(); + } + + this.updateIntervalId = setInterval(() => { + this.updateAllFeeds(); + }, this.config.updateIntervalMinutes * 60 * 1000); + + console.log('RSS feed manager started'); + this.updateAllFeeds(); // Do an initial update + + return this; + } + + // Stop RSS feed monitoring + stop() { + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + } + + console.log('RSS feed manager stopped'); + return this; + } + + // Update all feeds + async updateAllFeeds() { + console.log('Updating all RSS feeds'); + + const results = { + total: this.config.feeds.length, + success: 0, + failed: 0, + newItems: 0 + }; + + for (const feed of this.config.feeds) { + try { + const newItems = await this.updateFeed(feed); + results.success++; + results.newItems += newItems; + } catch (error) { + console.error(`Error updating feed ${feed.name || feed.url}:`, error); + results.failed++; + } + } + + console.log(`Feed update complete: ${results.success}/${results.total} feeds updated, ${results.newItems} new items`); + + // Save updated feed items to database + await this.saveItems(); + + return results; + } + + // Update a single feed + async updateFeed(feed) { + console.log(`Updating feed: ${feed.name || feed.url}`); + + try { + const response = await fetch(feed.url); + + if (!response.ok) { + throw new Error(`Failed to fetch RSS feed: ${response.statusText}`); + } + + const xml = await response.text(); + const result = await this.parseXml(xml); + + // Extract and format feed items + const items = this.extractItems(result, feed); + + // Count new items (not already in our database) + let newItemCount = 0; + + // Process each item + for (const item of items) { + // Generate a unique ID for the item + const itemId = this.generateItemId(item); + + // Check if item already exists + const existingItem = this.feedItems.find(i => i.id === itemId); + + if (!existingItem) { + // Add feed info to item + item.feedId = feed.id; + item.feedName = feed.name; + item.id = itemId; + item.firstSeen = new Date().toISOString(); + item.downloaded = false; + + // Add to items array + this.feedItems.push(item); + newItemCount++; + + // Auto-download if enabled and item matches filter + if (feed.autoDownload && this.matchesFilter(item, feed.filters)) { + this.downloadItem(item); + } + } + } + + console.log(`Feed updated: ${feed.name || feed.url}, ${newItemCount} new items`); + return newItemCount; + } catch (error) { + console.error(`Error updating feed: ${error.message}`); + throw error; + } + } + + // Parse XML data + async parseXml(xml) { + return new Promise((resolve, reject) => { + this.parser.parseString(xml, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Extract items from parsed feed + extractItems(parsedFeed, feedConfig) { + const items = []; + + // Handle different feed formats + let feedItems = []; + + if (parsedFeed.rss && parsedFeed.rss.channel) { + // Standard RSS + feedItems = parsedFeed.rss.channel.item || []; + if (!Array.isArray(feedItems)) { + feedItems = [feedItems]; + } + } else if (parsedFeed.feed && parsedFeed.feed.entry) { + // Atom format + feedItems = parsedFeed.feed.entry || []; + if (!Array.isArray(feedItems)) { + feedItems = [feedItems]; + } + } + + // Process each item + for (const item of feedItems) { + const processedItem = this.processItem(item, feedConfig); + if (processedItem) { + items.push(processedItem); + } + } + + return items; + } + + // Process a single feed item + processItem(item, feedConfig) { + // Extract basic fields + const processed = { + title: item.title || '', + description: item.description || item.summary || '', + pubDate: item.pubdate || item.published || item.date || new Date().toISOString(), + size: 0, + seeders: 0, + leechers: 0, + quality: 'Unknown', + category: 'Unknown' + }; + + // Set download link + if (item.link) { + if (typeof item.link === 'string') { + processed.link = item.link; + } else if (item.link.$ && item.link.$.href) { + processed.link = item.link.$.href; + } else if (Array.isArray(item.link) && item.link.length > 0) { + const magnetLink = item.link.find(link => + (typeof link === 'string' && link.startsWith('magnet:')) || + (link.$ && link.$.href && link.$.href.startsWith('magnet:')) + ); + + if (magnetLink) { + processed.link = typeof magnetLink === 'string' ? magnetLink : magnetLink.$.href; + } else { + processed.link = typeof item.link[0] === 'string' ? item.link[0] : (item.link[0].$ ? item.link[0].$.href : ''); + } + } + } else if (item.enclosure && item.enclosure.url) { + processed.link = item.enclosure.url; + } + + // Skip item if no link found + if (!processed.link) { + return null; + } + + // Try to extract size information + if (item.size) { + processed.size = parseInt(item.size, 10) || 0; + } else if (item.enclosure && item.enclosure.length) { + processed.size = parseInt(item.enclosure.length, 10) || 0; + } else if (item['torrent:contentlength']) { + processed.size = parseInt(item['torrent:contentlength'], 10) || 0; + } + + // Convert size to MB if it's in bytes + if (processed.size > 1000000) { + processed.size = Math.round(processed.size / 1000000); + } + + // Try to extract seeders/leechers + if (item.seeders || item['torrent:seeds']) { + processed.seeders = parseInt(item.seeders || item['torrent:seeds'], 10) || 0; + } + + if (item.leechers || item['torrent:peers']) { + processed.leechers = parseInt(item.leechers || item['torrent:peers'], 10) || 0; + } + + // Try to determine category + if (item.category) { + if (Array.isArray(item.category)) { + processed.category = item.category[0] || 'Unknown'; + } else { + processed.category = item.category; + } + } else if (feedConfig.defaultCategory) { + processed.category = feedConfig.defaultCategory; + } + + // Detect quality from title + processed.quality = this.detectQuality(processed.title); + + return processed; + } + + // Detect quality from title + detectQuality(title) { + const lowerTitle = title.toLowerCase(); + + // Quality patterns + const qualityPatterns = [ + { regex: /\b(2160p|uhd|4k)\b/i, quality: '2160p/4K' }, + { regex: /\b(1080p|fullhd|fhd)\b/i, quality: '1080p' }, + { regex: /\b(720p|hd)\b/i, quality: '720p' }, + { regex: /\b(bluray|bdremux|bdrip)\b/i, quality: 'BluRay' }, + { regex: /\b(webdl|web-dl|webrip)\b/i, quality: 'WebDL' }, + { regex: /\b(dvdrip|dvd-rip)\b/i, quality: 'DVDRip' }, + { regex: /\b(hdtv)\b/i, quality: 'HDTV' }, + { regex: /\b(hdtc|hd-tc)\b/i, quality: 'HDTC' }, + { regex: /\b(hdts|hd-ts|hdcam)\b/i, quality: 'HDTS' }, + { regex: /\b(cam|camrip)\b/i, quality: 'CAM' } + ]; + + for (const pattern of qualityPatterns) { + if (pattern.regex.test(lowerTitle)) { + return pattern.quality; + } + } + + return 'Unknown'; + } + + // Generate a unique ID for an item + generateItemId(item) { + // Use hash of title + link as ID + const hash = require('crypto').createHash('md5'); + hash.update(item.title + item.link); + return hash.digest('hex'); + } + + // Check if an item matches a filter + matchesFilter(item, filters) { + if (!filters) return true; + + // Name contains filter + if (filters.nameContains && !item.title.toLowerCase().includes(filters.nameContains.toLowerCase())) { + return false; + } + + // Size filters + if (filters.minSize && item.size < filters.minSize) { + return false; + } + + if (filters.maxSize && filters.maxSize > 0 && item.size > filters.maxSize) { + return false; + } + + // Category filter + if (filters.includeCategory && !item.category.toLowerCase().includes(filters.includeCategory.toLowerCase())) { + return false; + } + + // Exclude terms + if (filters.excludeTerms) { + const terms = filters.excludeTerms.toLowerCase().split(',').map(t => t.trim()); + if (terms.some(term => item.title.toLowerCase().includes(term))) { + return false; + } + } + + // Unwanted formats + if (filters.unwantedFormats) { + const formats = filters.unwantedFormats.toLowerCase().split(',').map(f => f.trim()); + if (formats.some(format => item.title.toLowerCase().includes(format) || + (item.quality && item.quality.toLowerCase() === format.toLowerCase()))) { + return false; + } + } + + // Minimum quality + if (filters.minimumQuality) { + const qualityRanks = { + 'unknown': 0, + 'cam': 0, + 'ts': 0, + 'hdts': 0, + 'tc': 0, + 'hdtc': 0, + 'dvdscr': 1, + 'webrip': 1, + 'webdl': 1, + 'dvdrip': 2, + 'hdtv': 2, + '720p': 3, + 'hd': 3, + '1080p': 4, + 'fullhd': 4, + 'bluray': 4, + '2160p': 5, + '4k': 5, + 'uhd': 5 + }; + + const minQualityRank = qualityRanks[filters.minimumQuality.toLowerCase()] || 0; + const itemQualityRank = this.getQualityRank(item.quality); + + if (itemQualityRank < minQualityRank) { + return false; + } + } + + // Minimum seeders + if (filters.minimumSeeders && item.seeders < filters.minimumSeeders) { + return false; + } + + // Passed all filters + return true; + } + + // Calculate quality rank for an item + getQualityRank(quality) { + if (!quality) return 0; + + const qualityStr = quality.toLowerCase(); + + if (qualityStr.includes('2160p') || qualityStr.includes('4k') || qualityStr.includes('uhd')) { + return 5; + } + + if (qualityStr.includes('1080p') || qualityStr.includes('fullhd') || + (qualityStr.includes('bluray') && !qualityStr.includes('720p'))) { + return 4; + } + + if (qualityStr.includes('720p') || qualityStr.includes('hd')) { + return 3; + } + + if (qualityStr.includes('dvdrip') || qualityStr.includes('sdbd')) { + return 2; + } + + if (qualityStr.includes('webrip') || qualityStr.includes('webdl') || qualityStr.includes('web-dl')) { + return 1; + } + + // Low quality or unknown + return 0; + } + + // Download an item (add to Transmission) + async downloadItem(item, transmissionClient) { + if (!transmissionClient) { + console.error('No Transmission client provided for download'); + return { success: false, message: 'No Transmission client provided' }; + } + + try { + // Add the torrent to Transmission + const result = await new Promise((resolve, reject) => { + transmissionClient.addUrl(item.link, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + + // Mark the item as downloaded + const existingItem = this.feedItems.find(i => i.id === item.id); + if (existingItem) { + existingItem.downloaded = true; + existingItem.downloadDate = new Date().toISOString(); + existingItem.transmissionId = result.id || result.hashString || null; + } + + // Save updated items + await this.saveItems(); + + return { success: true, result }; + } catch (error) { + console.error(`Error downloading item: ${error.message}`); + return { success: false, message: error.message }; + } + } + + // Save items to persistent storage + async saveItems() { + try { + // In Node.js environment + const dbPath = path.join(__dirname, 'rss-items.json'); + await fs.writeFile(dbPath, JSON.stringify(this.feedItems, null, 2), 'utf8'); + return true; + } catch (error) { + console.error('Error saving RSS items:', error); + return false; + } + } + + // Load items from persistent storage + async loadItems() { + try { + // In Node.js environment + const dbPath = path.join(__dirname, 'rss-items.json'); + + try { + const data = await fs.readFile(dbPath, 'utf8'); + this.feedItems = JSON.parse(data); + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + // File doesn't exist, use empty array + this.feedItems = []; + } + + return true; + } catch (error) { + console.error('Error loading RSS items:', error); + return false; + } + } + + // Get all feed items + getAllItems() { + return [...this.feedItems]; + } + + // Get undownloaded feed items + getUndownloadedItems() { + return this.feedItems.filter(item => !item.downloaded); + } + + // Filter items based on criteria + filterItems(filters) { + return this.feedItems.filter(item => this.matchesFilter(item, filters)); + } + + // Add a new feed + addFeed(feed) { + // Generate an ID if not provided + if (!feed.id) { + feed.id = `feed-${Date.now()}`; + } + + // Add the feed to config + this.config.feeds.push(feed); + + // Update the feed immediately + this.updateFeed(feed); + + return feed; + } + + // Remove a feed + removeFeed(feedId) { + const index = this.config.feeds.findIndex(f => f.id === feedId); + if (index !== -1) { + this.config.feeds.splice(index, 1); + return true; + } + return false; + } + + // Update feed configuration + updateFeedConfig(feedId, updates) { + const feed = this.config.feeds.find(f => f.id === feedId); + if (feed) { + Object.assign(feed, updates); + return true; + } + return false; + } + + // Get all feeds + getAllFeeds() { + return [...this.config.feeds]; + } + + // Save configuration + async saveConfig() { + try { + // In Node.js environment + const configPath = path.join(__dirname, 'rss-config.json'); + await fs.writeFile(configPath, JSON.stringify(this.config, null, 2), 'utf8'); + return true; + } catch (error) { + console.error('Error saving RSS config:', error); + return false; + } + } + + // Load configuration + async loadConfig() { + try { + // In Node.js environment + const configPath = path.join(__dirname, 'rss-config.json'); + + try { + const data = await fs.readFile(configPath, 'utf8'); + const loadedConfig = JSON.parse(data); + this.config = { ...this.config, ...loadedConfig }; + } catch (err) { + if (err.code !== 'ENOENT') { + throw err; + } + // Config file doesn't exist, use defaults + } + + return true; + } catch (error) { + console.error('Error loading RSS config:', error); + return false; + } + } +} + +// Export for Node.js or browser +if (typeof module !== 'undefined' && module.exports) { + module.exports = RssFeedManager; +} else if (typeof window !== 'undefined') { + window.RssFeedManager = RssFeedManager; +}