diff --git a/README.md b/README.md index e69de29..27ba6b5 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,283 @@ +# Transmission RSS Manager + +A comprehensive web-based tool to automate and manage your Transmission torrent downloads with RSS feed integration and intelligent media organization. + +## Features + +- 🔄 **RSS Feed Integration**: Automatically download torrents from RSS feeds with customizable filters +- 📊 **Torrent Management**: Monitor and control your Transmission torrents from a clean web interface +- 📚 **Intelligent Media Organization**: Automatically categorize and organize downloads by media type +- 📖 **Book & Magazine Sorting**: Specialized processing for e-books and magazines with metadata extraction +- 📂 **Post-Processing**: Extract archives, rename files, and move content to appropriate directories +- 🔄 **Remote Support**: Connect to remote Transmission instances with local path mapping +- 📱 **Mobile-Friendly UI**: Responsive design works on desktop and mobile devices + +## Installation + +### Prerequisites + +- Ubuntu/Debian-based system (may work on other Linux distributions) +- Node.js 14+ and npm +- Transmission daemon installed and running +- Nginx (for reverse proxy) + +### Automatic Installation + +The easiest way to install Transmission RSS Manager is with the installation script: + +```bash +# Download the installation script +wget https://raw.githubusercontent.com/username/transmission-rss-manager/main/install.sh + +# Make it executable +chmod +x install.sh + +# Run it with sudo +sudo ./install.sh +``` + +The script will guide you through the configuration process and set up everything you need. + +### Manual Installation + +If you prefer to install manually: + +1. Clone the repository: + ```bash + git clone https://github.com/username/transmission-rss-manager.git + cd transmission-rss-manager + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Configure settings: + ```bash + cp config.example.json config.json + nano config.json + ``` + +4. Start the server: + ```bash + node server.js + ``` + +## Configuration + +### Main Configuration Options + +The system can be configured through the web interface or by editing the `config.json` file: + +```json +{ + "transmissionConfig": { + "host": "localhost", + "port": 9091, + "username": "transmission", + "password": "password", + "path": "/transmission/rpc" + }, + "destinationPaths": { + "movies": "/mnt/media/movies", + "tvShows": "/mnt/media/tvshows", + "music": "/mnt/media/music", + "books": "/mnt/media/books", + "magazines": "/mnt/media/magazines", + "software": "/mnt/media/software" + }, + "processingOptions": { + "enableBookSorting": true, + "extractArchives": true, + "renameFiles": true, + "ignoreSample": true + } +} +``` + +### Remote Transmission Setup + +For remote Transmission instances, configure the directory mapping: + +```json +"remoteConfig": { + "isRemote": true, + "directoryMapping": { + "/var/lib/transmission-daemon/downloads": "/mnt/transmission-downloads" + } +} +``` + +This maps paths between your remote Transmission server and the local directories. + +## Usage + +### Web Interface + +The web interface provides access to all functionality and is available at: +``` +http://your-server-ip +``` + +### RSS Feed Management + +1. Go to the "RSS Feeds" tab in the web interface +2. Click "Add Feed" and enter the RSS feed URL +3. Configure optional filters for automatic downloads +4. The system will periodically check feeds and download matching items + +### Managing Torrents + +From the "Torrents" tab, you can: +- Add new torrents via URL or magnet link +- Start, stop, or delete existing torrents +- Monitor download progress and stats + +### Media Organization + +The post-processor automatically: +1. Waits for torrents to complete and meet seeding requirements +2. Identifies the media type based on content analysis +3. Extracts archives if needed +4. Moves files to the appropriate category directory +5. Renames files according to media type conventions +6. Updates the media library for browsing + +### Book & Magazine Sorting + +When enabled, the system can: +- Differentiate between books and magazines +- Extract author information from book filenames +- Organize magazines by title and issue number +- Create appropriate folder structures + +## Detailed Features + +### Automatic Media Detection + +The system uses sophisticated detection to categorize downloads: + +- **Movies**: Recognizes common patterns such as resolution (1080p, 720p) and release year +- **TV Shows**: Identifies season/episode patterns (S01E01) and TV-specific naming +- **Music**: Detects audio formats like MP3, FLAC, album folders +- **Books**: Identifies e-book formats (EPUB, MOBI, PDF) and author-title patterns +- **Magazines**: Recognizes magazine naming patterns, issues, volumes, and publication dates +- **Software**: Detects software installers, ISOs, and other program files + +### RSS Feed Filtering + +Powerful filtering options for RSS feeds: + +- **Title matching**: Regular expression support for title patterns +- **Category filtering**: Filter by feed categories +- **Size limits**: Set minimum and maximum size requirements +- **Custom rules**: Combine multiple criteria for precise matching + +### Remote Transmission Support + +Full support for remote Transmission instances: + +- **Secure authentication**: Username/password protection +- **Path mapping**: Configure how remote paths map to local directories +- **Full API support**: Complete control via the web interface + +## Updating + +To update to the latest version: + +```bash +wget https://raw.githubusercontent.com/username/transmission-rss-manager/main/update.sh +chmod +x update.sh +sudo ./update.sh +``` + +## File Structure + +``` +transmission-rss-manager/ +├── server.js # Main application server +├── postProcessor.js # Media processing module +├── rssFeedManager.js # RSS feed management module +├── install.sh # Installation script +├── update.sh # Update script +├── config.json # Configuration file +├── public/ # Web interface files +│ ├── index.html # Main web interface +│ ├── js/ # JavaScript files +│ │ └── enhanced-ui.js # Enhanced UI functionality +│ └── css/ # CSS stylesheets +└── README.md # This file +``` + +## Modules + +### Post-Processor + +The Post-Processor module handles: +- Monitoring completed torrents +- Categorizing content by type +- Extracting archives +- Organizing files into the correct directories +- Renaming files according to conventions + +### RSS Feed Manager + +The RSS Feed Manager module provides: +- Regular checking of configured RSS feeds +- Filtering of feed items based on rules +- Automated downloading of matching content +- History tracking of downloaded items + +## Advanced Configuration + +### Seeding Requirements + +Set minimum seeding requirements before processing: + +```json +"seedingRequirements": { + "minRatio": 1.0, + "minTimeMinutes": 60, + "checkIntervalSeconds": 300 +} +``` + +### Processing Options + +Customize how files are processed: + +```json +"processingOptions": { + "enableBookSorting": true, + "extractArchives": true, + "deleteArchives": true, + "createCategoryFolders": true, + "ignoreSample": true, + "ignoreExtras": true, + "renameFiles": true, + "autoReplaceUpgrades": true, + "removeDuplicates": true, + "keepOnlyBestVersion": true +} +``` + +## Contributing + +Contributions are welcome! Here's how you can help: + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License. + +## Acknowledgments + +- [Transmission](https://transmissionbt.com/) for the excellent BitTorrent client +- [Node.js](https://nodejs.org/) and the npm community for the foundation libraries +- All contributors who have helped improve this project diff --git a/full-server-implementation.txt b/full-server-implementation.txt deleted file mode 100644 index 3eb8474..0000000 --- a/full-server-implementation.txt +++ /dev/null @@ -1,119 +0,0 @@ -// 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 index 62547a3..f1454a6 100755 --- a/install-script.sh +++ b/install-script.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Transmission RSS Manager Installation Script for Ubuntu -# This script installs all necessary dependencies and sets up the program +# Enhanced Transmission RSS Manager Installation Script for Ubuntu +# Includes support for book/magazine sorting and all UI enhancements # Text formatting BOLD='\033[1m' @@ -12,6 +12,7 @@ NC='\033[0m' # No Color # Print header echo -e "${BOLD}==================================================${NC}" echo -e "${BOLD} Transmission RSS Manager Installer ${NC}" +echo -e "${BOLD} Version 1.2.0 - Enhanced Edition ${NC}" echo -e "${BOLD}==================================================${NC}" echo @@ -140,6 +141,15 @@ echo -e "${BOLD}Media Destination Configuration:${NC}" read -p "Media destination base directory [/mnt/media]: " MEDIA_DIR MEDIA_DIR=${MEDIA_DIR:-"/mnt/media"} +# Ask about enabling book/magazine sorting +echo +echo -e "${BOLD}Content Type Configuration:${NC}" +read -p "Enable book and magazine sorting? (y/n) [y]: " input_book_sorting +ENABLE_BOOK_SORTING=true +if [[ $input_book_sorting =~ ^[Nn]$ ]]; then + ENABLE_BOOK_SORTING=false +fi + echo echo -e "${YELLOW}Installing dependencies...${NC}" @@ -165,21 +175,16 @@ apt-get install -y unrar unzip p7zip-full nginx 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 || : +mkdir -p $INSTALL_DIR/public/js +mkdir -p $INSTALL_DIR/public/css # Create package.json echo -e "${YELLOW}Creating package.json...${NC}" cat > $INSTALL_DIR/package.json << EOF { "name": "transmission-rss-manager", - "version": "1.0.0", - "description": "Transmission RSS Manager with post-processing capabilities", + "version": "1.2.0", + "description": "Enhanced Transmission RSS Manager with post-processing capabilities", "main": "server.js", "scripts": { "start": "node server.js" @@ -198,37 +203,43 @@ 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"); +cat > $INSTALL_DIR/server.js << 'EOF' +// server.js - Main application server file +const express = require('express'); +const bodyParser = require('body-parser'); +const path = require('path'); +const fs = require('fs').promises; +const cors = require('cors'); +const Transmission = require('transmission'); + +// Import custom modules +const PostProcessor = require('./postProcessor'); +const RssFeedManager = require('./rssFeedManager'); // Initialize Express app const app = express(); -const PORT = process.env.PORT || $PORT; +const PORT = process.env.PORT || 3000; // Load configuration let config = { transmissionConfig: { - host: "$TRANSMISSION_HOST", - port: $TRANSMISSION_PORT, - username: "$TRANSMISSION_USER", - password: "$TRANSMISSION_PASS", - path: "$TRANSMISSION_RPC_PATH" + host: 'localhost', + port: 9091, + username: '', + password: '', + path: '/transmission/rpc' }, remoteConfig: { - isRemote: $TRANSMISSION_REMOTE, - directoryMapping: $TRANSMISSION_DIR_MAPPING + isRemote: false, + directoryMapping: {} }, destinationPaths: { - movies: "$MEDIA_DIR/movies", - tvShows: "$MEDIA_DIR/tvshows", - music: "$MEDIA_DIR/music", - books: "$MEDIA_DIR/books", - software: "$MEDIA_DIR/software" + movies: '/mnt/media/movies', + tvShows: '/mnt/media/tvshows', + music: '/mnt/media/music', + books: '/mnt/media/books', + magazines: '/mnt/media/magazines', + software: '/mnt/media/software' }, seedingRequirements: { minRatio: 1.0, @@ -236,6 +247,7 @@ let config = { checkIntervalSeconds: 300 }, processingOptions: { + enableBookSorting: true, extractArchives: true, deleteArchives: true, createCategoryFolders: true, @@ -246,9 +258,16 @@ let config = { removeDuplicates: true, keepOnlyBestVersion: true }, - downloadDir: "$TRANSMISSION_DOWNLOAD_DIR" + rssFeeds: [], + rssUpdateIntervalMinutes: 60, + autoProcessing: false }; +// Service instances +let transmissionClient = null; +let postProcessor = null; +let rssFeedManager = null; + // Save config function async function saveConfig() { try { @@ -258,28 +277,30 @@ async function saveConfig() { 'utf8' ); console.log('Configuration saved'); + return true; } catch (err) { console.error('Error saving config:', err.message); + return false; } } -// Initialize config on startup +// 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 -let transmissionClient = null; - function initTransmission() { transmissionClient = new Transmission({ host: config.transmissionConfig.host, @@ -288,26 +309,188 @@ function initTransmission() { password: config.transmissionConfig.password, url: config.transmissionConfig.path }); + + console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`); + return transmissionClient; +} + +// Initialize post processor +function initPostProcessor() { + if (postProcessor) { + postProcessor.stop(); + } + + postProcessor = new PostProcessor({ + transmissionConfig: config.transmissionConfig, + remoteConfig: config.remoteConfig, + destinationPaths: config.destinationPaths, + seedingRequirements: config.seedingRequirements, + processingOptions: config.processingOptions, + downloadDir: config.downloadDir + }); + + if (config.autoProcessing) { + postProcessor.start(); + console.log('Post-processor started automatically'); + } else { + console.log('Post-processor initialized (not auto-started)'); + } + + return postProcessor; +} + +// Initialize RSS feed manager +function initRssFeedManager() { + if (rssFeedManager) { + rssFeedManager.stop(); + } + + rssFeedManager = new RssFeedManager({ + feeds: config.rssFeeds, + updateIntervalMinutes: config.rssUpdateIntervalMinutes + }); + + rssFeedManager.loadItems() + .then(() => { + if (config.rssFeeds && config.rssFeeds.length > 0) { + rssFeedManager.start(); + console.log('RSS feed manager started'); + } else { + console.log('RSS feed manager initialized (no feeds configured)'); + } + }) + .catch(err => { + console.error('Error initializing RSS feed manager:', err); + }); + + return rssFeedManager; } // Enable CORS app.use(cors()); // Parse JSON bodies -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: true })); +app.use(bodyParser.json({ limit: '50mb' })); +app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); -// 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 - })); +// API routes +//============================== + +// Server status API +app.get('/api/status', (req, res) => { + res.json({ + status: 'running', + version: '1.2.0', + transmissionConnected: !!transmissionClient, + postProcessorActive: postProcessor && postProcessor.processingIntervalId !== null, + rssFeedManagerActive: rssFeedManager && rssFeedManager.updateIntervalId !== null, + config: { + autoProcessing: config.autoProcessing, + rssEnabled: config.rssFeeds && config.rssFeeds.length > 0 + } + }); }); -app.post("/api/transmission/test", (req, res) => { +// Get configuration +app.get('/api/config', (req, res) => { + // Don't send password in response + const safeConfig = { ...config }; + if (safeConfig.transmissionConfig) { + safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' }; + } + res.json(safeConfig); +}); + +// Update configuration +app.post('/api/config', async (req, res) => { + try { + // Create a deep copy of the old config + const oldConfig = JSON.parse(JSON.stringify(config)); + + // Update config object, preserving password if not provided + config = { + ...config, + ...req.body, + transmissionConfig: { + ...config.transmissionConfig, + ...req.body.transmissionConfig, + password: req.body.transmissionConfig?.password || config.transmissionConfig.password + }, + remoteConfig: { + ...config.remoteConfig, + ...req.body.remoteConfig + }, + destinationPaths: { + ...config.destinationPaths, + ...req.body.destinationPaths + }, + seedingRequirements: { + ...config.seedingRequirements, + ...req.body.seedingRequirements + }, + processingOptions: { + ...config.processingOptions, + ...req.body.processingOptions + } + }; + + // Save the updated config + await saveConfig(); + + // Check if key services need reinitialization + let changes = { + transmissionChanged: JSON.stringify(oldConfig.transmissionConfig) !== JSON.stringify(config.transmissionConfig), + postProcessorChanged: + JSON.stringify(oldConfig.remoteConfig) !== JSON.stringify(config.remoteConfig) || + JSON.stringify(oldConfig.destinationPaths) !== JSON.stringify(config.destinationPaths) || + JSON.stringify(oldConfig.seedingRequirements) !== JSON.stringify(config.seedingRequirements) || + JSON.stringify(oldConfig.processingOptions) !== JSON.stringify(config.processingOptions), + rssFeedsChanged: + JSON.stringify(oldConfig.rssFeeds) !== JSON.stringify(config.rssFeeds) || + oldConfig.rssUpdateIntervalMinutes !== config.rssUpdateIntervalMinutes, + autoProcessingChanged: oldConfig.autoProcessing !== config.autoProcessing + }; + + // Reinitialize services as needed + if (changes.transmissionChanged) { + initTransmission(); + } + + if (changes.postProcessorChanged || changes.transmissionChanged) { + initPostProcessor(); + } + + if (changes.rssFeedsChanged) { + initRssFeedManager(); + } + + if (changes.autoProcessingChanged) { + if (config.autoProcessing && postProcessor) { + postProcessor.start(); + } else if (!config.autoProcessing && postProcessor) { + postProcessor.stop(); + } + } + + res.json({ + success: true, + message: 'Configuration updated successfully', + changes + }); + } catch (error) { + console.error('Error updating configuration:', error); + res.status(500).json({ + success: false, + message: `Failed to update configuration: ${error.message}` + }); + } +}); + +// Transmission API routes +//============================== + +// Test Transmission connection +app.post('/api/transmission/test', (req, res) => { const { host, port, username, password } = req.body; // Create a test client with provided credentials @@ -324,25 +507,11 @@ app.post("/api/transmission/test", (req, res) => { if (err) { return res.json({ success: false, - message: \`Connection failed: \${err.message}\` + 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(); - + // Connection successful res.json({ success: true, message: "Connected to Transmission successfully!", @@ -354,56 +523,535 @@ app.post("/api/transmission/test", (req, res) => { }); }); -// 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: '••••••••' }; +// Get torrents from Transmission +app.get('/api/transmission/torrents', (req, res) => { + if (!transmissionClient) { + return res.status(400).json({ + success: false, + message: 'Transmission client not initialized' + }); } - res.json(safeConfig); + + transmissionClient.get((err, result) => { + if (err) { + return res.status(500).json({ + success: false, + message: `Error getting torrents: ${err.message}` + }); + } + + res.json({ + success: true, + data: result.torrents || [] + }); + }); }); -// 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 }); +// Add torrent to Transmission +app.post('/api/transmission/add', (req, res) => { + if (!transmissionClient) { + return res.status(400).json({ + success: false, + message: 'Transmission client not initialized' + }); } + + const { url } = req.body; + if (!url) { + return res.status(400).json({ + success: false, + message: 'URL is required' + }); + } + + transmissionClient.addUrl(url, (err, result) => { + if (err) { + return res.status(500).json({ + success: false, + message: `Error adding torrent: ${err.message}` + }); + } + + res.json({ + success: true, + data: result + }); + }); +}); + +// Remove torrent from Transmission +app.post('/api/transmission/remove', (req, res) => { + if (!transmissionClient) { + return res.status(400).json({ + success: false, + message: 'Transmission client not initialized' + }); + } + + const { ids, deleteLocalData } = req.body; + if (!ids || !Array.isArray(ids) && typeof ids !== 'number') { + return res.status(400).json({ + success: false, + message: 'Valid torrent ID(s) required' + }); + } + + transmissionClient.remove(ids, !!deleteLocalData, (err, result) => { + if (err) { + return res.status(500).json({ + success: false, + message: `Error removing torrent: ${err.message}` + }); + } + + res.json({ + success: true, + data: result + }); + }); +}); + +// Start torrents +app.post('/api/transmission/start', (req, res) => { + if (!transmissionClient) { + return res.status(400).json({ + success: false, + message: 'Transmission client not initialized' + }); + } + + const { ids } = req.body; + if (!ids) { + return res.status(400).json({ + success: false, + message: 'Torrent ID(s) required' + }); + } + + transmissionClient.start(ids, (err, result) => { + if (err) { + return res.status(500).json({ + success: false, + message: `Error starting torrent: ${err.message}` + }); + } + + res.json({ + success: true, + data: result + }); + }); +}); + +// Stop torrents +app.post('/api/transmission/stop', (req, res) => { + if (!transmissionClient) { + return res.status(400).json({ + success: false, + message: 'Transmission client not initialized' + }); + } + + const { ids } = req.body; + if (!ids) { + return res.status(400).json({ + success: false, + message: 'Torrent ID(s) required' + }); + } + + transmissionClient.stop(ids, (err, result) => { + if (err) { + return res.status(500).json({ + success: false, + message: `Error stopping torrent: ${err.message}` + }); + } + + res.json({ + success: true, + data: result + }); + }); +}); + +// RSS Feed Manager API routes +//============================== + +// Get all feeds +app.get('/api/rss/feeds', (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + res.json({ + success: true, + data: rssFeedManager.getAllFeeds() + }); +}); + +// Add a new feed +app.post('/api/rss/feeds', async (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + const feed = req.body; + if (!feed || !feed.url) { + return res.status(400).json({ + success: false, + message: 'Feed URL is required' + }); + } + + try { + const newFeed = rssFeedManager.addFeed(feed); + + // Update the config with the new feed + config.rssFeeds = rssFeedManager.getAllFeeds(); + await saveConfig(); + + res.json({ + success: true, + data: newFeed + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error adding feed: ${error.message}` + }); + } +}); + +// Update a feed +app.put('/api/rss/feeds/:id', async (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + const { id } = req.params; + const updates = req.body; + + if (!id || !updates) { + return res.status(400).json({ + success: false, + message: 'Feed ID and updates are required' + }); + } + + try { + const success = rssFeedManager.updateFeedConfig(id, updates); + + if (!success) { + return res.status(404).json({ + success: false, + message: `Feed with ID ${id} not found` + }); + } + + // Update the config with the updated feeds + config.rssFeeds = rssFeedManager.getAllFeeds(); + await saveConfig(); + + res.json({ + success: true, + message: 'Feed updated successfully' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error updating feed: ${error.message}` + }); + } +}); + +// Delete a feed +app.delete('/api/rss/feeds/:id', async (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + const { id } = req.params; + + if (!id) { + return res.status(400).json({ + success: false, + message: 'Feed ID is required' + }); + } + + try { + const success = rssFeedManager.removeFeed(id); + + if (!success) { + return res.status(404).json({ + success: false, + message: `Feed with ID ${id} not found` + }); + } + + // Update the config with the remaining feeds + config.rssFeeds = rssFeedManager.getAllFeeds(); + await saveConfig(); + + res.json({ + success: true, + message: 'Feed removed successfully' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error removing feed: ${error.message}` + }); + } +}); + +// Get feed items +app.get('/api/rss/items', (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + const { filter } = req.query; + + let items; + if (filter === 'undownloaded') { + items = rssFeedManager.getUndownloadedItems(); + } else { + items = rssFeedManager.getAllItems(); + } + + res.json({ + success: true, + data: items + }); +}); + +// Filter feed items +app.post('/api/rss/filter', (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + const filters = req.body; + + const filteredItems = rssFeedManager.filterItems(filters); + + res.json({ + success: true, + data: filteredItems + }); +}); + +// Fetch and update RSS feed +app.post('/api/rss/update', async (req, res) => { + if (!rssFeedManager) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager not initialized' + }); + } + + try { + const result = await rssFeedManager.updateAllFeeds(); + + res.json({ + success: true, + data: result + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error updating feeds: ${error.message}` + }); + } +}); + +// Download RSS item +app.post('/api/rss/download', async (req, res) => { + if (!rssFeedManager || !transmissionClient) { + return res.status(400).json({ + success: false, + message: 'RSS feed manager or Transmission client not initialized' + }); + } + + const { itemId } = req.body; + + if (!itemId) { + return res.status(400).json({ + success: false, + message: 'Item ID is required' + }); + } + + try { + const items = rssFeedManager.getAllItems(); + const item = items.find(i => i.id === itemId); + + if (!item) { + return res.status(404).json({ + success: false, + message: `Item with ID ${itemId} not found` + }); + } + + const result = await rssFeedManager.downloadItem(item, transmissionClient); + + res.json({ + success: result.success, + message: result.success ? 'Item added to Transmission' : result.message, + data: result.result + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error downloading item: ${error.message}` + }); + } +}); + +// Post-Processor API routes +//============================== + +// Start post-processor +app.post('/api/post-processor/start', (req, res) => { + if (!postProcessor) { + return res.status(400).json({ + success: false, + message: 'Post-processor not initialized' + }); + } + + try { + postProcessor.start(); + + // Update config + config.autoProcessing = true; + saveConfig(); + + res.json({ + success: true, + message: 'Post-processor started' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error starting post-processor: ${error.message}` + }); + } +}); + +// Stop post-processor +app.post('/api/post-processor/stop', (req, res) => { + if (!postProcessor) { + return res.status(400).json({ + success: false, + message: 'Post-processor not initialized' + }); + } + + try { + postProcessor.stop(); + + // Update config + config.autoProcessing = false; + saveConfig(); + + res.json({ + success: true, + message: 'Post-processor stopped' + }); + } catch (error) { + res.status(500).json({ + success: false, + message: `Error stopping post-processor: ${error.message}` + }); + } +}); + +// Get media library +app.get('/api/media/library', (req, res) => { + if (!postProcessor) { + return res.status(400).json({ + success: false, + message: 'Post-processor not initialized' + }); + } + + const { query } = req.query; + + let library; + if (query) { + library = postProcessor.searchLibrary(query); + } else { + library = postProcessor.getLibrary(); + } + + res.json({ + success: true, + data: library + }); +}); + +// Get library statistics +app.get('/api/media/stats', (req, res) => { + if (!postProcessor) { + return res.status(400).json({ + success: false, + message: 'Post-processor not initialized' + }); + } + + const stats = postProcessor.getLibraryStats(); + + res.json({ + success: true, + data: stats + }); }); // Serve static files -app.use(express.static(path.join(__dirname, "public"))); +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")); +// Catch-all route for SPA +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -// Initialize the application +// Initialize application async function init() { + console.log('Initializing application...'); + + // Load configuration await loadConfig(); + + // Initialize services initTransmission(); + initPostProcessor(); + initRssFeedManager(); // Start the server app.listen(PORT, () => { - console.log(\`Transmission RSS Manager running on port \${PORT}\`); + console.log(`Transmission RSS Manager running on port ${PORT}`); }); } @@ -411,460 +1059,1840 @@ async function init() { init().catch(err => { console.error('Failed to initialize application:', err); }); + +// Handle graceful shutdown +process.on('SIGINT', async () => { + console.log('Shutting down...'); + + if (postProcessor) { + postProcessor.stop(); + } + + if (rssFeedManager) { + rssFeedManager.stop(); + await rssFeedManager.saveItems(); + await rssFeedManager.saveConfig(); + } + + await saveConfig(); + + console.log('Shutdown complete'); + process.exit(0); +}); EOF -# Create 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...

