first commit
This commit is contained in:
commit
756c6fdd6f
119
full-server-implementation.txt
Normal file
119
full-server-implementation.txt
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// server.js - Main application server file
|
||||||
|
const express = require('express');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const cors = require('cors');
|
||||||
|
const Transmission = require('transmission');
|
||||||
|
|
||||||
|
// Import custom modules
|
||||||
|
const PostProcessor = require('./postProcessor');
|
||||||
|
const RssFeedManager = require('./rssFeedManager');
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = {
|
||||||
|
transmissionConfig: {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 9091,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
path: '/transmission/rpc'
|
||||||
|
},
|
||||||
|
remoteConfig: {
|
||||||
|
isRemote: false,
|
||||||
|
directoryMapping: {}
|
||||||
|
},
|
||||||
|
destinationPaths: {
|
||||||
|
movies: '/mnt/media/movies',
|
||||||
|
tvShows: '/mnt/media/tvshows',
|
||||||
|
music: '/mnt/media/music',
|
||||||
|
books: '/mnt/media/books',
|
||||||
|
software: '/mnt/media/software'
|
||||||
|
},
|
||||||
|
seedingRequirements: {
|
||||||
|
minRatio: 1.0,
|
||||||
|
minTimeMinutes: 60,
|
||||||
|
checkIntervalSeconds: 300
|
||||||
|
},
|
||||||
|
processingOptions: {
|
||||||
|
extractArchives: true,
|
||||||
|
deleteArchives: true,
|
||||||
|
createCategoryFolders: true,
|
||||||
|
ignoreSample: true,
|
||||||
|
ignoreExtras: true,
|
||||||
|
renameFiles: true,
|
||||||
|
autoReplaceUpgrades: true,
|
||||||
|
removeDuplicates: true,
|
||||||
|
keepOnlyBestVersion: true
|
||||||
|
},
|
||||||
|
rssFeeds: [],
|
||||||
|
rssUpdateIntervalMinutes: 60,
|
||||||
|
autoProcessing: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Service instances
|
||||||
|
let transmissionClient = null;
|
||||||
|
let postProcessor = null;
|
||||||
|
let rssFeedManager = null;
|
||||||
|
|
||||||
|
// Save config function
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(__dirname, 'config.json'),
|
||||||
|
JSON.stringify(config, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
console.log('Configuration saved');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving config:', err.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config function
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8');
|
||||||
|
const loadedConfig = JSON.parse(data);
|
||||||
|
config = { ...config, ...loadedConfig };
|
||||||
|
console.log('Configuration loaded');
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading config, using defaults:', err.message);
|
||||||
|
// Save default config
|
||||||
|
await saveConfig();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Transmission client
|
||||||
|
function initTransmission() {
|
||||||
|
transmissionClient = new Transmission({
|
||||||
|
host: config.transmissionConfig.host,
|
||||||
|
port: config.transmissionConfig.port,
|
||||||
|
username: config.transmissionConfig.username,
|
||||||
|
password: config.transmissionConfig.password,
|
||||||
|
url: config.transmissionConfig.path
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`);
|
||||||
|
return transmissionClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize post processor
|
||||||
|
function initPostProcessor() {
|
||||||
|
if (postProcessor) {
|
||||||
|
postProcessor.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
postProcessor = new PostProcessor({
|
||||||
|
transmissionConfig: config.transmissionConfig,
|
||||||
|
remoteConfig: config.remoteConfig,
|
||||||
|
destinationPaths: config.destinationPaths,
|
||||||
|
see
|
870
install-script.sh
Executable file
870
install-script.sh
Executable file
@ -0,0 +1,870 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Transmission RSS Manager Installation Script for Ubuntu
|
||||||
|
# This script installs all necessary dependencies and sets up the program
|
||||||
|
|
||||||
|
# Text formatting
|
||||||
|
BOLD='\033[1m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Print header
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo -e "${BOLD} Transmission RSS Manager Installer ${NC}"
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check if script is run with sudo
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo -e "${RED}Please run as root (use sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current directory
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
|
|
||||||
|
# Configuration variables
|
||||||
|
INSTALL_DIR="/opt/transmission-rss-manager"
|
||||||
|
SERVICE_NAME="transmission-rss-manager"
|
||||||
|
USER=$(logname || echo $SUDO_USER)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# Transmission configuration variables
|
||||||
|
TRANSMISSION_REMOTE=false
|
||||||
|
TRANSMISSION_HOST="localhost"
|
||||||
|
TRANSMISSION_PORT=9091
|
||||||
|
TRANSMISSION_USER=""
|
||||||
|
TRANSMISSION_PASS=""
|
||||||
|
TRANSMISSION_RPC_PATH="/transmission/rpc"
|
||||||
|
TRANSMISSION_DOWNLOAD_DIR="/var/lib/transmission-daemon/downloads"
|
||||||
|
TRANSMISSION_DIR_MAPPING="{}"
|
||||||
|
|
||||||
|
# Ask user for configuration
|
||||||
|
echo -e "${BOLD}Installation Configuration:${NC}"
|
||||||
|
echo -e "Please provide the following configuration parameters:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Installation directory [$INSTALL_DIR]: " input_install_dir
|
||||||
|
INSTALL_DIR=${input_install_dir:-$INSTALL_DIR}
|
||||||
|
|
||||||
|
read -p "Web interface port [$PORT]: " input_port
|
||||||
|
PORT=${input_port:-$PORT}
|
||||||
|
|
||||||
|
read -p "Run as user [$USER]: " input_user
|
||||||
|
USER=${input_user:-$USER}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}Transmission Configuration:${NC}"
|
||||||
|
echo -e "Configure connection to your Transmission client:"
|
||||||
|
echo
|
||||||
|
|
||||||
|
read -p "Is Transmission running on a remote server? (y/n) [n]: " input_remote
|
||||||
|
if [[ $input_remote =~ ^[Yy]$ ]]; then
|
||||||
|
TRANSMISSION_REMOTE=true
|
||||||
|
|
||||||
|
read -p "Remote Transmission host [localhost]: " input_trans_host
|
||||||
|
TRANSMISSION_HOST=${input_trans_host:-$TRANSMISSION_HOST}
|
||||||
|
|
||||||
|
read -p "Remote Transmission port [9091]: " input_trans_port
|
||||||
|
TRANSMISSION_PORT=${input_trans_port:-$TRANSMISSION_PORT}
|
||||||
|
|
||||||
|
read -p "Remote Transmission username []: " input_trans_user
|
||||||
|
TRANSMISSION_USER=${input_trans_user:-$TRANSMISSION_USER}
|
||||||
|
|
||||||
|
read -p "Remote Transmission password []: " input_trans_pass
|
||||||
|
TRANSMISSION_PASS=${input_trans_pass:-$TRANSMISSION_PASS}
|
||||||
|
|
||||||
|
read -p "Remote Transmission RPC path [/transmission/rpc]: " input_trans_path
|
||||||
|
TRANSMISSION_RPC_PATH=${input_trans_path:-$TRANSMISSION_RPC_PATH}
|
||||||
|
|
||||||
|
# Configure directory mapping for remote setup
|
||||||
|
echo
|
||||||
|
echo -e "${YELLOW}Directory Mapping Configuration${NC}"
|
||||||
|
echo -e "When using a remote Transmission server, you need to map paths between servers."
|
||||||
|
echo -e "For each directory on the remote server, specify the corresponding local directory."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Get remote download directory
|
||||||
|
read -p "Remote Transmission download directory: " REMOTE_DOWNLOAD_DIR
|
||||||
|
REMOTE_DOWNLOAD_DIR=${REMOTE_DOWNLOAD_DIR:-"/var/lib/transmission-daemon/downloads"}
|
||||||
|
|
||||||
|
# Get local directory that corresponds to remote download directory
|
||||||
|
read -p "Local directory that corresponds to the remote download directory: " LOCAL_DOWNLOAD_DIR
|
||||||
|
LOCAL_DOWNLOAD_DIR=${LOCAL_DOWNLOAD_DIR:-"/mnt/transmission-downloads"}
|
||||||
|
|
||||||
|
# Create mapping JSON
|
||||||
|
TRANSMISSION_DIR_MAPPING=$(cat <<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"}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Copy application files if they exist
|
||||||
|
echo -e "${YELLOW}Copying application files...${NC}"
|
||||||
|
cp $SCRIPT_DIR/*.js $INSTALL_DIR/ 2>/dev/null || :
|
||||||
|
cp $SCRIPT_DIR/*.css $INSTALL_DIR/ 2>/dev/null || :
|
||||||
|
cp $SCRIPT_DIR/*.html $INSTALL_DIR/ 2>/dev/null || :
|
||||||
|
cp $SCRIPT_DIR/*.md $INSTALL_DIR/ 2>/dev/null || :
|
||||||
|
|
||||||
|
# Create package.json
|
||||||
|
echo -e "${YELLOW}Creating package.json...${NC}"
|
||||||
|
cat > $INSTALL_DIR/package.json << EOF
|
||||||
|
{
|
||||||
|
"name": "transmission-rss-manager",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Transmission RSS Manager with post-processing capabilities",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"body-parser": "^1.20.2",
|
||||||
|
"transmission": "^0.4.10",
|
||||||
|
"adm-zip": "^0.5.10",
|
||||||
|
"node-fetch": "^2.6.9",
|
||||||
|
"xml2js": "^0.5.0",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create server.js
|
||||||
|
echo -e "${YELLOW}Creating server.js...${NC}"
|
||||||
|
cat > $INSTALL_DIR/server.js << EOF
|
||||||
|
const express = require("express");
|
||||||
|
const bodyParser = require("body-parser");
|
||||||
|
const path = require("path");
|
||||||
|
const fs = require("fs").promises;
|
||||||
|
const cors = require("cors");
|
||||||
|
const Transmission = require("transmission");
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || $PORT;
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
let config = {
|
||||||
|
transmissionConfig: {
|
||||||
|
host: "$TRANSMISSION_HOST",
|
||||||
|
port: $TRANSMISSION_PORT,
|
||||||
|
username: "$TRANSMISSION_USER",
|
||||||
|
password: "$TRANSMISSION_PASS",
|
||||||
|
path: "$TRANSMISSION_RPC_PATH"
|
||||||
|
},
|
||||||
|
remoteConfig: {
|
||||||
|
isRemote: $TRANSMISSION_REMOTE,
|
||||||
|
directoryMapping: $TRANSMISSION_DIR_MAPPING
|
||||||
|
},
|
||||||
|
destinationPaths: {
|
||||||
|
movies: "$MEDIA_DIR/movies",
|
||||||
|
tvShows: "$MEDIA_DIR/tvshows",
|
||||||
|
music: "$MEDIA_DIR/music",
|
||||||
|
books: "$MEDIA_DIR/books",
|
||||||
|
software: "$MEDIA_DIR/software"
|
||||||
|
},
|
||||||
|
seedingRequirements: {
|
||||||
|
minRatio: 1.0,
|
||||||
|
minTimeMinutes: 60,
|
||||||
|
checkIntervalSeconds: 300
|
||||||
|
},
|
||||||
|
processingOptions: {
|
||||||
|
extractArchives: true,
|
||||||
|
deleteArchives: true,
|
||||||
|
createCategoryFolders: true,
|
||||||
|
ignoreSample: true,
|
||||||
|
ignoreExtras: true,
|
||||||
|
renameFiles: true,
|
||||||
|
autoReplaceUpgrades: true,
|
||||||
|
removeDuplicates: true,
|
||||||
|
keepOnlyBestVersion: true
|
||||||
|
},
|
||||||
|
downloadDir: "$TRANSMISSION_DOWNLOAD_DIR"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save config function
|
||||||
|
async function saveConfig() {
|
||||||
|
try {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(__dirname, 'config.json'),
|
||||||
|
JSON.stringify(config, null, 2),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
console.log('Configuration saved');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving config:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize config on startup
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8');
|
||||||
|
const loadedConfig = JSON.parse(data);
|
||||||
|
config = { ...config, ...loadedConfig };
|
||||||
|
console.log('Configuration loaded');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading config, using defaults:', err.message);
|
||||||
|
// Save default config
|
||||||
|
await saveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Transmission client
|
||||||
|
let transmissionClient = null;
|
||||||
|
|
||||||
|
function initTransmission() {
|
||||||
|
transmissionClient = new Transmission({
|
||||||
|
host: config.transmissionConfig.host,
|
||||||
|
port: config.transmissionConfig.port,
|
||||||
|
username: config.transmissionConfig.username,
|
||||||
|
password: config.transmissionConfig.password,
|
||||||
|
url: config.transmissionConfig.path
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable CORS
|
||||||
|
app.use(cors());
|
||||||
|
|
||||||
|
// Parse JSON bodies
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// API routes - defined BEFORE static files
|
||||||
|
app.get("/api/status", (req, res) => {
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.send(JSON.stringify({
|
||||||
|
status: "running",
|
||||||
|
version: "1.0.0",
|
||||||
|
transmissionConnected: !!transmissionClient
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/transmission/test", (req, res) => {
|
||||||
|
const { host, port, username, password } = req.body;
|
||||||
|
|
||||||
|
// Create a test client with provided credentials
|
||||||
|
const testClient = new Transmission({
|
||||||
|
host: host || config.transmissionConfig.host,
|
||||||
|
port: port || config.transmissionConfig.port,
|
||||||
|
username: username || config.transmissionConfig.username,
|
||||||
|
password: password || config.transmissionConfig.password,
|
||||||
|
url: config.transmissionConfig.path
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
testClient.sessionStats((err, result) => {
|
||||||
|
if (err) {
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
message: \`Connection failed: \${err.message}\`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If successful, update config
|
||||||
|
config.transmissionConfig = {
|
||||||
|
host: host || config.transmissionConfig.host,
|
||||||
|
port: port || config.transmissionConfig.port,
|
||||||
|
username: username || config.transmissionConfig.username,
|
||||||
|
password: password || config.transmissionConfig.password,
|
||||||
|
path: config.transmissionConfig.path
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save updated config
|
||||||
|
saveConfig();
|
||||||
|
|
||||||
|
// Reinitialize client
|
||||||
|
initTransmission();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Connected to Transmission successfully!",
|
||||||
|
data: {
|
||||||
|
version: result.version || "Unknown",
|
||||||
|
rpcVersion: result.rpcVersion || "Unknown"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
app.get("/api/config", (req, res) => {
|
||||||
|
// Don't send password in response
|
||||||
|
const safeConfig = { ...config };
|
||||||
|
if (safeConfig.transmissionConfig) {
|
||||||
|
safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' };
|
||||||
|
}
|
||||||
|
res.json(safeConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
app.post("/api/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Merge new config with existing config
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
...req.body,
|
||||||
|
// Preserve password if not provided
|
||||||
|
transmissionConfig: {
|
||||||
|
...config.transmissionConfig,
|
||||||
|
...req.body.transmissionConfig,
|
||||||
|
password: req.body.transmissionConfig?.password || config.transmissionConfig.password
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveConfig();
|
||||||
|
initTransmission();
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Configuration updated' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
// Catch-all route AFTER static files
|
||||||
|
app.get("*", (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "public", "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize the application
|
||||||
|
async function init() {
|
||||||
|
await loadConfig();
|
||||||
|
initTransmission();
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(\`Transmission RSS Manager running on port \${PORT}\`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the application
|
||||||
|
init().catch(err => {
|
||||||
|
console.error('Failed to initialize application:', err);
|
||||||
|
});
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create public directory and index.html
|
||||||
|
mkdir -p $INSTALL_DIR/public
|
||||||
|
echo -e "${YELLOW}Creating index.html...${NC}"
|
||||||
|
cat > $INSTALL_DIR/public/index.html << EOF
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Transmission RSS Manager</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-message {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
border-bottom: 2px solid #3498db;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<h1>Transmission RSS Manager</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="connection">Connection</div>
|
||||||
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
|
<div class="tab" data-tab="status">Status</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status-bar" class="status-message">
|
||||||
|
Checking connection...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="connection-tab">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Connection Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="host">Host:</label>
|
||||||
|
<input type="text" id="host" value="localhost">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="port">Port:</label>
|
||||||
|
<input type="number" id="port" value="9091">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username:</label>
|
||||||
|
<input type="text" id="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="connect-btn">Connect to Transmission</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="config-tab">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>System Configuration</h2>
|
||||||
|
<p>Configuration options will be available after connecting to Transmission.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="status-tab">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>System Status</h2>
|
||||||
|
<div id="system-info">
|
||||||
|
<p>Loading system information...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
const statusBar = document.getElementById("status-bar");
|
||||||
|
const tabs = document.querySelectorAll(".tab");
|
||||||
|
const tabContents = document.querySelectorAll(".tab-content");
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener("click", function() {
|
||||||
|
// Remove active class from all tabs
|
||||||
|
tabs.forEach(t => t.classList.remove("active"));
|
||||||
|
tabContents.forEach(tc => tc.classList.remove("active"));
|
||||||
|
|
||||||
|
// Add active class to clicked tab
|
||||||
|
this.classList.add("active");
|
||||||
|
document.getElementById(this.dataset.tab + "-tab").classList.add("active");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check API connection
|
||||||
|
fetch("/api/status")
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("API response was not ok: " + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
statusBar.textContent = "API Status: Connected";
|
||||||
|
statusBar.classList.add("success");
|
||||||
|
|
||||||
|
// Update system info
|
||||||
|
const systemInfo = document.getElementById("system-info");
|
||||||
|
systemInfo.innerHTML = \`
|
||||||
|
<p><strong>Version:</strong> \${data.version || "1.0.0"}</p>
|
||||||
|
<p><strong>Transmission Connected:</strong> \${data.transmissionConnected ? "Yes" : "No"}</p>
|
||||||
|
<p><strong>Status:</strong> \${data.status || "Unknown"}</p>
|
||||||
|
\`;
|
||||||
|
|
||||||
|
// Check if already connected to Transmission
|
||||||
|
if (data.transmissionConnected) {
|
||||||
|
// Load configuration
|
||||||
|
loadConfig();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
statusBar.textContent = "API Connection Error: " + error.message;
|
||||||
|
statusBar.classList.add("error");
|
||||||
|
console.error("Error details:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect button functionality
|
||||||
|
document.getElementById("connect-btn").addEventListener("click", function() {
|
||||||
|
const host = document.getElementById("host").value;
|
||||||
|
const port = document.getElementById("port").value;
|
||||||
|
const username = document.getElementById("username").value;
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
|
||||||
|
statusBar.textContent = "Connecting to Transmission...";
|
||||||
|
statusBar.classList.remove("success", "error");
|
||||||
|
|
||||||
|
fetch("/api/transmission/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
host, port, username, password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("API response was not ok: " + response.status);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
statusBar.textContent = "Connected to Transmission successfully!";
|
||||||
|
statusBar.classList.add("success");
|
||||||
|
|
||||||
|
// Load configuration after successful connection
|
||||||
|
loadConfig();
|
||||||
|
} else {
|
||||||
|
statusBar.textContent = "Connection failed: " + data.message;
|
||||||
|
statusBar.classList.add("error");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
statusBar.textContent = "Connection error: " + error.message;
|
||||||
|
statusBar.classList.add("error");
|
||||||
|
console.error("Error details:", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load configuration from server
|
||||||
|
function loadConfig() {
|
||||||
|
fetch("/api/config")
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(config => {
|
||||||
|
// Update configuration tab
|
||||||
|
const configTab = document.getElementById("config-tab");
|
||||||
|
configTab.innerHTML = \`
|
||||||
|
<div class="panel">
|
||||||
|
<h2>System Configuration</h2>
|
||||||
|
<h3>Transmission</h3>
|
||||||
|
<p><strong>Host:</strong> \${config.transmissionConfig.host}</p>
|
||||||
|
<p><strong>Port:</strong> \${config.transmissionConfig.port}</p>
|
||||||
|
|
||||||
|
<h3>Destination Paths</h3>
|
||||||
|
<p><strong>Movies:</strong> \${config.destinationPaths.movies}</p>
|
||||||
|
<p><strong>TV Shows:</strong> \${config.destinationPaths.tvShows}</p>
|
||||||
|
<p><strong>Music:</strong> \${config.destinationPaths.music}</p>
|
||||||
|
|
||||||
|
<h3>Seeding Requirements</h3>
|
||||||
|
<p><strong>Minimum Ratio:</strong> \${config.seedingRequirements.minRatio}</p>
|
||||||
|
<p><strong>Minimum Time (minutes):</strong> \${config.seedingRequirements.minTimeMinutes}</p>
|
||||||
|
|
||||||
|
<button id="edit-config-btn" class="primary-button">Edit Configuration</button>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error("Error loading configuration:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create directories for media
|
||||||
|
echo -e "${YELLOW}Creating media directories...${NC}"
|
||||||
|
mkdir -p $MEDIA_DIR/movies
|
||||||
|
mkdir -p $MEDIA_DIR/tvshows
|
||||||
|
mkdir -p $MEDIA_DIR/music
|
||||||
|
mkdir -p $MEDIA_DIR/books
|
||||||
|
mkdir -p $MEDIA_DIR/software
|
||||||
|
|
||||||
|
# Set correct permissions
|
||||||
|
echo -e "${YELLOW}Setting permissions...${NC}"
|
||||||
|
chown -R $USER:$USER $INSTALL_DIR
|
||||||
|
chown -R $USER:$USER $MEDIA_DIR
|
||||||
|
chmod -R 755 $INSTALL_DIR
|
||||||
|
chmod -R 755 $MEDIA_DIR
|
||||||
|
|
||||||
|
# Create config file
|
||||||
|
echo -e "${YELLOW}Creating configuration file...${NC}"
|
||||||
|
cat > $INSTALL_DIR/config.json << EOF
|
||||||
|
{
|
||||||
|
"transmissionConfig": {
|
||||||
|
"host": "$TRANSMISSION_HOST",
|
||||||
|
"port": $TRANSMISSION_PORT,
|
||||||
|
"username": "$TRANSMISSION_USER",
|
||||||
|
"password": "$TRANSMISSION_PASS",
|
||||||
|
"path": "$TRANSMISSION_RPC_PATH"
|
||||||
|
},
|
||||||
|
"remoteConfig": {
|
||||||
|
"isRemote": $TRANSMISSION_REMOTE,
|
||||||
|
"directoryMapping": $TRANSMISSION_DIR_MAPPING
|
||||||
|
},
|
||||||
|
"destinationPaths": {
|
||||||
|
"movies": "$MEDIA_DIR/movies",
|
||||||
|
"tvShows": "$MEDIA_DIR/tvshows",
|
||||||
|
"music": "$MEDIA_DIR/music",
|
||||||
|
"books": "$MEDIA_DIR/books",
|
||||||
|
"software": "$MEDIA_DIR/software"
|
||||||
|
},
|
||||||
|
"seedingRequirements": {
|
||||||
|
"minRatio": 1.0,
|
||||||
|
"minTimeMinutes": 60,
|
||||||
|
"checkIntervalSeconds": 300
|
||||||
|
},
|
||||||
|
"processingOptions": {
|
||||||
|
"extractArchives": true,
|
||||||
|
"deleteArchives": true,
|
||||||
|
"createCategoryFolders": true,
|
||||||
|
"ignoreSample": true,
|
||||||
|
"ignoreExtras": true,
|
||||||
|
"renameFiles": true,
|
||||||
|
"autoReplaceUpgrades": true,
|
||||||
|
"removeDuplicates": true,
|
||||||
|
"keepOnlyBestVersion": true
|
||||||
|
},
|
||||||
|
"downloadDir": "$TRANSMISSION_DOWNLOAD_DIR"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Install Node.js dependencies
|
||||||
|
echo -e "${YELLOW}Installing Node.js dependencies...${NC}"
|
||||||
|
cd $INSTALL_DIR
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
echo -e "${YELLOW}Creating systemd service...${NC}"
|
||||||
|
cat > /etc/systemd/system/$SERVICE_NAME.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Transmission RSS Manager
|
||||||
|
After=network.target
|
||||||
|
Wants=transmission-daemon.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node $INSTALL_DIR/server.js
|
||||||
|
WorkingDirectory=$INSTALL_DIR
|
||||||
|
Restart=always
|
||||||
|
User=$USER
|
||||||
|
Environment=NODE_ENV=production PORT=$PORT
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create Nginx configuration
|
||||||
|
echo -e "${YELLOW}Creating Nginx configuration...${NC}"
|
||||||
|
cat > /etc/nginx/sites-available/$SERVICE_NAME << EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
# Change this to your domain if you have one
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Main location for static files
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:$PORT;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specific location for API calls
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:$PORT/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Enable Nginx site
|
||||||
|
ln -sf /etc/nginx/sites-available/$SERVICE_NAME /etc/nginx/sites-enabled/
|
||||||
|
|
||||||
|
# Test Nginx configuration
|
||||||
|
nginx -t
|
||||||
|
|
||||||
|
# Reload Nginx
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
# Enable and start the service
|
||||||
|
echo -e "${YELLOW}Starting service...${NC}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable $SERVICE_NAME
|
||||||
|
systemctl start $SERVICE_NAME
|
||||||
|
|
||||||
|
# Print completion message
|
||||||
|
echo
|
||||||
|
echo -e "${GREEN}${BOLD}Installation Complete!${NC}"
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}The Transmission RSS Manager has been installed to:${NC} $INSTALL_DIR"
|
||||||
|
echo -e "${BOLD}Web interface is available at:${NC} http://localhost (or your server IP)"
|
||||||
|
echo -e "${BOLD}Service status:${NC} $(systemctl is-active $SERVICE_NAME)"
|
||||||
|
echo
|
||||||
|
echo -e "${BOLD}To view logs:${NC} journalctl -u $SERVICE_NAME -f"
|
||||||
|
echo -e "${BOLD}To restart service:${NC} sudo systemctl restart $SERVICE_NAME"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ "$TRANSMISSION_REMOTE" = true ]; then
|
||||||
|
echo -e "${YELLOW}Remote Transmission Configuration:${NC}"
|
||||||
|
echo -e "Host: $TRANSMISSION_HOST:$TRANSMISSION_PORT"
|
||||||
|
echo -e "Directory Mapping: Remote paths will be mapped to local paths for processing"
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final check
|
||||||
|
if systemctl is-active --quiet $SERVICE_NAME; then
|
||||||
|
echo -e "${GREEN}Service is running successfully!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Service failed to start. Check logs with: journalctl -u $SERVICE_NAME -f${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BOLD}==================================================${NC}"
|
982
post-processor-implementation.txt
Normal file
982
post-processor-implementation.txt
Normal file
@ -0,0 +1,982 @@
|
|||||||
|
// postProcessor.js - Handles post-download processing
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const childProcess = require('child_process');
|
||||||
|
const util = require('util');
|
||||||
|
const exec = util.promisify(childProcess.exec);
|
||||||
|
const Transmission = require('transmission');
|
||||||
|
const AdmZip = require('adm-zip');
|
||||||
|
|
||||||
|
class PostProcessor {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.config = {
|
||||||
|
// Transmission connection details
|
||||||
|
transmissionConfig: config.transmissionConfig || {},
|
||||||
|
|
||||||
|
// Remote configuration
|
||||||
|
remoteConfig: {
|
||||||
|
isRemote: false,
|
||||||
|
directoryMapping: {},
|
||||||
|
...config.remoteConfig
|
||||||
|
},
|
||||||
|
|
||||||
|
// Destination paths
|
||||||
|
destinationPaths: {
|
||||||
|
movies: '/mnt/media/movies',
|
||||||
|
tvShows: '/mnt/media/tvshows',
|
||||||
|
music: '/mnt/media/music',
|
||||||
|
books: '/mnt/media/books',
|
||||||
|
software: '/mnt/media/software',
|
||||||
|
...config.destinationPaths
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seeding requirements
|
||||||
|
seedingRequirements: {
|
||||||
|
minRatio: 1.0, // Minimum ratio to achieve
|
||||||
|
minTimeMinutes: 60, // Minimum seeding time in minutes
|
||||||
|
checkIntervalSeconds: 300, // Check interval in seconds
|
||||||
|
...config.seedingRequirements
|
||||||
|
},
|
||||||
|
|
||||||
|
// Processing options
|
||||||
|
processingOptions: {
|
||||||
|
extractArchives: true, // Extract rar, zip, etc.
|
||||||
|
deleteArchives: true, // Delete archives after extraction
|
||||||
|
createCategoryFolders: true, // Create category subfolders
|
||||||
|
ignoreSample: true, // Ignore sample files
|
||||||
|
ignoreExtras: true, // Ignore extra/bonus content
|
||||||
|
renameFiles: true, // Rename files to clean names
|
||||||
|
autoReplaceUpgrades: true, // Automatically replace with better quality
|
||||||
|
removeDuplicates: true, // Remove duplicate content
|
||||||
|
keepOnlyBestVersion: true, // Keep only the best version when duplicates exist
|
||||||
|
...config.processingOptions
|
||||||
|
},
|
||||||
|
|
||||||
|
// File patterns
|
||||||
|
filePatterns: {
|
||||||
|
videoExtensions: ['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.m4v', '.mpg', '.mpeg'],
|
||||||
|
audioExtensions: ['.mp3', '.flac', '.m4a', '.wav', '.ogg', '.aac'],
|
||||||
|
archiveExtensions: ['.rar', '.zip', '.7z', '.tar', '.gz'],
|
||||||
|
ignorePatterns: ['sample', 'trailer', 'extra', 'bonus', 'behind.the.scenes', 'featurette'],
|
||||||
|
...config.filePatterns
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.transmissionClient = new Transmission(this.config.transmissionConfig);
|
||||||
|
this.processQueue = [];
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.processingIntervalId = null;
|
||||||
|
|
||||||
|
// Initialize the database
|
||||||
|
this.managedContentDatabase = this.loadManagedContentDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start processing monitor
|
||||||
|
start() {
|
||||||
|
if (this.processingIntervalId) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingIntervalId = setInterval(() => {
|
||||||
|
this.checkCompletedTorrents();
|
||||||
|
}, this.config.seedingRequirements.checkIntervalSeconds * 1000);
|
||||||
|
|
||||||
|
console.log('Post-processor started');
|
||||||
|
this.checkCompletedTorrents(); // Do an initial check
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop processing monitor
|
||||||
|
stop() {
|
||||||
|
if (this.processingIntervalId) {
|
||||||
|
clearInterval(this.processingIntervalId);
|
||||||
|
this.processingIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Post-processor stopped');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for completed torrents that meet seeding requirements
|
||||||
|
async checkCompletedTorrents() {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
return; // Don't start a new check if already processing
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isProcessing = true;
|
||||||
|
|
||||||
|
// Get detailed info about all torrents
|
||||||
|
const response = await this.getTorrents([
|
||||||
|
'id', 'name', 'status', 'downloadDir', 'percentDone', 'uploadRatio',
|
||||||
|
'addedDate', 'doneDate', 'downloadedEver', 'files', 'labels',
|
||||||
|
'secondsSeeding', 'isFinished'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!response || !response.arguments || !response.arguments.torrents) {
|
||||||
|
throw new Error('Invalid response from Transmission');
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrents = response.arguments.torrents;
|
||||||
|
|
||||||
|
// Find completed torrents that are seeding
|
||||||
|
for (const torrent of torrents) {
|
||||||
|
// Skip if not finished downloading or already processed
|
||||||
|
if (!torrent.isFinished || this.processQueue.some(item => item.id === torrent.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate seeding time in minutes
|
||||||
|
const seedingTimeMinutes = torrent.secondsSeeding / 60;
|
||||||
|
|
||||||
|
// Check if seeding requirements are met
|
||||||
|
const ratioMet = torrent.uploadRatio >= this.config.seedingRequirements.minRatio;
|
||||||
|
const timeMet = seedingTimeMinutes >= this.config.seedingRequirements.minTimeMinutes;
|
||||||
|
|
||||||
|
// If either requirement is met, queue for processing
|
||||||
|
if (ratioMet || timeMet) {
|
||||||
|
this.processQueue.push({
|
||||||
|
id: torrent.id,
|
||||||
|
name: torrent.name,
|
||||||
|
downloadDir: torrent.downloadDir,
|
||||||
|
files: torrent.files,
|
||||||
|
category: this.detectCategory(torrent),
|
||||||
|
ratioMet,
|
||||||
|
timeMet,
|
||||||
|
seedingTimeMinutes
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Queued torrent for processing: ${torrent.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the queue
|
||||||
|
if (this.processQueue.length > 0) {
|
||||||
|
await this.processNextInQueue();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking completed torrents:', error);
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process next item in queue
|
||||||
|
async processNextInQueue() {
|
||||||
|
if (this.processQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = this.processQueue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Processing: ${item.name}`);
|
||||||
|
|
||||||
|
// 1. Determine target directory
|
||||||
|
const targetDir = this.getTargetDirectory(item);
|
||||||
|
|
||||||
|
// 2. Process files (copy/extract)
|
||||||
|
await this.processFiles(item, targetDir);
|
||||||
|
|
||||||
|
// 3. Update status to indicate processing is complete
|
||||||
|
console.log(`Processing complete for: ${item.name}`);
|
||||||
|
|
||||||
|
// 4. Optionally remove torrent from Transmission (commented out for safety)
|
||||||
|
// await this.removeTorrent(item.id, false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing ${item.name}:`, error);
|
||||||
|
|
||||||
|
// Put back in queue to retry later
|
||||||
|
this.processQueue.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process next item
|
||||||
|
if (this.processQueue.length > 0) {
|
||||||
|
await this.processNextInQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get torrents from Transmission
|
||||||
|
async getTorrents(fields) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.transmissionClient.get(fields, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove torrent from Transmission
|
||||||
|
async removeTorrent(id, deleteLocalData = false) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.transmissionClient.remove(id, deleteLocalData, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the category of a torrent
|
||||||
|
detectCategory(torrent) {
|
||||||
|
// First check if torrent has labels
|
||||||
|
if (torrent.labels && torrent.labels.length > 0) {
|
||||||
|
const label = torrent.labels[0].toLowerCase();
|
||||||
|
|
||||||
|
if (label.includes('movie')) return 'movies';
|
||||||
|
if (label.includes('tv') || label.includes('show')) return 'tvShows';
|
||||||
|
if (label.includes('music') || label.includes('audio')) return 'music';
|
||||||
|
if (label.includes('book') || label.includes('ebook')) return 'books';
|
||||||
|
if (label.includes('software') || label.includes('app')) return 'software';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check the name for common patterns
|
||||||
|
const name = torrent.name.toLowerCase();
|
||||||
|
|
||||||
|
// Check for TV show patterns (e.g., S01E01, Season 1, etc.)
|
||||||
|
if (/s\d{1,2}e\d{1,2}/i.test(name) || /season\s\d{1,2}/i.test(name)) {
|
||||||
|
return 'tvShows';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extensions to help determine type
|
||||||
|
const fileExtensions = torrent.files.map(file => {
|
||||||
|
const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase();
|
||||||
|
return ext;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasVideoFiles = fileExtensions.some(ext =>
|
||||||
|
this.config.filePatterns.videoExtensions.includes(ext)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAudioFiles = fileExtensions.some(ext =>
|
||||||
|
this.config.filePatterns.audioExtensions.includes(ext)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasVideoFiles) {
|
||||||
|
// Assume movies if has video but not detected as TV show
|
||||||
|
return 'movies';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAudioFiles) {
|
||||||
|
return 'music';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback based on common terms
|
||||||
|
if (name.includes('movie') || name.includes('film')) return 'movies';
|
||||||
|
if (name.includes('album') || name.includes('discography')) return 'music';
|
||||||
|
if (name.includes('book') || name.includes('epub') || name.includes('pdf')) return 'books';
|
||||||
|
if (name.includes('software') || name.includes('program') || name.includes('app')) return 'software';
|
||||||
|
|
||||||
|
// Ultimate fallback
|
||||||
|
return 'movies';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target directory path based on category
|
||||||
|
getTargetDirectory(item) {
|
||||||
|
const baseDir = this.config.destinationPaths[item.category] || this.config.destinationPaths.movies;
|
||||||
|
|
||||||
|
if (!this.config.processingOptions.createCategoryFolders) {
|
||||||
|
return baseDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For movies, we might want to organize by name
|
||||||
|
if (item.category === 'movies') {
|
||||||
|
// Extract name and year if possible
|
||||||
|
const mediaInfo = this.extractMediaInfo(item.name);
|
||||||
|
if (mediaInfo.title) {
|
||||||
|
return `${baseDir}/${mediaInfo.title}${mediaInfo.year ? ` (${mediaInfo.year})` : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For TV shows, organize by show name
|
||||||
|
if (item.category === 'tvShows') {
|
||||||
|
// Try to extract show name from common patterns
|
||||||
|
const match = item.name.match(/^(.+?)(?:\s*S\d{1,2}|\s*Season\s*\d{1,2}|\s*\(\d{4}\))/i);
|
||||||
|
if (match) {
|
||||||
|
const showName = match[1].replace(/\./g, ' ').trim();
|
||||||
|
return `${baseDir}/${showName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to using the item name directly
|
||||||
|
return `${baseDir}/${item.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a remote path to a local path
|
||||||
|
mapRemotePathToLocal(remotePath) {
|
||||||
|
if (!this.config.remoteConfig.isRemote) {
|
||||||
|
return remotePath; // Not a remote setup, return as is
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = this.config.remoteConfig.directoryMapping;
|
||||||
|
|
||||||
|
// Check for exact match
|
||||||
|
if (mapping[remotePath]) {
|
||||||
|
return mapping[remotePath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for parent directory match
|
||||||
|
for (const [remote, local] of Object.entries(mapping)) {
|
||||||
|
if (remotePath.startsWith(remote)) {
|
||||||
|
const relativePath = remotePath.slice(remote.length);
|
||||||
|
return local + relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mapping found, return original path but log warning
|
||||||
|
console.warn(`No directory mapping found for remote path: ${remotePath}`);
|
||||||
|
return remotePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a local path to a remote path
|
||||||
|
mapLocalPathToRemote(localPath) {
|
||||||
|
if (!this.config.remoteConfig.isRemote) {
|
||||||
|
return localPath; // Not a remote setup, return as is
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapping = this.config.remoteConfig.directoryMapping;
|
||||||
|
const reversedMapping = {};
|
||||||
|
|
||||||
|
// Create reversed mapping (local -> remote)
|
||||||
|
for (const [remote, local] of Object.entries(mapping)) {
|
||||||
|
reversedMapping[local] = remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for exact match
|
||||||
|
if (reversedMapping[localPath]) {
|
||||||
|
return reversedMapping[localPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for parent directory match
|
||||||
|
for (const [local, remote] of Object.entries(reversedMapping)) {
|
||||||
|
if (localPath.startsWith(local)) {
|
||||||
|
const relativePath = localPath.slice(local.length);
|
||||||
|
return remote + relativePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No mapping found, return original path but log warning
|
||||||
|
console.warn(`No directory mapping found for local path: ${localPath}`);
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process files (copy or extract)
|
||||||
|
async processFiles(item, targetDir) {
|
||||||
|
// Check for existing versions of the same content
|
||||||
|
const existingVersion = await this.findExistingVersion(item);
|
||||||
|
|
||||||
|
if (existingVersion) {
|
||||||
|
console.log(`Found existing version: ${existingVersion.path}`);
|
||||||
|
|
||||||
|
// Check if new version is better quality
|
||||||
|
const isUpgrade = await this.isQualityUpgrade(item, existingVersion);
|
||||||
|
|
||||||
|
if (isUpgrade) {
|
||||||
|
console.log(`New version is a quality upgrade. Replacing...`);
|
||||||
|
|
||||||
|
// If auto-replace is enabled, remove the old version
|
||||||
|
if (this.config.processingOptions.autoReplaceUpgrades) {
|
||||||
|
await this.removeExistingVersion(existingVersion);
|
||||||
|
}
|
||||||
|
} else if (this.config.processingOptions.keepOnlyBestVersion) {
|
||||||
|
// If not an upgrade and we only want to keep the best version, stop processing
|
||||||
|
console.log(`New version is not a quality upgrade. Skipping...`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If checking for duplicates, verify this isn't a duplicate
|
||||||
|
if (this.config.processingOptions.removeDuplicates) {
|
||||||
|
const duplicates = await this.findDuplicates(item);
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
console.log(`Found ${duplicates.length} duplicate(s).`);
|
||||||
|
|
||||||
|
// Keep only the best version if enabled
|
||||||
|
if (this.config.processingOptions.keepOnlyBestVersion) {
|
||||||
|
// Determine the best version among all (including the new one)
|
||||||
|
const allVersions = [...duplicates, { item, quality: this.getQualityRank(item) }];
|
||||||
|
const bestVersion = this.findBestQualityVersion(allVersions);
|
||||||
|
|
||||||
|
// If the new version is the best, remove all others and continue
|
||||||
|
if (bestVersion.item === item) {
|
||||||
|
console.log(`New version is the best quality. Removing duplicates.`);
|
||||||
|
for (const duplicate of duplicates) {
|
||||||
|
await this.removeExistingVersion(duplicate);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the new version is not the best, skip processing
|
||||||
|
console.log(`A better version already exists. Skipping...`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create target directory
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
// For each file in the torrent
|
||||||
|
for (const file of item.files) {
|
||||||
|
// Handle remote path mapping if applicable
|
||||||
|
let sourcePath;
|
||||||
|
if (this.config.remoteConfig.isRemote) {
|
||||||
|
// Map the remote download dir to local path
|
||||||
|
const localDownloadDir = this.mapRemotePathToLocal(item.downloadDir);
|
||||||
|
sourcePath = path.join(localDownloadDir, file.name);
|
||||||
|
} else {
|
||||||
|
sourcePath = path.join(item.downloadDir, file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExt = path.extname(file.name).toLowerCase();
|
||||||
|
|
||||||
|
// Skip sample files if configured
|
||||||
|
if (this.config.processingOptions.ignoreSample &&
|
||||||
|
this.config.filePatterns.ignorePatterns.some(pattern =>
|
||||||
|
file.name.toLowerCase().includes(pattern))) {
|
||||||
|
console.log(`Skipping file (matches ignore pattern): ${file.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle archives if extraction is enabled
|
||||||
|
if (this.config.processingOptions.extractArchives &&
|
||||||
|
this.config.filePatterns.archiveExtensions.includes(fileExt)) {
|
||||||
|
console.log(`Extracting archive: ${sourcePath} to ${targetDir}`);
|
||||||
|
|
||||||
|
// Extract the archive
|
||||||
|
if (fileExt === '.zip') {
|
||||||
|
await this.extractZipArchive(sourcePath, targetDir);
|
||||||
|
} else if (fileExt === '.rar') {
|
||||||
|
await this.extractRarArchive(sourcePath, targetDir);
|
||||||
|
} else {
|
||||||
|
console.log(`Unsupported archive type: ${fileExt}`);
|
||||||
|
// Just copy the file as is
|
||||||
|
let destFileName = file.name;
|
||||||
|
if (this.config.processingOptions.renameFiles) {
|
||||||
|
destFileName = this.cleanFileName(file.name);
|
||||||
|
}
|
||||||
|
const destPath = path.join(targetDir, destFileName);
|
||||||
|
await fs.copyFile(sourcePath, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete archive after extraction if configured
|
||||||
|
if (this.config.processingOptions.deleteArchives) {
|
||||||
|
console.log(`Deleting archive after extraction: ${sourcePath}`);
|
||||||
|
try {
|
||||||
|
await fs.unlink(sourcePath);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error deleting archive: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Copy non-archive files
|
||||||
|
else {
|
||||||
|
let destFileName = file.name;
|
||||||
|
|
||||||
|
// Rename files if configured
|
||||||
|
if (this.config.processingOptions.renameFiles) {
|
||||||
|
destFileName = this.cleanFileName(file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subdirectories if needed
|
||||||
|
const subDir = path.dirname(file.name);
|
||||||
|
if (subDir !== '.') {
|
||||||
|
const targetSubDir = path.join(targetDir, subDir);
|
||||||
|
await fs.mkdir(targetSubDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const destPath = path.join(targetDir, destFileName);
|
||||||
|
console.log(`Copying file: ${sourcePath} to ${destPath}`);
|
||||||
|
|
||||||
|
// Copy the file
|
||||||
|
await fs.copyFile(sourcePath, destPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After successful processing, update the managed content database
|
||||||
|
await this.updateManagedContent(item, targetDir);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing files: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract ZIP archive
|
||||||
|
async extractZipArchive(archivePath, destination) {
|
||||||
|
try {
|
||||||
|
const zip = new AdmZip(archivePath);
|
||||||
|
zip.extractAllTo(destination, true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to extract ZIP archive: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract RAR archive
|
||||||
|
async extractRarArchive(archivePath, destination) {
|
||||||
|
try {
|
||||||
|
// Check if unrar is available
|
||||||
|
await exec('unrar --version');
|
||||||
|
|
||||||
|
// Use unrar to extract the archive
|
||||||
|
const command = `unrar x -o+ "${archivePath}" "${destination}"`;
|
||||||
|
await exec(command);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to extract RAR archive: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load managed content database
|
||||||
|
loadManagedContentDatabase() {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
// Browser environment
|
||||||
|
const savedData = localStorage.getItem('managed-content-database');
|
||||||
|
return savedData ? JSON.parse(savedData) : [];
|
||||||
|
} else {
|
||||||
|
// Node.js environment
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const dbPath = path.join(__dirname, 'managed-content-database.json');
|
||||||
|
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
const data = fs.readFileSync(dbPath, 'utf8');
|
||||||
|
return JSON.parse(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading database file:', err);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading managed content database:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save managed content database
|
||||||
|
saveManagedContentDatabase(database) {
|
||||||
|
try {
|
||||||
|
if (typeof window !== 'undefined' && window.localStorage) {
|
||||||
|
// Browser environment
|
||||||
|
localStorage.setItem('managed-content-database', JSON.stringify(database));
|
||||||
|
} else {
|
||||||
|
// Node.js environment
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const dbPath = path.join(__dirname, 'managed-content-database.json');
|
||||||
|
fs.writeFileSync(dbPath, JSON.stringify(database, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving managed content database:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update managed content database with new item
|
||||||
|
async updateManagedContent(item, targetDir) {
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
|
||||||
|
// Extract media info
|
||||||
|
const mediaInfo = this.extractMediaInfo(item.name);
|
||||||
|
|
||||||
|
// Calculate quality rank
|
||||||
|
const qualityRank = this.getQualityRank(item);
|
||||||
|
|
||||||
|
// Check if entry already exists
|
||||||
|
const existingIndex = database.findIndex(entry =>
|
||||||
|
entry.title.toLowerCase() === mediaInfo.title.toLowerCase() &&
|
||||||
|
(!mediaInfo.year || !entry.year || entry.year === mediaInfo.year)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create new entry
|
||||||
|
const newEntry = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
title: mediaInfo.title,
|
||||||
|
year: mediaInfo.year,
|
||||||
|
category: item.category,
|
||||||
|
quality: this.detectQuality(item.name),
|
||||||
|
qualityRank,
|
||||||
|
path: targetDir,
|
||||||
|
originalName: item.name,
|
||||||
|
dateAdded: new Date().toISOString(),
|
||||||
|
torrentId: item.id
|
||||||
|
};
|
||||||
|
|
||||||
|
// If entry exists, update or add to history
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
// If new version has better quality
|
||||||
|
if (qualityRank > database[existingIndex].qualityRank) {
|
||||||
|
// Save the old version to history
|
||||||
|
if (!database[existingIndex].history) {
|
||||||
|
database[existingIndex].history = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
database[existingIndex].history.push({
|
||||||
|
quality: database[existingIndex].quality,
|
||||||
|
qualityRank: database[existingIndex].qualityRank,
|
||||||
|
path: database[existingIndex].path,
|
||||||
|
originalName: database[existingIndex].originalName,
|
||||||
|
dateAdded: database[existingIndex].dateAdded
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update with new version
|
||||||
|
database[existingIndex].quality = newEntry.quality;
|
||||||
|
database[existingIndex].qualityRank = newEntry.qualityRank;
|
||||||
|
database[existingIndex].path = newEntry.path;
|
||||||
|
database[existingIndex].originalName = newEntry.originalName;
|
||||||
|
database[existingIndex].dateUpdated = new Date().toISOString();
|
||||||
|
database[existingIndex].torrentId = newEntry.torrentId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add new entry
|
||||||
|
database.push(newEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated database
|
||||||
|
this.saveManagedContentDatabase(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing version of the same content
|
||||||
|
async findExistingVersion(item) {
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
const mediaInfo = this.extractMediaInfo(item.name);
|
||||||
|
|
||||||
|
// Find matching content
|
||||||
|
const match = database.find(entry =>
|
||||||
|
entry.title.toLowerCase() === mediaInfo.title.toLowerCase() &&
|
||||||
|
(!mediaInfo.year || !entry.year || entry.year === mediaInfo.year)
|
||||||
|
);
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find duplicates of the same content
|
||||||
|
async findDuplicates(item) {
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
const mediaInfo = this.extractMediaInfo(item.name);
|
||||||
|
|
||||||
|
// Find all matching content
|
||||||
|
const matches = database.filter(entry =>
|
||||||
|
entry.title.toLowerCase() === mediaInfo.title.toLowerCase() &&
|
||||||
|
(!mediaInfo.year || !entry.year || entry.year === mediaInfo.year)
|
||||||
|
);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an existing version
|
||||||
|
async removeExistingVersion(version) {
|
||||||
|
console.log(`Removing existing version: ${version.path}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if directory exists
|
||||||
|
const stats = await fs.stat(version.path);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
// Remove directory and all contents
|
||||||
|
await this.removeDirectory(version.path);
|
||||||
|
} else {
|
||||||
|
// Just remove the file
|
||||||
|
await fs.unlink(version.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from managed content database
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
const updatedDatabase = database.filter(entry => entry.id !== version.id);
|
||||||
|
this.saveManagedContentDatabase(updatedDatabase);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing existing version: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a directory recursively
|
||||||
|
async removeDirectory(dirPath) {
|
||||||
|
try {
|
||||||
|
const items = await fs.readdir(dirPath);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dirPath, item);
|
||||||
|
const stats = await fs.stat(itemPath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await this.removeDirectory(itemPath);
|
||||||
|
} else {
|
||||||
|
await fs.unlink(itemPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rmdir(dirPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing directory: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a new version is a quality upgrade
|
||||||
|
async isQualityUpgrade(newItem, existingVersion) {
|
||||||
|
const newQuality = this.getQualityRank(newItem);
|
||||||
|
const existingQuality = existingVersion.qualityRank || 0;
|
||||||
|
|
||||||
|
return newQuality > existingQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best quality version among multiple versions
|
||||||
|
findBestQualityVersion(versions) {
|
||||||
|
return versions.reduce((best, current) => {
|
||||||
|
return (current.qualityRank > best.qualityRank) ? current : best;
|
||||||
|
}, versions[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate quality rank for an item
|
||||||
|
getQualityRank(item) {
|
||||||
|
const quality = this.detectQuality(item.name);
|
||||||
|
|
||||||
|
if (!quality) return 0;
|
||||||
|
|
||||||
|
const qualityStr = quality.toLowerCase();
|
||||||
|
|
||||||
|
if (qualityStr.includes('2160p') || qualityStr.includes('4k') || qualityStr.includes('uhd')) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('1080p') || qualityStr.includes('fullhd') ||
|
||||||
|
(qualityStr.includes('bluray') && !qualityStr.includes('720p'))) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('720p') || qualityStr.includes('hd')) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('dvdrip') || qualityStr.includes('sdbd')) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('webrip') || qualityStr.includes('webdl') || qualityStr.includes('web-dl')) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low quality or unknown
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect quality from item name
|
||||||
|
detectQuality(name) {
|
||||||
|
const lowerName = name.toLowerCase();
|
||||||
|
|
||||||
|
// Quality patterns
|
||||||
|
const qualityPatterns = [
|
||||||
|
{ regex: /\b(2160p|uhd|4k)\b/i, quality: '2160p/4K' },
|
||||||
|
{ regex: /\b(1080p|fullhd|fhd)\b/i, quality: '1080p' },
|
||||||
|
{ regex: /\b(720p|hd)\b/i, quality: '720p' },
|
||||||
|
{ regex: /\b(bluray|bdremux|bdrip)\b/i, quality: 'BluRay' },
|
||||||
|
{ regex: /\b(webdl|web-dl|webrip)\b/i, quality: 'WebDL' },
|
||||||
|
{ regex: /\b(dvdrip|dvd-rip)\b/i, quality: 'DVDRip' },
|
||||||
|
{ regex: /\b(hdtv)\b/i, quality: 'HDTV' },
|
||||||
|
{ regex: /\b(hdtc|hd-tc)\b/i, quality: 'HDTC' },
|
||||||
|
{ regex: /\b(hdts|hd-ts|hdcam)\b/i, quality: 'HDTS' },
|
||||||
|
{ regex: /\b(cam|camrip)\b/i, quality: 'CAM' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of qualityPatterns) {
|
||||||
|
if (pattern.regex.test(lowerName)) {
|
||||||
|
return pattern.quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract media info from name
|
||||||
|
extractMediaInfo(name) {
|
||||||
|
// Year pattern (e.g., "2023" or "(2023)" or "[2023]")
|
||||||
|
const yearPattern = /[\[\(\s.]+(19|20)\d{2}[\]\)\s.]+/;
|
||||||
|
const yearMatch = name.match(yearPattern);
|
||||||
|
const year = yearMatch ? yearMatch[0].replace(/[\[\]\(\)\s.]+/g, '') : null;
|
||||||
|
|
||||||
|
// Clean up title to get movie name
|
||||||
|
let cleanTitle = yearMatch ? name.split(yearPattern)[0].trim() : name;
|
||||||
|
|
||||||
|
// Remove quality indicators, scene tags, etc.
|
||||||
|
const cleanupPatterns = [
|
||||||
|
/\b(720p|1080p|2160p|4K|UHD|HDTV|WEB-DL|WEBRip|BRRip|BluRay|DVDRip)\b/gi,
|
||||||
|
/\b(HEVC|AVC|x264|x265|H\.?264|H\.?265|XVID|DIVX)\b/gi,
|
||||||
|
/\b(AAC|MP3|AC3|DTS|FLAC|OPUS)\b/gi,
|
||||||
|
/\b(AMZN|DSNP|HULU|HMAX|NF|PCOK)\b/gi,
|
||||||
|
/\b(EXTENDED|Directors\.?Cut|UNRATED|REMASTERED|PROPER|REMUX)\b/gi,
|
||||||
|
/\b(IMAX)\b/gi,
|
||||||
|
/[-\.][A-Za-z0-9]+$/
|
||||||
|
];
|
||||||
|
|
||||||
|
cleanupPatterns.forEach(pattern => {
|
||||||
|
cleanTitle = cleanTitle.replace(pattern, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final cleanup
|
||||||
|
cleanTitle = cleanTitle
|
||||||
|
.replace(/[._-]/g, ' ')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: cleanTitle,
|
||||||
|
year: year
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up file name for nicer presentation
|
||||||
|
cleanFileName(fileName) {
|
||||||
|
// Remove common scene tags
|
||||||
|
let cleaned = fileName;
|
||||||
|
|
||||||
|
// Remove stuff in brackets
|
||||||
|
cleaned = cleaned.replace(/\[[^\]]+\]/g, '');
|
||||||
|
|
||||||
|
// Remove scene group names after dash
|
||||||
|
cleaned = cleaned.replace(/-[A-Za-z0-9]+$/, '');
|
||||||
|
|
||||||
|
// Replace dots with spaces
|
||||||
|
cleaned = cleaned.replace(/\./g, ' ');
|
||||||
|
|
||||||
|
// Remove multiple spaces
|
||||||
|
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
|
||||||
|
// Keep the file extension
|
||||||
|
const lastDotIndex = fileName.lastIndexOf('.');
|
||||||
|
if (lastDotIndex !== -1) {
|
||||||
|
const extension = fileName.substring(lastDotIndex);
|
||||||
|
cleaned = cleaned + extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update configuration
|
||||||
|
updateConfig(newConfig) {
|
||||||
|
this.config = {
|
||||||
|
...this.config,
|
||||||
|
...newConfig,
|
||||||
|
transmissionConfig: {
|
||||||
|
...this.config.transmissionConfig,
|
||||||
|
...newConfig.transmissionConfig
|
||||||
|
},
|
||||||
|
remoteConfig: {
|
||||||
|
...this.config.remoteConfig,
|
||||||
|
...newConfig.remoteConfig
|
||||||
|
},
|
||||||
|
destinationPaths: {
|
||||||
|
...this.config.destinationPaths,
|
||||||
|
...newConfig.destinationPaths
|
||||||
|
},
|
||||||
|
seedingRequirements: {
|
||||||
|
...this.config.seedingRequirements,
|
||||||
|
...newConfig.seedingRequirements
|
||||||
|
},
|
||||||
|
processingOptions: {
|
||||||
|
...this.config.processingOptions,
|
||||||
|
...newConfig.processingOptions
|
||||||
|
},
|
||||||
|
filePatterns: {
|
||||||
|
...this.config.filePatterns,
|
||||||
|
...newConfig.filePatterns
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reinitialize the Transmission client
|
||||||
|
this.transmissionClient = new Transmission(this.config.transmissionConfig);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get library statistics
|
||||||
|
getLibraryStats() {
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
totalItems: database.length,
|
||||||
|
byCategory: {},
|
||||||
|
byQuality: {
|
||||||
|
'Unknown': 0,
|
||||||
|
'CAM/TS': 0,
|
||||||
|
'SD': 0,
|
||||||
|
'720p': 0,
|
||||||
|
'1080p': 0,
|
||||||
|
'4K/UHD': 0
|
||||||
|
},
|
||||||
|
totalUpgrades: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each item
|
||||||
|
database.forEach(item => {
|
||||||
|
// Count by category
|
||||||
|
stats.byCategory[item.category] = (stats.byCategory[item.category] || 0) + 1;
|
||||||
|
|
||||||
|
// Count by quality rank
|
||||||
|
if (item.qualityRank >= 5) {
|
||||||
|
stats.byQuality['4K/UHD']++;
|
||||||
|
} else if (item.qualityRank >= 4) {
|
||||||
|
stats.byQuality['1080p']++;
|
||||||
|
} else if (item.qualityRank >= 3) {
|
||||||
|
stats.byQuality['720p']++;
|
||||||
|
} else if (item.qualityRank >= 1) {
|
||||||
|
stats.byQuality['SD']++;
|
||||||
|
} else if (item.qualityRank === 0 && item.quality && item.quality.toLowerCase().includes('cam')) {
|
||||||
|
stats.byQuality['CAM/TS']++;
|
||||||
|
} else {
|
||||||
|
stats.byQuality['Unknown']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count upgrades
|
||||||
|
if (item.history && item.history.length > 0) {
|
||||||
|
stats.totalUpgrades += item.history.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get entire media library
|
||||||
|
getLibrary() {
|
||||||
|
return this.loadManagedContentDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for media items
|
||||||
|
searchLibrary(query) {
|
||||||
|
if (!query) return this.getLibrary();
|
||||||
|
|
||||||
|
const database = this.loadManagedContentDatabase();
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
|
return database.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(lowerQuery) ||
|
||||||
|
(item.year && item.year.includes(lowerQuery)) ||
|
||||||
|
(item.category && item.category.toLowerCase().includes(lowerQuery)) ||
|
||||||
|
(item.quality && item.quality.toLowerCase().includes(lowerQuery))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for Node.js or browser
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = PostProcessor;
|
||||||
|
} else if (typeof window !== 'undefined') {
|
||||||
|
window.PostProcessor = PostProcessor;
|
||||||
|
}
|
586
rss-implementation.js
Normal file
586
rss-implementation.js
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
// rssFeedManager.js - Manages RSS feeds and parsing
|
||||||
|
const xml2js = require('xml2js');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
class RssFeedManager {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.config = {
|
||||||
|
feeds: [],
|
||||||
|
updateIntervalMinutes: 60,
|
||||||
|
maxItemsPerFeed: 100,
|
||||||
|
autoDownload: false,
|
||||||
|
downloadFilters: {},
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
|
||||||
|
this.parser = new xml2js.Parser({
|
||||||
|
explicitArray: false,
|
||||||
|
normalize: true,
|
||||||
|
normalizeTags: true
|
||||||
|
});
|
||||||
|
|
||||||
|
this.feedItems = [];
|
||||||
|
this.updateIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start RSS feed monitoring
|
||||||
|
start() {
|
||||||
|
if (this.updateIntervalId) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateIntervalId = setInterval(() => {
|
||||||
|
this.updateAllFeeds();
|
||||||
|
}, this.config.updateIntervalMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
console.log('RSS feed manager started');
|
||||||
|
this.updateAllFeeds(); // Do an initial update
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop RSS feed monitoring
|
||||||
|
stop() {
|
||||||
|
if (this.updateIntervalId) {
|
||||||
|
clearInterval(this.updateIntervalId);
|
||||||
|
this.updateIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('RSS feed manager stopped');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all feeds
|
||||||
|
async updateAllFeeds() {
|
||||||
|
console.log('Updating all RSS feeds');
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: this.config.feeds.length,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
newItems: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const feed of this.config.feeds) {
|
||||||
|
try {
|
||||||
|
const newItems = await this.updateFeed(feed);
|
||||||
|
results.success++;
|
||||||
|
results.newItems += newItems;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating feed ${feed.name || feed.url}:`, error);
|
||||||
|
results.failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Feed update complete: ${results.success}/${results.total} feeds updated, ${results.newItems} new items`);
|
||||||
|
|
||||||
|
// Save updated feed items to database
|
||||||
|
await this.saveItems();
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a single feed
|
||||||
|
async updateFeed(feed) {
|
||||||
|
console.log(`Updating feed: ${feed.name || feed.url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(feed.url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch RSS feed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const result = await this.parseXml(xml);
|
||||||
|
|
||||||
|
// Extract and format feed items
|
||||||
|
const items = this.extractItems(result, feed);
|
||||||
|
|
||||||
|
// Count new items (not already in our database)
|
||||||
|
let newItemCount = 0;
|
||||||
|
|
||||||
|
// Process each item
|
||||||
|
for (const item of items) {
|
||||||
|
// Generate a unique ID for the item
|
||||||
|
const itemId = this.generateItemId(item);
|
||||||
|
|
||||||
|
// Check if item already exists
|
||||||
|
const existingItem = this.feedItems.find(i => i.id === itemId);
|
||||||
|
|
||||||
|
if (!existingItem) {
|
||||||
|
// Add feed info to item
|
||||||
|
item.feedId = feed.id;
|
||||||
|
item.feedName = feed.name;
|
||||||
|
item.id = itemId;
|
||||||
|
item.firstSeen = new Date().toISOString();
|
||||||
|
item.downloaded = false;
|
||||||
|
|
||||||
|
// Add to items array
|
||||||
|
this.feedItems.push(item);
|
||||||
|
newItemCount++;
|
||||||
|
|
||||||
|
// Auto-download if enabled and item matches filter
|
||||||
|
if (feed.autoDownload && this.matchesFilter(item, feed.filters)) {
|
||||||
|
this.downloadItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Feed updated: ${feed.name || feed.url}, ${newItemCount} new items`);
|
||||||
|
return newItemCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating feed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse XML data
|
||||||
|
async parseXml(xml) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.parser.parseString(xml, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract items from parsed feed
|
||||||
|
extractItems(parsedFeed, feedConfig) {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
// Handle different feed formats
|
||||||
|
let feedItems = [];
|
||||||
|
|
||||||
|
if (parsedFeed.rss && parsedFeed.rss.channel) {
|
||||||
|
// Standard RSS
|
||||||
|
feedItems = parsedFeed.rss.channel.item || [];
|
||||||
|
if (!Array.isArray(feedItems)) {
|
||||||
|
feedItems = [feedItems];
|
||||||
|
}
|
||||||
|
} else if (parsedFeed.feed && parsedFeed.feed.entry) {
|
||||||
|
// Atom format
|
||||||
|
feedItems = parsedFeed.feed.entry || [];
|
||||||
|
if (!Array.isArray(feedItems)) {
|
||||||
|
feedItems = [feedItems];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each item
|
||||||
|
for (const item of feedItems) {
|
||||||
|
const processedItem = this.processItem(item, feedConfig);
|
||||||
|
if (processedItem) {
|
||||||
|
items.push(processedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process a single feed item
|
||||||
|
processItem(item, feedConfig) {
|
||||||
|
// Extract basic fields
|
||||||
|
const processed = {
|
||||||
|
title: item.title || '',
|
||||||
|
description: item.description || item.summary || '',
|
||||||
|
pubDate: item.pubdate || item.published || item.date || new Date().toISOString(),
|
||||||
|
size: 0,
|
||||||
|
seeders: 0,
|
||||||
|
leechers: 0,
|
||||||
|
quality: 'Unknown',
|
||||||
|
category: 'Unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set download link
|
||||||
|
if (item.link) {
|
||||||
|
if (typeof item.link === 'string') {
|
||||||
|
processed.link = item.link;
|
||||||
|
} else if (item.link.$ && item.link.$.href) {
|
||||||
|
processed.link = item.link.$.href;
|
||||||
|
} else if (Array.isArray(item.link) && item.link.length > 0) {
|
||||||
|
const magnetLink = item.link.find(link =>
|
||||||
|
(typeof link === 'string' && link.startsWith('magnet:')) ||
|
||||||
|
(link.$ && link.$.href && link.$.href.startsWith('magnet:'))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (magnetLink) {
|
||||||
|
processed.link = typeof magnetLink === 'string' ? magnetLink : magnetLink.$.href;
|
||||||
|
} else {
|
||||||
|
processed.link = typeof item.link[0] === 'string' ? item.link[0] : (item.link[0].$ ? item.link[0].$.href : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item.enclosure && item.enclosure.url) {
|
||||||
|
processed.link = item.enclosure.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip item if no link found
|
||||||
|
if (!processed.link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract size information
|
||||||
|
if (item.size) {
|
||||||
|
processed.size = parseInt(item.size, 10) || 0;
|
||||||
|
} else if (item.enclosure && item.enclosure.length) {
|
||||||
|
processed.size = parseInt(item.enclosure.length, 10) || 0;
|
||||||
|
} else if (item['torrent:contentlength']) {
|
||||||
|
processed.size = parseInt(item['torrent:contentlength'], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert size to MB if it's in bytes
|
||||||
|
if (processed.size > 1000000) {
|
||||||
|
processed.size = Math.round(processed.size / 1000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract seeders/leechers
|
||||||
|
if (item.seeders || item['torrent:seeds']) {
|
||||||
|
processed.seeders = parseInt(item.seeders || item['torrent:seeds'], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.leechers || item['torrent:peers']) {
|
||||||
|
processed.leechers = parseInt(item.leechers || item['torrent:peers'], 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to determine category
|
||||||
|
if (item.category) {
|
||||||
|
if (Array.isArray(item.category)) {
|
||||||
|
processed.category = item.category[0] || 'Unknown';
|
||||||
|
} else {
|
||||||
|
processed.category = item.category;
|
||||||
|
}
|
||||||
|
} else if (feedConfig.defaultCategory) {
|
||||||
|
processed.category = feedConfig.defaultCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect quality from title
|
||||||
|
processed.quality = this.detectQuality(processed.title);
|
||||||
|
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect quality from title
|
||||||
|
detectQuality(title) {
|
||||||
|
const lowerTitle = title.toLowerCase();
|
||||||
|
|
||||||
|
// Quality patterns
|
||||||
|
const qualityPatterns = [
|
||||||
|
{ regex: /\b(2160p|uhd|4k)\b/i, quality: '2160p/4K' },
|
||||||
|
{ regex: /\b(1080p|fullhd|fhd)\b/i, quality: '1080p' },
|
||||||
|
{ regex: /\b(720p|hd)\b/i, quality: '720p' },
|
||||||
|
{ regex: /\b(bluray|bdremux|bdrip)\b/i, quality: 'BluRay' },
|
||||||
|
{ regex: /\b(webdl|web-dl|webrip)\b/i, quality: 'WebDL' },
|
||||||
|
{ regex: /\b(dvdrip|dvd-rip)\b/i, quality: 'DVDRip' },
|
||||||
|
{ regex: /\b(hdtv)\b/i, quality: 'HDTV' },
|
||||||
|
{ regex: /\b(hdtc|hd-tc)\b/i, quality: 'HDTC' },
|
||||||
|
{ regex: /\b(hdts|hd-ts|hdcam)\b/i, quality: 'HDTS' },
|
||||||
|
{ regex: /\b(cam|camrip)\b/i, quality: 'CAM' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of qualityPatterns) {
|
||||||
|
if (pattern.regex.test(lowerTitle)) {
|
||||||
|
return pattern.quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique ID for an item
|
||||||
|
generateItemId(item) {
|
||||||
|
// Use hash of title + link as ID
|
||||||
|
const hash = require('crypto').createHash('md5');
|
||||||
|
hash.update(item.title + item.link);
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an item matches a filter
|
||||||
|
matchesFilter(item, filters) {
|
||||||
|
if (!filters) return true;
|
||||||
|
|
||||||
|
// Name contains filter
|
||||||
|
if (filters.nameContains && !item.title.toLowerCase().includes(filters.nameContains.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size filters
|
||||||
|
if (filters.minSize && item.size < filters.minSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.maxSize && filters.maxSize > 0 && item.size > filters.maxSize) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (filters.includeCategory && !item.category.toLowerCase().includes(filters.includeCategory.toLowerCase())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude terms
|
||||||
|
if (filters.excludeTerms) {
|
||||||
|
const terms = filters.excludeTerms.toLowerCase().split(',').map(t => t.trim());
|
||||||
|
if (terms.some(term => item.title.toLowerCase().includes(term))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwanted formats
|
||||||
|
if (filters.unwantedFormats) {
|
||||||
|
const formats = filters.unwantedFormats.toLowerCase().split(',').map(f => f.trim());
|
||||||
|
if (formats.some(format => item.title.toLowerCase().includes(format) ||
|
||||||
|
(item.quality && item.quality.toLowerCase() === format.toLowerCase()))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum quality
|
||||||
|
if (filters.minimumQuality) {
|
||||||
|
const qualityRanks = {
|
||||||
|
'unknown': 0,
|
||||||
|
'cam': 0,
|
||||||
|
'ts': 0,
|
||||||
|
'hdts': 0,
|
||||||
|
'tc': 0,
|
||||||
|
'hdtc': 0,
|
||||||
|
'dvdscr': 1,
|
||||||
|
'webrip': 1,
|
||||||
|
'webdl': 1,
|
||||||
|
'dvdrip': 2,
|
||||||
|
'hdtv': 2,
|
||||||
|
'720p': 3,
|
||||||
|
'hd': 3,
|
||||||
|
'1080p': 4,
|
||||||
|
'fullhd': 4,
|
||||||
|
'bluray': 4,
|
||||||
|
'2160p': 5,
|
||||||
|
'4k': 5,
|
||||||
|
'uhd': 5
|
||||||
|
};
|
||||||
|
|
||||||
|
const minQualityRank = qualityRanks[filters.minimumQuality.toLowerCase()] || 0;
|
||||||
|
const itemQualityRank = this.getQualityRank(item.quality);
|
||||||
|
|
||||||
|
if (itemQualityRank < minQualityRank) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimum seeders
|
||||||
|
if (filters.minimumSeeders && item.seeders < filters.minimumSeeders) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passed all filters
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate quality rank for an item
|
||||||
|
getQualityRank(quality) {
|
||||||
|
if (!quality) return 0;
|
||||||
|
|
||||||
|
const qualityStr = quality.toLowerCase();
|
||||||
|
|
||||||
|
if (qualityStr.includes('2160p') || qualityStr.includes('4k') || qualityStr.includes('uhd')) {
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('1080p') || qualityStr.includes('fullhd') ||
|
||||||
|
(qualityStr.includes('bluray') && !qualityStr.includes('720p'))) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('720p') || qualityStr.includes('hd')) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('dvdrip') || qualityStr.includes('sdbd')) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (qualityStr.includes('webrip') || qualityStr.includes('webdl') || qualityStr.includes('web-dl')) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low quality or unknown
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download an item (add to Transmission)
|
||||||
|
async downloadItem(item, transmissionClient) {
|
||||||
|
if (!transmissionClient) {
|
||||||
|
console.error('No Transmission client provided for download');
|
||||||
|
return { success: false, message: 'No Transmission client provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add the torrent to Transmission
|
||||||
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
transmissionClient.addUrl(item.link, (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark the item as downloaded
|
||||||
|
const existingItem = this.feedItems.find(i => i.id === item.id);
|
||||||
|
if (existingItem) {
|
||||||
|
existingItem.downloaded = true;
|
||||||
|
existingItem.downloadDate = new Date().toISOString();
|
||||||
|
existingItem.transmissionId = result.id || result.hashString || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated items
|
||||||
|
await this.saveItems();
|
||||||
|
|
||||||
|
return { success: true, result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error downloading item: ${error.message}`);
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save items to persistent storage
|
||||||
|
async saveItems() {
|
||||||
|
try {
|
||||||
|
// In Node.js environment
|
||||||
|
const dbPath = path.join(__dirname, 'rss-items.json');
|
||||||
|
await fs.writeFile(dbPath, JSON.stringify(this.feedItems, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving RSS items:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load items from persistent storage
|
||||||
|
async loadItems() {
|
||||||
|
try {
|
||||||
|
// In Node.js environment
|
||||||
|
const dbPath = path.join(__dirname, 'rss-items.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(dbPath, 'utf8');
|
||||||
|
this.feedItems = JSON.parse(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// File doesn't exist, use empty array
|
||||||
|
this.feedItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RSS items:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all feed items
|
||||||
|
getAllItems() {
|
||||||
|
return [...this.feedItems];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get undownloaded feed items
|
||||||
|
getUndownloadedItems() {
|
||||||
|
return this.feedItems.filter(item => !item.downloaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter items based on criteria
|
||||||
|
filterItems(filters) {
|
||||||
|
return this.feedItems.filter(item => this.matchesFilter(item, filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new feed
|
||||||
|
addFeed(feed) {
|
||||||
|
// Generate an ID if not provided
|
||||||
|
if (!feed.id) {
|
||||||
|
feed.id = `feed-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the feed to config
|
||||||
|
this.config.feeds.push(feed);
|
||||||
|
|
||||||
|
// Update the feed immediately
|
||||||
|
this.updateFeed(feed);
|
||||||
|
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a feed
|
||||||
|
removeFeed(feedId) {
|
||||||
|
const index = this.config.feeds.findIndex(f => f.id === feedId);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.config.feeds.splice(index, 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update feed configuration
|
||||||
|
updateFeedConfig(feedId, updates) {
|
||||||
|
const feed = this.config.feeds.find(f => f.id === feedId);
|
||||||
|
if (feed) {
|
||||||
|
Object.assign(feed, updates);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all feeds
|
||||||
|
getAllFeeds() {
|
||||||
|
return [...this.config.feeds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save configuration
|
||||||
|
async saveConfig() {
|
||||||
|
try {
|
||||||
|
// In Node.js environment
|
||||||
|
const configPath = path.join(__dirname, 'rss-config.json');
|
||||||
|
await fs.writeFile(configPath, JSON.stringify(this.config, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving RSS config:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
async loadConfig() {
|
||||||
|
try {
|
||||||
|
// In Node.js environment
|
||||||
|
const configPath = path.join(__dirname, 'rss-config.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(configPath, 'utf8');
|
||||||
|
const loadedConfig = JSON.parse(data);
|
||||||
|
this.config = { ...this.config, ...loadedConfig };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
// Config file doesn't exist, use defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RSS config:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for Node.js or browser
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = RssFeedManager;
|
||||||
|
} else if (typeof window !== 'undefined') {
|
||||||
|
window.RssFeedManager = RssFeedManager;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user