2899 lines
88 KiB
Bash
Executable File
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
|