-
-
-
-
+# Create enhanced-ui.js +echo -e "${YELLOW}Creating enhanced-ui.js...${NC}" +cat > $INSTALL_DIR/public/js/enhanced-ui.js << 'EOF' +// RSS Feed Management Functions +function addFeed() { + // Create a modal dialog for adding a feed + const modal = document.createElement('div'); + modal.style.position = 'fixed'; + modal.style.top = '0'; + modal.style.left = '0'; + modal.style.width = '100%'; + modal.style.height = '100%'; + modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'; + modal.style.display = 'flex'; + modal.style.justifyContent = 'center'; + modal.style.alignItems = 'center'; + modal.style.zIndex = '1000'; - - - -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; +function updateFeed(id, feed) { + statusMessage.textContent = "Updating RSS feed..."; + statusMessage.className = "status-message"; - # Change this to your domain if you have one - server_name _; + fetch(`/api/rss/feeds/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(feed) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + statusMessage.textContent = "RSS feed updated successfully!"; + statusMessage.classList.add("status-success"); + loadRssData(); + } else { + statusMessage.textContent = "Error updating RSS feed: " + data.message; + statusMessage.classList.add("status-error"); + } + }) + .catch(error => { + statusMessage.textContent = "Error: " + error.message; + statusMessage.classList.add("status-error"); + console.error("Error updating RSS feed:", error); + }); +} + +function deleteFeed(id) { + statusMessage.textContent = "Deleting RSS feed..."; + statusMessage.className = "status-message"; - # 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; + fetch(`/api/rss/feeds/${id}`, { + method: "DELETE" + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + statusMessage.textContent = "RSS feed deleted successfully!"; + statusMessage.classList.add("status-success"); + loadRssData(); + } else { + statusMessage.textContent = "Error deleting RSS feed: " + data.message; + statusMessage.classList.add("status-error"); + } + }) + .catch(error => { + statusMessage.textContent = "Error: " + error.message; + statusMessage.classList.add("status-error"); + console.error("Error deleting RSS feed:", error); + }); +} + +function downloadRssItem(id) { + statusMessage.textContent = "Adding item to download queue..."; + statusMessage.className = "status-message"; + + fetch("/api/rss/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemId: id }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + statusMessage.textContent = "Item added to download queue!"; + statusMessage.classList.add("status-success"); + loadRssData(); + } else { + statusMessage.textContent = "Error adding item to download queue: " + data.message; + statusMessage.classList.add("status-error"); + } + }) + .catch(error => { + statusMessage.textContent = "Error: " + error.message; + statusMessage.classList.add("status-error"); + console.error("Error downloading item:", error); + }); +} + +// Enhanced settings panel with book/magazine support +function enhanceSettingsPage() { + // Select the existing post-processing settings card + const processingSettingsCard = document.querySelector('#settings-tab .card:nth-child(2)'); + + if (processingSettingsCard) { + processingSettingsCard.innerHTML = ` +

