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