transmission-rss-manager/install-script.sh
2025-02-26 13:30:39 +01:00

2899 lines
88 KiB
Bash
Executable File

#!/bin/bash
# Enhanced Transmission RSS Manager Installation Script for Ubuntu
# Includes support for book/magazine sorting and all UI enhancements
# Text formatting
BOLD='\033[1m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Print header
echo -e "${BOLD}==================================================${NC}"
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
echo -e "${BOLD} Version 1.2.0 - Enhanced Edition ${NC}"
echo -e "${BOLD}==================================================${NC}"
echo
# Check if script is run with sudo
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Please run as root (use sudo)${NC}"
exit 1
fi
# Get current directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
# Configuration variables
INSTALL_DIR="/opt/transmission-rss-manager"
SERVICE_NAME="transmission-rss-manager"
USER=$(logname || echo $SUDO_USER)
PORT=3000
# Transmission configuration variables
TRANSMISSION_REMOTE=false
TRANSMISSION_HOST="localhost"
TRANSMISSION_PORT=9091
TRANSMISSION_USER=""
TRANSMISSION_PASS=""
TRANSMISSION_RPC_PATH="/transmission/rpc"
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
TRANSMISSION_DIR_MAPPING="{}"
# Ask user for configuration
echo -e "${BOLD}Installation Configuration:${NC}"
echo -e "Please provide the following configuration parameters:"
echo
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
INSTALL_DIR=${input_install_dir:-$INSTALL_DIR}
read -p "Web interface port [$PORT]: " input_port
PORT=${input_port:-$PORT}
read -p "Run as user [$USER]: " input_user
USER=${input_user:-$USER}
echo
echo -e "${BOLD}Transmission Configuration:${NC}"
echo -e "Configure connection to your Transmission client:"
echo
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
if [[ $input_remote =~ ^[Yy]$ ]]; then
TRANSMISSION_REMOTE=true
read -p "Remote Transmission host [localhost]: " input_trans_host
TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST}
read -p "Remote Transmission port [9091]: " input_trans_port
TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT}
read -p "Remote Transmission username []: " input_trans_user
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
read -p "Remote Transmission password []: " input_trans_pass
TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS}
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH}
# Configure directory mapping for remote setup
echo
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
echo -e "When using a remote Transmission server, you need to map paths between servers."
echo -e "For each directory on the remote server, specify the corresponding local directory."
echo
# Get remote download directory
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
# Get local directory that corresponds to remote download directory
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
# Create mapping JSON
TRANSMISSION_DIR_MAPPING=$(cat <<EOF
{
"$REMOTE_DOWNLOAD_DIR": "$LOCAL_DOWNLOAD_DIR"
}
EOF
)
# Create the local directory
mkdir -p "$LOCAL_DOWNLOAD_DIR"
chown -R $USER:$USER "$LOCAL_DOWNLOAD_DIR"
# Ask if want to add more mappings
while true; do
read -p "Add another directory mapping? (y/n) [n]: " add_another
if [[ ! $add_another =~ ^[Yy]$ ]]; then
break
fi
read -p "Remote directory path: " remote_dir
read -p "Corresponding local directory path: " local_dir
if [ -n "$remote_dir" ] && [ -n "$local_dir" ]; then
# Update mapping JSON (remove the last "}" and add the new mapping)
TRANSMISSION_DIR_MAPPING="${TRANSMISSION_DIR_MAPPING%\}}, \"$remote_dir\": \"$local_dir\" }"
# Create the local directory
mkdir -p "$local_dir"
chown -R $USER:$USER "$local_dir"
echo -e "${GREEN}Mapping added: $remote_dir$local_dir${NC}"
fi
done
# Set Transmission download dir for configuration
TRANSMISSION_DOWNLOAD_DIR=$REMOTE_DOWNLOAD_DIR
else
read -p "Transmission download directory [/var/lib/transmission-daemon/downloads]: " input_trans_dir
TRANSMISSION_DOWNLOAD_DIR=${input_trans_dir:-$TRANSMISSION_DOWNLOAD_DIR}
fi
echo
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}"
# Update package index
apt-get update
# Install Node.js and npm if not already installed
if ! command -v node &> /dev/null; then
echo "Installing Node.js and npm..."
apt-get install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
apt-get update
apt-get install -y nodejs
fi
# Install additional dependencies
echo "Installing additional dependencies..."
apt-get install -y unrar unzip p7zip-full nginx
# Create installation directory
echo -e "${YELLOW}Creating installation directory...${NC}"
mkdir -p $INSTALL_DIR
mkdir -p $INSTALL_DIR/logs
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.2.0",
"description": "Enhanced Transmission RSS Manager with post-processing capabilities",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"body-parser": "^1.20.2",
"transmission": "^0.4.10",
"adm-zip": "^0.5.10",
"node-fetch": "^2.6.9",
"xml2js": "^0.5.0",
"cors": "^2.8.5"
}
}
EOF
# Create server.js
echo -e "${YELLOW}Creating server.js...${NC}"
cat > $INSTALL_DIR/server.js << 'EOF'
// 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',
magazines: '/mnt/media/magazines',
software: '/mnt/media/software'
},
seedingRequirements: {
minRatio: 1.0,
minTimeMinutes: 60,
checkIntervalSeconds: 300
},
processingOptions: {
enableBookSorting: true,
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,
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({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
// 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
}
});
});
// 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
const testClient = new Transmission({
host: host || config.transmissionConfig.host,
port: port || config.transmissionConfig.port,
username: username || config.transmissionConfig.username,
password: password || config.transmissionConfig.password,
url: config.transmissionConfig.path
});
// Test connection
testClient.sessionStats((err, result) => {
if (err) {
return res.json({
success: false,
message: `Connection failed: ${err.message}`
});
}
// Connection successful
res.json({
success: true,
message: "Connected to Transmission successfully!",
data: {
version: result.version || "Unknown",
rpcVersion: result.rpcVersion || "Unknown"
}
});
});
});
// 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'
});
}
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 || []
});
});
});
// 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')));
// Catch-all route for SPA
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// 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}`);
});
}
// Start the application
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 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';
// Create the modal content
const modalContent = document.createElement('div');
modalContent.style.backgroundColor = 'white';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '8px';
modalContent.style.width = '500px';
modalContent.style.maxWidth = '90%';
modalContent.innerHTML = `
<h2>Add RSS Feed</h2>
<div class="form-group">
<label for="feed-name">Feed Name:</label>
<input type="text" id="feed-name" placeholder="My Feed">
</div>
<div class="form-group">
<label for="feed-url">Feed URL:</label>
<input type="text" id="feed-url" placeholder="https://example.com/rss.xml">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="feed-auto-download"> Auto-Download
</label>
</div>
<div id="filter-container" style="display: none; margin-top: 15px; border: 1px solid #ddd; padding: 10px; border-radius: 4px;">
<h3>Auto-Download Filters</h3>
<div class="form-group">
<label for="filter-title">Title Contains:</label>
<input type="text" id="filter-title" placeholder="e.g., 1080p, HEVC">
</div>
<div class="form-group">
<label for="filter-category">Category:</label>
<input type="text" id="filter-category" placeholder="e.g., Movies, TV">
</div>
<div class="form-group">
<label for="filter-min-size">Minimum Size (MB):</label>
<input type="number" id="filter-min-size" min="0">
</div>
<div class="form-group">
<label for="filter-max-size">Maximum Size (MB):</label>
<input type="number" id="filter-max-size" min="0">
</div>
</div>
<div style="margin-top: 20px; text-align: right;">
<button id="cancel-add-feed" style="background-color: #6c757d; margin-right: 10px;">Cancel</button>
<button id="save-add-feed" style="background-color: #28a745;">Add Feed</button>
</div>
`;
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Toggle filter container based on auto-download checkbox
document.getElementById('feed-auto-download').addEventListener('change', function() {
document.getElementById('filter-container').style.display = this.checked ? 'block' : 'none';
});
// Cancel button
document.getElementById('cancel-add-feed').addEventListener('click', function() {
document.body.removeChild(modal);
});
// Save button
document.getElementById('save-add-feed').addEventListener('click', function() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const autoDownload = document.getElementById('feed-auto-download').checked;
if (!name || !url) {
alert('Name and URL are required!');
return;
}
// Create feed object
const feed = {
name,
url,
autoDownload
};
// Add filters if auto-download is enabled
if (autoDownload) {
const title = document.getElementById('filter-title').value.trim();
const category = document.getElementById('filter-category').value.trim();
const minSize = document.getElementById('filter-min-size').value ?
parseInt(document.getElementById('filter-min-size').value, 10) * 1024 * 1024 : null;
const maxSize = document.getElementById('filter-max-size').value ?
parseInt(document.getElementById('filter-max-size').value, 10) * 1024 * 1024 : null;
feed.filters = [{
title,
category,
minSize,
maxSize
}];
}
// Call API to add the feed
saveFeed(feed);
// Remove modal
document.body.removeChild(modal);
});
}
function saveFeed(feed) {
statusMessage.textContent = "Adding RSS feed...";
statusMessage.className = "status-message";
fetch("/api/rss/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(feed)
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusMessage.textContent = "RSS feed added successfully!";
statusMessage.classList.add("status-success");
loadRssData();
} else {
statusMessage.textContent = "Error adding RSS feed: " + data.message;
statusMessage.classList.add("status-error");
}
})
.catch(error => {
statusMessage.textContent = "Error: " + error.message;
statusMessage.classList.add("status-error");
console.error("Error adding RSS feed:", error);
});
}
function editFeed(id) {
// First, get the feed data
fetch(`/api/rss/feeds`)
.then(response => response.json())
.then(data => {
if (!data.success) {
alert("Error loading feed: " + data.message);
return;
}
const feed = data.data.find(f => f.id === id);
if (!feed) {
alert("Feed not found!");
return;
}
// Create a modal dialog for editing the feed (similar to addFeed)
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';
// Create the modal content
const modalContent = document.createElement('div');
modalContent.style.backgroundColor = 'white';
modalContent.style.padding = '20px';
modalContent.style.borderRadius = '8px';
modalContent.style.width = '500px';
modalContent.style.maxWidth = '90%';
// Get filter values if available
const filter = feed.filters && feed.filters.length > 0 ? feed.filters[0] : {};
const title = filter.title || '';
const category = filter.category || '';
const minSize = filter.minSize ? Math.round(filter.minSize / (1024 * 1024)) : '';
const maxSize = filter.maxSize ? Math.round(filter.maxSize / (1024 * 1024)) : '';
modalContent.innerHTML = `
<h2>Edit RSS Feed</h2>
<div class="form-group">
<label for="feed-name">Feed Name:</label>
<input type="text" id="feed-name" value="${feed.name}" placeholder="My Feed">
</div>
<div class="form-group">
<label for="feed-url">Feed URL:</label>
<input type="text" id="feed-url" value="${feed.url}" placeholder="https://example.com/rss.xml">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="feed-auto-download" ${feed.autoDownload ? 'checked' : ''}> Auto-Download
</label>
</div>
<div id="filter-container" style="display: ${feed.autoDownload ? 'block' : 'none'}; margin-top: 15px; border: 1px solid #ddd; padding: 10px; border-radius: 4px;">
<h3>Auto-Download Filters</h3>
<div class="form-group">
<label for="filter-title">Title Contains:</label>
<input type="text" id="filter-title" value="${title}" placeholder="e.g., 1080p, HEVC">
</div>
<div class="form-group">
<label for="filter-category">Category:</label>
<input type="text" id="filter-category" value="${category}" placeholder="e.g., Movies, TV">
</div>
<div class="form-group">
<label for="filter-min-size">Minimum Size (MB):</label>
<input type="number" id="filter-min-size" min="0" value="${minSize}">
</div>
<div class="form-group">
<label for="filter-max-size">Maximum Size (MB):</label>
<input type="number" id="filter-max-size" min="0" value="${maxSize}">
</div>
</div>
<div style="margin-top: 20px; text-align: right;">
<button id="cancel-edit-feed" style="background-color: #6c757d; margin-right: 10px;">Cancel</button>
<button id="save-edit-feed" style="background-color: #28a745;">Save Changes</button>
</div>
`;
modal.appendChild(modalContent);
document.body.appendChild(modal);
// Toggle filter container based on auto-download checkbox
document.getElementById('feed-auto-download').addEventListener('change', function() {
document.getElementById('filter-container').style.display = this.checked ? 'block' : 'none';
});
// Cancel button
document.getElementById('cancel-edit-feed').addEventListener('click', function() {
document.body.removeChild(modal);
});
// Save button
document.getElementById('save-edit-feed').addEventListener('click', function() {
const name = document.getElementById('feed-name').value.trim();
const url = document.getElementById('feed-url').value.trim();
const autoDownload = document.getElementById('feed-auto-download').checked;
if (!name || !url) {
alert('Name and URL are required!');
return;
}
// Create updated feed object
const updatedFeed = {
name,
url,
autoDownload
};
// Add filters if auto-download is enabled
if (autoDownload) {
const title = document.getElementById('filter-title').value.trim();
const category = document.getElementById('filter-category').value.trim();
const minSize = document.getElementById('filter-min-size').value ?
parseInt(document.getElementById('filter-min-size').value, 10) * 1024 * 1024 : null;
const maxSize = document.getElementById('filter-max-size').value ?
parseInt(document.getElementById('filter-max-size').value, 10) * 1024 * 1024 : null;
updatedFeed.filters = [{
title,
category,
minSize,
maxSize
}];
}
// Call API to update the feed
updateFeed(id, updatedFeed);
// Remove modal
document.body.removeChild(modal);
});
})
.catch(error => {
alert("Error loading feed: " + error.message);
console.error("Error loading feed:", error);
});
}
function updateFeed(id, feed) {
statusMessage.textContent = "Updating RSS feed...";
statusMessage.className = "status-message";
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";
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 = `
<h2>Post-Processing Settings</h2>
<h3>Seeding Requirements</h3>
<div class="form-group">
<label for="seeding-ratio">Minimum Seeding Ratio:</label>
<input type="number" id="seeding-ratio" min="0" step="0.1" placeholder="1.0">
</div>
<div class="form-group">
<label for="seeding-time">Minimum Seeding Time (minutes):</label>
<input type="number" id="seeding-time" min="0" placeholder="60">
</div>
<div class="form-group">
<label for="check-interval">Check Interval (seconds):</label>
<input type="number" id="check-interval" min="30" placeholder="300">
</div>
<h3>Content Categories</h3>
<div class="form-group">
<label>
<input type="checkbox" id="enable-book-sorting"> Enable Book and Magazine Sorting
</label>
<p class="help-text" style="margin-top: 5px; color: #666; font-size: 0.9em;">
When enabled, books and magazines will be processed separately with specialized organization.
</p>
</div>
<h3>Media Paths</h3>
<div class="form-group">
<label for="movies-path">Movies Path:</label>
<input type="text" id="movies-path" placeholder="/mnt/media/movies">
</div>
<div class="form-group">
<label for="tvshows-path">TV Shows Path:</label>
<input type="text" id="tvshows-path" placeholder="/mnt/media/tvshows">
</div>
<div class="form-group">
<label for="music-path">Music Path:</label>
<input type="text" id="music-path" placeholder="/mnt/media/music">
</div>
<div class="form-group">
<label for="books-path">Books Path:</label>
<input type="text" id="books-path" placeholder="/mnt/media/books">
</div>
<div class="form-group">
<label for="magazines-path">Magazines Path:</label>
<input type="text" id="magazines-path" placeholder="/mnt/media/magazines">
</div>
<div class="form-group">
<label for="software-path">Software Path:</label>
<input type="text" id="software-path" placeholder="/mnt/media/software">
</div>
<h3>Archive Processing</h3>
<div class="form-group">
<label>
<input type="checkbox" id="extract-archives"> Extract Archives
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="delete-archives"> Delete Archives After Extraction
</label>
</div>
<h3>File Organization</h3>
<div class="form-group">
<label>
<input type="checkbox" id="create-category-folders"> Create Category Folders
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="rename-files"> Rename Files
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="ignore-sample"> Ignore Sample Files
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="ignore-extras"> Ignore Extras Files
</label>
</div>
<h3>Quality Management</h3>
<div class="form-group">
<label>
<input type="checkbox" id="auto-replace-upgrades"> Auto-Replace With Better Versions
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="remove-duplicates"> Remove Duplicates
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="keep-best-version"> Keep Only Best Version
</label>
</div>
<button id="save-processing-settings" class="success">Save Settings</button>
<div class="mt-2">
<button id="start-processor" class="success">Start Post-Processor</button>
<button id="stop-processor" class="warning">Stop Post-Processor</button>
</div>
`;
console.log("Post-processing settings panel has been enhanced");
} else {
console.error("Could not find post-processing settings card");
}
// 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 = `
<label>
<input type="radio" name="library-filter" value="all" checked> All
</label>
<label>
<input type="radio" name="library-filter" value="movies"> Movies
</label>
<label>
<input type="radio" name="library-filter" value="tvShows"> TV Shows
</label>
<label>
<input type="radio" name="library-filter" value="music"> Music
</label>
<label>
<input type="radio" name="library-filter" value="books"> Books
</label>
<label>
<input type="radio" name="library-filter" value="magazines"> Magazines
</label>
<label>
<input type="radio" name="library-filter" value="software"> Software
</label>
`;
}
}
}
// 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
# 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');
const execAsync = util.promisify(exec);
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;
}
}
module.exports = PostProcessor;
EOF
# 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');
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;
});
}
}
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