Post-Processing Settings

+ +

Seeding Requirements

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

Content Categories

+
+ +

+ When enabled, books and magazines will be processed separately with specialized organization. +

+
+ +

Media Paths

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

Archive Processing

+
+ +
+
+ +
+ +

File Organization

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

Quality Management

+
+ +
+
+ +
+
+ +
+ + + +
+ + +
+ `; + + console.log("Post-processing settings panel has been enhanced"); + } else { + console.error("Could not find post-processing settings card"); } - # 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; + // Add a new section for the Media Library tab to show magazines separately + const mediaTab = document.getElementById('media-tab'); + if (mediaTab) { + // Update the filter options in the Media Library tab + const filterDiv = mediaTab.querySelector('.mb-2'); + if (filterDiv) { + filterDiv.innerHTML = ` + + + + + + + + `; + } } } + +// Enhanced version of loadSettingsData function +function enhancedLoadSettingsData() { + fetch("/api/config") + .then(response => response.json()) + .then(config => { + // Transmission settings + document.getElementById("transmission-host").value = config.transmissionConfig.host || ''; + document.getElementById("transmission-port").value = config.transmissionConfig.port || ''; + document.getElementById("transmission-user").value = config.transmissionConfig.username || ''; + // Password is masked in the API response, so we leave it blank + + // Post-processing settings + document.getElementById("seeding-ratio").value = config.seedingRequirements.minRatio || ''; + document.getElementById("seeding-time").value = config.seedingRequirements.minTimeMinutes || ''; + document.getElementById("check-interval").value = config.seedingRequirements.checkIntervalSeconds || ''; + + // Media paths + document.getElementById("movies-path").value = config.destinationPaths.movies || ''; + document.getElementById("tvshows-path").value = config.destinationPaths.tvShows || ''; + document.getElementById("music-path").value = config.destinationPaths.music || ''; + document.getElementById("books-path").value = config.destinationPaths.books || ''; + document.getElementById("magazines-path").value = config.destinationPaths.magazines || ''; + document.getElementById("software-path").value = config.destinationPaths.software || ''; + + // Book and magazine sorting + document.getElementById("enable-book-sorting").checked = config.processingOptions.enableBookSorting || false; + + // Archive options + document.getElementById("extract-archives").checked = config.processingOptions.extractArchives; + document.getElementById("delete-archives").checked = config.processingOptions.deleteArchives; + + // File organization options + document.getElementById("create-category-folders").checked = config.processingOptions.createCategoryFolders; + document.getElementById("rename-files").checked = config.processingOptions.renameFiles; + document.getElementById("ignore-sample").checked = config.processingOptions.ignoreSample; + document.getElementById("ignore-extras").checked = config.processingOptions.ignoreExtras; + + // Quality management options + document.getElementById("auto-replace-upgrades").checked = config.processingOptions.autoReplaceUpgrades; + document.getElementById("remove-duplicates").checked = config.processingOptions.removeDuplicates; + document.getElementById("keep-best-version").checked = config.processingOptions.keepOnlyBestVersion; + + // RSS settings + document.getElementById("rss-interval").value = config.rssUpdateIntervalMinutes || ''; + + // Add event listeners for settings buttons + document.getElementById("test-connection").addEventListener("click", function() { + testTransmissionConnection(); + }); + + document.getElementById("save-transmission-settings").addEventListener("click", function() { + saveTransmissionSettings(); + }); + + document.getElementById("save-processing-settings").addEventListener("click", function() { + saveProcessingSettings(); + }); + + document.getElementById("save-rss-settings").addEventListener("click", function() { + saveRssSettings(); + }); + + document.getElementById("start-processor").addEventListener("click", function() { + startPostProcessor(); + }); + + document.getElementById("stop-processor").addEventListener("click", function() { + stopPostProcessor(); + }); + }) + .catch(error => { + console.error("Error loading configuration:", error); + }); +} + +// Enhanced saveProcessingSettings function that includes book/magazine settings +function saveProcessingSettings() { + const minRatio = parseFloat(document.getElementById("seeding-ratio").value); + const minTimeMinutes = parseInt(document.getElementById("seeding-time").value, 10); + const checkIntervalSeconds = parseInt(document.getElementById("check-interval").value, 10); + + // Media paths + const moviesPath = document.getElementById("movies-path").value; + const tvShowsPath = document.getElementById("tvshows-path").value; + const musicPath = document.getElementById("music-path").value; + const booksPath = document.getElementById("books-path").value; + const magazinesPath = document.getElementById("magazines-path").value; + const softwarePath = document.getElementById("software-path").value; + + // Book and magazine sorting + const enableBookSorting = document.getElementById("enable-book-sorting").checked; + + // Archive options + const extractArchives = document.getElementById("extract-archives").checked; + const deleteArchives = document.getElementById("delete-archives").checked; + + // File organization options + const createCategoryFolders = document.getElementById("create-category-folders").checked; + const renameFiles = document.getElementById("rename-files").checked; + const ignoreSample = document.getElementById("ignore-sample").checked; + const ignoreExtras = document.getElementById("ignore-extras").checked; + + // Quality management options + const autoReplaceUpgrades = document.getElementById("auto-replace-upgrades").checked; + const removeDuplicates = document.getElementById("remove-duplicates").checked; + const keepOnlyBestVersion = document.getElementById("keep-best-version").checked; + + statusMessage.textContent = "Saving processing settings..."; + statusMessage.className = "status-message"; + + fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + seedingRequirements: { + minRatio, + minTimeMinutes, + checkIntervalSeconds + }, + destinationPaths: { + movies: moviesPath, + tvShows: tvShowsPath, + music: musicPath, + books: booksPath, + magazines: magazinesPath, + software: softwarePath + }, + processingOptions: { + enableBookSorting, + extractArchives, + deleteArchives, + createCategoryFolders, + renameFiles, + ignoreSample, + ignoreExtras, + autoReplaceUpgrades, + removeDuplicates, + keepOnlyBestVersion + } + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + statusMessage.textContent = "Processing settings saved successfully!"; + statusMessage.classList.add("status-success"); + } else { + statusMessage.textContent = "Error saving settings: " + data.message; + statusMessage.classList.add("status-error"); + } + }) + .catch(error => { + statusMessage.textContent = "Error: " + error.message; + statusMessage.classList.add("status-error"); + console.error("Error saving settings:", error); + }); +} + +// Update getCategoryTitle function to include magazines +function getCategoryTitle(category) { + switch(category) { + case 'movies': return 'Movies'; + case 'tvShows': return 'TV Shows'; + case 'music': return 'Music'; + case 'books': return 'Books'; + case 'magazines': return 'Magazines'; + case 'software': return 'Software'; + default: return category.charAt(0).toUpperCase() + category.slice(1); + } +} + +// Make sure to add this initialization code +document.addEventListener("DOMContentLoaded", function() { + // Add a hook to enhance the settings page after the page loads + enhanceSettingsPage(); + + // Override the loadSettingsData function + window.loadSettingsData = function() { + enhancedLoadSettingsData(); + }; +}); EOF -# Enable Nginx site -ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/ +# Create the postProcessor.js file +echo -e "${YELLOW}Creating postProcessor.js...${NC}" +cat > $INSTALL_DIR/postProcessor.js << 'EOF' +const path = require('path'); +const fs = require('fs').promises; +const Transmission = require('transmission'); +const AdmZip = require('adm-zip'); +const { exec } = require('child_process'); +const util = require('util'); -# Test Nginx configuration -nginx -t +const execAsync = util.promisify(exec); -# Reload Nginx -systemctl reload nginx +class PostProcessor { + constructor(config) { + this.config = config; + this.processingIntervalId = null; + this.transmissionClient = null; + this.library = { + movies: [], + tvShows: [], + music: [], + books: [], + magazines: [], + software: [] + }; + + this.initTransmission(); + } + + initTransmission() { + this.transmissionClient = new Transmission({ + host: this.config.transmissionConfig.host, + port: this.config.transmissionConfig.port, + username: this.config.transmissionConfig.username, + password: this.config.transmissionConfig.password, + url: this.config.transmissionConfig.path + }); + } + + start() { + if (this.processingIntervalId) { + return; + } + + // Run the process immediately at startup + this.processCompletedTorrents(); + + // Then set up the interval + this.processingIntervalId = setInterval(() => { + this.processCompletedTorrents(); + }, this.config.seedingRequirements.checkIntervalSeconds * 1000); + + console.log(`Post-processor started, interval: ${this.config.seedingRequirements.checkIntervalSeconds} seconds`); + } + + stop() { + if (this.processingIntervalId) { + clearInterval(this.processingIntervalId); + this.processingIntervalId = null; + console.log('Post-processor stopped'); + } + } + + async processCompletedTorrents() { + try { + // Get all torrents from Transmission + const torrents = await this.getTransmissionTorrents(); + + // Filter torrents that meet seeding requirements + const completedTorrents = this.filterCompletedTorrents(torrents); + + if (completedTorrents.length === 0) { + return; + } + + console.log(`Processing ${completedTorrents.length} completed torrents`); + + // Process each completed torrent + for (const torrent of completedTorrents) { + await this.processTorrent(torrent); + } + + // Update the library + await this.updateLibrary(); + + } catch (error) { + console.error('Error processing completed torrents:', error); + } + } + + getTransmissionTorrents() { + return new Promise((resolve, reject) => { + this.transmissionClient.get((err, result) => { + if (err) { + reject(err); + } else { + resolve(result.torrents || []); + } + }); + }); + } + + filterCompletedTorrents(torrents) { + return torrents.filter(torrent => { + // Check if the torrent is 100% completed + if (torrent.percentDone < 1.0) { + return false; + } + + // Check if seeding requirements are met + const seedingRatioMet = torrent.uploadRatio >= this.config.seedingRequirements.minRatio; + const seedingTimeMet = torrent.secondsSeeding >= (this.config.seedingRequirements.minTimeMinutes * 60); + + return seedingRatioMet && seedingTimeMet; + }); + } + + async processTorrent(torrent) { + console.log(`Processing torrent: ${torrent.name}`); + + try { + // Determine the category of the torrent + const category = this.determineTorrentCategory(torrent); + + // Get the download directory + const downloadDir = torrent.downloadDir || this.config.downloadDir; + + // Map remote paths to local paths if necessary + const localPath = this.mapRemotePath(downloadDir, torrent.name); + + // Process the downloaded files + await this.processDownloadedFiles(localPath, category); + + // Update torrent status in Transmission + if (this.config.processingOptions.removeTorrentAfterProcessing) { + await this.removeTorrentFromTransmission(torrent.id); + } + + console.log(`Successfully processed torrent: ${torrent.name}`); + return true; + } catch (error) { + console.error(`Error processing torrent ${torrent.name}:`, error); + return false; + } + } + + determineTorrentCategory(torrent) { + // Default category + let category = 'other'; + + // Check name for category indicators + const name = torrent.name.toLowerCase(); + + // Check for video file extensions in the files + const hasVideoFiles = torrent.files && torrent.files.some(file => + ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.flv'].some(ext => + file.name.toLowerCase().endsWith(ext) + ) + ); + + // Movie patterns + if (hasVideoFiles && ( + name.includes('1080p') || + name.includes('720p') || + name.includes('2160p') || + name.includes('bluray') || + name.includes('bdrip') || + name.includes('dvdrip') || + name.includes('webrip') || + /\(\d{4}\)/.test(name) || // Year in parentheses + /\.\d{4}\./.test(name) // Year between dots + )) { + // Check if it's a TV show + if ( + /s\d{1,2}e\d{1,2}/i.test(name) || // S01E01 format + /\d{1,2}x\d{1,2}/i.test(name) || // 1x01 format + name.includes('season') || + name.includes('episode') || + name.includes('complete series') + ) { + category = 'tvShows'; + } else { + category = 'movies'; + } + } + // Music patterns + else if ( + name.includes('mp3') || + name.includes('flac') || + name.includes('alac') || + name.includes('wav') || + name.includes('album') || + name.includes('discography') + ) { + category = 'music'; + } + // Book patterns + else if ( + this.config.processingOptions.enableBookSorting && + ( + name.includes('epub') || + name.includes('mobi') || + name.includes('azw3') || + name.includes('pdf') && !name.includes('magazine') && !name.includes('issue') || + name.includes('book') || + name.includes('ebook') + ) + ) { + category = 'books'; + } + // Magazine patterns + else if ( + this.config.processingOptions.enableBookSorting && + ( + name.includes('magazine') || + name.includes('issue') && name.includes('pdf') || + /\b(vol|volume)\b.*\d+/.test(name) && name.includes('pdf') || + /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) && name.includes('pdf') + ) + ) { + category = 'magazines'; + } + // Generic PDF - can be book or magazine, check more closely + else if ( + this.config.processingOptions.enableBookSorting && + name.includes('pdf') + ) { + // Check if it looks like a magazine - has dates, issues, volumes + if ( + /\b(issue|vol|volume)\b.*\d+/.test(name) || + /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) || + name.includes('magazine') + ) { + category = 'magazines'; + } else { + // Default to books for other PDFs + category = 'books'; + } + } + // Software patterns + else if ( + name.includes('windows') || + name.includes('macos') || + name.includes('linux') || + name.includes('crack') || + name.includes('keygen') || + name.includes('iso') || + name.includes('software') || + name.includes('app') || + name.includes('application') + ) { + category = 'software'; + } + + return category; + } + + mapRemotePath(remotePath, torrentName) { + // If we're not working with a remote Transmission setup, return the path as-is + if (!this.config.remoteConfig.isRemote) { + return path.join(remotePath, torrentName); + } + + // If we are working with a remote setup, map the remote path to a local path + const mapping = this.config.remoteConfig.directoryMapping; + + // Find the appropriate mapping + for (const [remote, local] of Object.entries(mapping)) { + if (remotePath.startsWith(remote)) { + // Replace the remote path with the local path + return path.join(local, torrentName); + } + } + + // If no mapping found, use the remote path directly + return path.join(remotePath, torrentName); + } + + async processDownloadedFiles(sourcePath, category) { + // Get the destination directory for this category + const destinationDir = this.config.destinationPaths[category] || this.config.destinationPaths.other; + + try { + // Check if the source path exists + await fs.access(sourcePath); + + // Check if the source is a directory or a file + const stats = await fs.stat(sourcePath); + + if (stats.isDirectory()) { + // Process a directory + await this.processDirectory(sourcePath, destinationDir, category); + } else { + // Process a single file + await this.processFile(sourcePath, destinationDir, category); + } + } catch (error) { + console.error(`Error processing ${sourcePath}:`, error); + throw error; + } + } + + async processDirectory(sourceDir, destDir, category) { + try { + // Read all files in the directory + const files = await fs.readdir(sourceDir); + + // Process each file + for (const file of files) { + const filePath = path.join(sourceDir, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + // Recursively process subdirectories + await this.processDirectory(filePath, destDir, category); + } else { + // Process files + await this.processFile(filePath, destDir, category); + } + } + } catch (error) { + console.error(`Error processing directory ${sourceDir}:`, error); + throw error; + } + } + + async processFile(filePath, destDir, category) { + const fileName = path.basename(filePath); + + // Skip processing if it's a sample file and ignoreSample is enabled + if (this.config.processingOptions.ignoreSample && this.isSampleFile(fileName)) { + console.log(`Skipping sample file: ${fileName}`); + return; + } + + // Skip processing if it's an extras file and ignoreExtras is enabled + if (this.config.processingOptions.ignoreExtras && this.isExtrasFile(fileName)) { + console.log(`Skipping extras file: ${fileName}`); + return; + } + + // Handle archive files + if (this.isArchiveFile(fileName) && this.config.processingOptions.extractArchives) { + await this.extractArchive(filePath, destDir); + + // Delete the archive after extraction if configured + if (this.config.processingOptions.deleteArchives) { + await fs.unlink(filePath); + console.log(`Deleted archive after extraction: ${fileName}`); + } + return; + } + + // For regular files, copy to destination + let destinationFile = path.join(destDir, fileName); + + // If renameFiles is enabled, rename based on category + if (this.config.processingOptions.renameFiles) { + destinationFile = this.getDestinationFileName(fileName, destDir, category); + } + + // Create destination directory if it doesn't exist + const destSubDir = path.dirname(destinationFile); + await fs.mkdir(destSubDir, { recursive: true }); + + // Copy the file to destination + await fs.copyFile(filePath, destinationFile); + console.log(`Copied file to: ${destinationFile}`); + + // Add to library + this.addToLibrary(destinationFile, category); + } + + isArchiveFile(fileName) { + const ext = path.extname(fileName).toLowerCase(); + return ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'].includes(ext); + } + + isSampleFile(fileName) { + return fileName.toLowerCase().includes('sample'); + } + + isExtrasFile(fileName) { + const lowerName = fileName.toLowerCase(); + return ['featurette', 'extra', 'bonus', 'deleted', 'interview'].some(term => lowerName.includes(term)); + } + + async extractArchive(archivePath, destDir) { + const ext = path.extname(archivePath).toLowerCase(); + + try { + if (['.zip', '.rar', '.7z'].includes(ext)) { + // Create extract directory + const extractDir = path.join(destDir, path.basename(archivePath, ext)); + await fs.mkdir(extractDir, { recursive: true }); + + if (ext === '.zip') { + // Use AdmZip for .zip files + const zip = new AdmZip(archivePath); + zip.extractAllTo(extractDir, true); + console.log(`Extracted zip to: ${extractDir}`); + } else { + // Use unrar or 7z for other archives + const cmd = ext === '.rar' + ? `unrar x -o+ "${archivePath}" "${extractDir}"` + : `7z x "${archivePath}" -o"${extractDir}"`; + + await execAsync(cmd); + console.log(`Extracted ${ext} to: ${extractDir}`); + } + } else { + console.log(`Unsupported archive format: ${ext}`); + } + } catch (error) { + console.error(`Error extracting archive ${archivePath}:`, error); + throw error; + } + } + + getDestinationFileName(fileName, destDir, category) { + // Basic filename sanitization + let cleanName = fileName.replace(/\.\w+$/, ''); // Remove extension + cleanName = cleanName.replace(/[\/\\:\*\?"<>\|]/g, ''); // Remove invalid characters + cleanName = cleanName.replace(/\.\d{4}\./g, ' '); // Clean up dots around year + cleanName = cleanName.replace(/\./g, ' '); // Replace dots with spaces + + // Get file extension + const ext = path.extname(fileName); + + // Format based on category + switch (category) { + case 'movies': + // Extract year if possible + const yearMatch = fileName.match(/\((\d{4})\)/) || fileName.match(/\.(\d{4})\./); + const year = yearMatch ? yearMatch[1] : ''; + + // Remove quality tags + const qualityTags = ['1080p', '720p', '2160p', 'bluray', 'bdrip', 'dvdrip', 'webrip', 'hdrip']; + let cleanTitle = cleanName; + qualityTags.forEach(tag => { + cleanTitle = cleanTitle.replace(new RegExp(tag, 'i'), ''); + }); + + // Format: Movie Title (Year).ext + return path.join(destDir, `${cleanTitle.trim()}${year ? ` (${year})` : ''}${ext}`); + + case 'tvShows': + // Try to extract show name and episode info + let showName = cleanName; + let episodeInfo = ''; + + // Look for S01E01 format + const seasonEpMatch = cleanName.match(/S(\d{1,2})E(\d{1,2})/i); + if (seasonEpMatch) { + const parts = cleanName.split(/S\d{1,2}E\d{1,2}/i); + showName = parts[0].trim(); + episodeInfo = `S${seasonEpMatch[1].padStart(2, '0')}E${seasonEpMatch[2].padStart(2, '0')}`; + } + + // Create show directory + const showDir = path.join(destDir, showName); + fs.mkdir(showDir, { recursive: true }); + + // Format: Show Name/Show Name - S01E01.ext + return path.join(showDir, `${showName}${episodeInfo ? ` - ${episodeInfo}` : ''}${ext}`); + + case 'books': + // Try to extract author and title + let author = ''; + let title = cleanName; + + // Look for common author patterns: "Author - Title" or "Author Name - Book Title" + const authorMatch = cleanName.match(/^(.*?)\s+-\s+(.*?)$/); + if (authorMatch) { + author = authorMatch[1].trim(); + title = authorMatch[2].trim(); + } + + // Create author directory if we identified one + let bookPath = destDir; + if (author && this.config.processingOptions.createCategoryFolders) { + bookPath = path.join(destDir, author); + fs.mkdir(bookPath, { recursive: true }); + } + + // Format: Author/Title.ext or just Title.ext if no author + return path.join(bookPath, `${title}${ext}`); + + case 'magazines': + // Try to extract magazine name and issue info + let magazineName = cleanName; + let issueInfo = ''; + + // Look for issue number patterns + const issueMatch = cleanName.match(/issue\s+(\d+)/i) || + cleanName.match(/(\w+)\s+(\d{4})/) || // Month Year + cleanName.match(/(\d+)\s+(\w+)\s+(\d{4})/); // Day Month Year + + if (issueMatch) { + // Try to separate magazine name from issue info + const parts = cleanName.split(/issue|vol|volume|\d{4}/i)[0]; + if (parts) { + magazineName = parts.trim(); + // Extract issue date/number from the full name + issueInfo = cleanName.substring(magazineName.length).trim(); + } + } + + // Create magazine directory + let magazinePath = destDir; + if (this.config.processingOptions.createCategoryFolders) { + magazinePath = path.join(destDir, magazineName); + fs.mkdir(magazinePath, { recursive: true }); + } + + // Format: Magazine Name/Magazine Name - Issue Info.ext + return path.join(magazinePath, `${magazineName}${issueInfo ? ` - ${issueInfo}` : ''}${ext}`); + + default: + // For other categories, just use the cleaned name + return path.join(destDir, `${cleanName}${ext}`); + } + } + + addToLibrary(filePath, category) { + if (!this.library[category]) { + this.library[category] = []; + } + + const fileName = path.basename(filePath); + + // Check if file is already in the library + if (!this.library[category].includes(fileName)) { + this.library[category].push({ + name: fileName, + path: filePath, + added: new Date().toISOString() + }); + } + } + + async updateLibrary() { + try { + // Scan the destination directories + for (const [category, destDir] of Object.entries(this.config.destinationPaths)) { + if (category === 'downloadDir') continue; + + // Initialize or clear the category in the library + this.library[category] = []; + + // Scan the directory + await this.scanDirectory(destDir, category); + } + + console.log('Library updated'); + } catch (error) { + console.error('Error updating library:', error); + } + } + + async scanDirectory(directory, category) { + try { + const files = await fs.readdir(directory); + + for (const file of files) { + const filePath = path.join(directory, file); + const stats = await fs.stat(filePath); + + if (stats.isDirectory()) { + await this.scanDirectory(filePath, category); + } else { + this.addToLibrary(filePath, category); + } + } + } catch (error) { + console.error(`Error scanning directory ${directory}:`, error); + } + } + + removeTorrentFromTransmission(torrentId) { + return new Promise((resolve, reject) => { + this.transmissionClient.remove(torrentId, true, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + }); + } + + // Public API methods + + getLibrary() { + return this.library; + } + + searchLibrary(query) { + const results = {}; + + query = query.toLowerCase(); + + for (const [category, items] of Object.entries(this.library)) { + results[category] = items.filter(item => + item.name.toLowerCase().includes(query) + ); + } + + return results; + } + + getLibraryStats() { + const stats = { + totalItems: 0, + categories: {} + }; + + for (const [category, items] of Object.entries(this.library)) { + stats.categories[category] = items.length; + stats.totalItems += items.length; + } + + return stats; + } +} -# Enable and start the service -echo -e "${YELLOW}Starting service...${NC}" -systemctl daemon-reload -systemctl enable $SERVICE_NAME -systemctl start $SERVICE_NAME +module.exports = PostProcessor; +EOF -# 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 +# Create rssFeedManager.js file +echo -e "${YELLOW}Creating rssFeedManager.js...${NC}" +cat > $INSTALL_DIR/rssFeedManager.js << 'EOF' +// rssFeedManager.js +const fs = require('fs').promises; +const path = require('path'); +const fetch = require('node-fetch'); +const xml2js = require('xml2js'); +const crypto = require('crypto'); -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 +class RssFeedManager { + constructor(config) { + this.config = config; + this.feeds = config.feeds || []; + this.items = []; + this.updateIntervalId = null; + this.updateIntervalMinutes = config.updateIntervalMinutes || 60; + this.parser = new xml2js.Parser({ explicitArray: false }); + } + + async start() { + if (this.updateIntervalId) { + return; + } + + // Run update immediately + await this.updateAllFeeds(); + + // Then set up interval + this.updateIntervalId = setInterval(async () => { + await this.updateAllFeeds(); + }, this.updateIntervalMinutes * 60 * 1000); + + console.log(`RSS feed manager started, interval: ${this.updateIntervalMinutes} minutes`); + } + + stop() { + if (this.updateIntervalId) { + clearInterval(this.updateIntervalId); + this.updateIntervalId = null; + console.log('RSS feed manager stopped'); + return true; + } + return false; + } + + async updateAllFeeds() { + console.log('Updating all RSS feeds...'); + + const results = []; + + for (const feed of this.feeds) { + try { + const result = await this.updateFeed(feed); + results.push({ + feedId: feed.id, + success: true, + newItems: result.newItems + }); + } catch (error) { + console.error(`Error updating feed ${feed.id} (${feed.url}):`, error.message); + results.push({ + feedId: feed.id, + success: false, + error: error.message + }); + } + } + + // Save updated items + await this.saveItems(); + + console.log('RSS feed update completed'); + return results; + } + + async updateFeed(feed) { + console.log(`Updating feed: ${feed.name} (${feed.url})`); + + const response = await fetch(feed.url); + + if (!response.ok) { + throw new Error(`HTTP error ${response.status}: ${response.statusText}`); + } + + const xml = await response.text(); + const result = await this.parseXml(xml); + + const rssItems = this.extractItems(result, feed); + const newItems = this.processNewItems(rssItems, feed); + + console.log(`Found ${rssItems.length} items, ${newItems.length} new items in feed: ${feed.name}`); + + return { + totalItems: rssItems.length, + newItems: newItems.length + }; + } + + parseXml(xml) { + return new Promise((resolve, reject) => { + this.parser.parseString(xml, (error, result) => { + if (error) { + reject(error); + } else { + resolve(result); + } + }); + }); + } + + extractItems(parsedXml, feed) { + try { + // Handle standard RSS 2.0 + if (parsedXml.rss && parsedXml.rss.channel) { + const channel = parsedXml.rss.channel; + const items = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean); + return items.map(item => this.normalizeRssItem(item, feed)); + } + + // Handle Atom + if (parsedXml.feed && parsedXml.feed.entry) { + const entries = Array.isArray(parsedXml.feed.entry) ? parsedXml.feed.entry : [parsedXml.feed.entry].filter(Boolean); + return entries.map(entry => this.normalizeAtomItem(entry, feed)); + } + + return []; + } catch (error) { + console.error('Error extracting items from XML:', error); + return []; + } + } + + normalizeRssItem(item, feed) { + // Create a unique ID for the item + const idContent = `${feed.id}:${item.title}:${item.pubDate || ''}:${item.link || ''}`; + const id = crypto.createHash('md5').update(idContent).digest('hex'); + + // Extract enclosure (torrent link) + let torrentLink = item.link || ''; + let fileSize = 0; + + if (item.enclosure) { + torrentLink = item.enclosure.$ ? item.enclosure.$.url : item.enclosure.url || torrentLink; + fileSize = item.enclosure.$ ? parseInt(item.enclosure.$.length || 0, 10) : parseInt(item.enclosure.length || 0, 10); + } + + // Handle custom namespaces (common in torrent feeds) + let category = ''; + let size = fileSize; + + if (item.category) { + category = Array.isArray(item.category) ? item.category[0] : item.category; + } + + // Some feeds use torrent:contentLength + if (item['torrent:contentLength']) { + size = parseInt(item['torrent:contentLength'], 10); + } + + return { + id, + feedId: feed.id, + title: entry.title || 'Untitled', + link: link, + torrentLink: torrentLink, + pubDate: entry.updated || entry.published || new Date().toISOString(), + category: entry.category?.$.term || '', + description: entry.summary || entry.content || '', + size: 0, // Atom usually doesn't include size + downloaded: false, + ignored: false, + added: new Date().toISOString() + }; + } + + processNewItems(rssItems, feed) { + const newItems = []; + + for (const item of rssItems) { + // Check if item already exists + const existingItem = this.items.find(i => i.id === item.id); + + if (!existingItem) { + // Add to items list + this.items.push(item); + newItems.push(item); + + // Check if we should auto-download this item based on feed filters + if (feed.autoDownload && this.itemMatchesFilters(item, feed.filters)) { + this.queueItemForDownload(item); + } + } + } + + return newItems; + } + + itemMatchesFilters(item, filters) { + if (!filters || !Array.isArray(filters) || filters.length === 0) { + return false; + } + + for (const filter of filters) { + let matches = true; + + // Match title + if (filter.title) { + const regex = new RegExp(filter.title, 'i'); + if (!regex.test(item.title)) { + matches = false; + } + } + + // Match category + if (filter.category && item.category) { + if (item.category.toLowerCase() !== filter.category.toLowerCase()) { + matches = false; + } + } + + // Match min size + if (filter.minSize && item.size) { + if (item.size < filter.minSize) { + matches = false; + } + } + + // Match max size + if (filter.maxSize && item.size) { + if (item.size > filter.maxSize) { + matches = false; + } + } + + // If all conditions match, return true + if (matches) { + return true; + } + } + + return false; + } + + queueItemForDownload(item) { + console.log(`Auto-download queued for item: ${item.title}`); + + // In a real implementation, we would either: + // 1. Call downloadItem right away + // 2. Add to a queue that gets processed by another service + + // For now, just mark as queued + item.queued = true; + } + + async downloadItem(item, transmissionClient) { + if (!item.torrentLink) { + return { + success: false, + message: 'No torrent link available' + }; + } + + return new Promise((resolve, reject) => { + transmissionClient.addUrl(item.torrentLink, (err, result) => { + if (err) { + console.error(`Error adding item to Transmission: ${err.message}`); + reject({ + success: false, + message: `Error adding to Transmission: ${err.message}` + }); + } else { + // Mark as downloaded + item.downloaded = true; + item.downloadDate = new Date().toISOString(); + + // Update the item in our items array + const index = this.items.findIndex(i => i.id === item.id); + if (index !== -1) { + this.items[index] = item; + } + + // Save items + this.saveItems(); + + resolve({ + success: true, + message: 'Item added to Transmission', + result + }); + } + }); + }); + } + + // File operations + + async saveItems() { + try { + await fs.writeFile( + path.join(__dirname, 'rss-items.json'), + JSON.stringify(this.items, null, 2), + 'utf8' + ); + return true; + } catch (error) { + console.error('Error saving RSS items:', error); + return false; + } + } + + async loadItems() { + try { + const data = await fs.readFile(path.join(__dirname, 'rss-items.json'), 'utf8'); + this.items = JSON.parse(data); + console.log(`Loaded ${this.items.length} RSS items`); + return true; + } catch (error) { + // If file doesn't exist yet, just return false + if (error.code === 'ENOENT') { + console.log('No saved RSS items found, starting fresh'); + return false; + } + + console.error('Error loading RSS items:', error); + return false; + } + } + + async saveConfig() { + try { + // In a real implementation, this might save to a database or config file + return true; + } catch (error) { + console.error('Error saving RSS config:', error); + return false; + } + } + + // Public API methods + + getAllFeeds() { + return this.feeds; + } + + getFeed(id) { + return this.feeds.find(feed => feed.id === id); + } + + addFeed(feed) { + // Generate an ID if not provided + if (!feed.id) { + feed.id = crypto.randomUUID(); + } + + // Add creation timestamp + feed.added = new Date().toISOString(); + + // Add to feeds array + this.feeds.push(feed); + + return feed; + } + + updateFeedConfig(id, updates) { + const index = this.feeds.findIndex(feed => feed.id === id); + + if (index === -1) { + return false; + } + + // Update the feed + this.feeds[index] = { + ...this.feeds[index], + ...updates, + updated: new Date().toISOString() + }; + + return true; + } + + removeFeed(id) { + const initialLength = this.feeds.length; + this.feeds = this.feeds.filter(feed => feed.id !== id); + + // Also remove items from this feed + this.items = this.items.filter(item => item.feedId !== id); + + return this.feeds.length < initialLength; + } + + getAllItems() { + return this.items; + } + + getUndownloadedItems() { + return this.items.filter(item => !item.downloaded && !item.ignored); + } + + filterItems(filters) { + if (!filters) { + return this.items; + } + + return this.items.filter(item => { + let matches = true; + + if (filters.title) { + const regex = new RegExp(filters.title, 'i'); + if (!regex.test(item.title)) { + matches = false; + } + } + + if (filters.feedId) { + if (item.feedId !== filters.feedId) { + matches = false; + } + } + + if (filters.downloaded !== undefined) { + if (item.downloaded !== filters.downloaded) { + matches = false; + } + } + + if (filters.ignored !== undefined) { + if (item.ignored !== filters.ignored) { + matches = false; + } + } + + return matches; + }); + } +} -# 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}" +module.exports = RssFeedManager;d.id, + title: item.title || 'Untitled', + link: item.link || '', + torrentLink: torrentLink, + pubDate: item.pubDate || new Date().toISOString(), + category: category, + description: item.description || '', + size: size || 0, + downloaded: false, + ignored: false, + added: new Date().toISOString() + }; + } + + normalizeAtomItem(entry, feed) { + // Create a unique ID for the item + const idContent = `${feed.id}:${entry.title}:${entry.updated || ''}:${entry.id || ''}`; + const id = crypto.createHash('md5').update(idContent).digest('hex'); + + // Extract link + let link = ''; + let torrentLink = ''; + + if (entry.link) { + if (Array.isArray(entry.link)) { + const links = entry.link; + link = links.find(l => l.$.rel === 'alternate')?.$.href || links[0]?.$.href || ''; + torrentLink = links.find(l => l.$.type && l.$.type.includes('torrent'))?.$.href || link; + } else { + link = entry.link.$.href || ''; + torrentLink = link; + } + } + + return { + id, + feedId: fee diff --git a/post-processor-implementation.txt b/post-processor-implementation.txt deleted file mode 100644 index dbef91e..0000000 --- a/post-processor-implementation.txt +++ /dev/null @@ -1,982 +0,0 @@ -// 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 deleted file mode 100644 index fc9b334..0000000 --- a/rss-implementation.js +++ /dev/null @@ -1,586 +0,0 @@ -// 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; -}