- Fixed update detection in install scripts - Added Git availability checks to update system - Improved error handling for update endpoint - Added detailed Git requirements to README - Added troubleshooting section for update issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1942 lines
55 KiB
Bash
Executable File
1942 lines
55 KiB
Bash
Executable File
#!/bin/bash
|
|
# File creator module for Transmission RSS Manager Installation
|
|
|
|
function create_directories() {
|
|
echo -e "${YELLOW}Creating directories...${NC}"
|
|
|
|
# Create main installation directory
|
|
mkdir -p "$INSTALL_DIR"
|
|
log "INFO" "Created installation directory: $INSTALL_DIR"
|
|
|
|
# Create modules directory
|
|
mkdir -p "$INSTALL_DIR/modules"
|
|
log "INFO" "Created modules directory: $INSTALL_DIR/modules"
|
|
|
|
# Create data directory
|
|
mkdir -p "$INSTALL_DIR/data"
|
|
log "INFO" "Created data directory: $INSTALL_DIR/data"
|
|
|
|
# Create public directory structure
|
|
mkdir -p "$INSTALL_DIR/public/css"
|
|
mkdir -p "$INSTALL_DIR/public/js"
|
|
log "INFO" "Created public directories"
|
|
|
|
# Create config directory
|
|
mkdir -p "$CONFIG_DIR"
|
|
log "INFO" "Created config directory: $CONFIG_DIR"
|
|
|
|
# Create logs directory
|
|
mkdir -p "$INSTALL_DIR/logs"
|
|
log "INFO" "Created logs directory: $INSTALL_DIR/logs"
|
|
|
|
# Set permissions
|
|
chown -R "$USER:$USER" "$INSTALL_DIR"
|
|
chmod -R 755 "$INSTALL_DIR"
|
|
log "INFO" "Set permissions for installation directories"
|
|
}
|
|
|
|
function create_config_files() {
|
|
echo -e "${YELLOW}Creating configuration files...${NC}"
|
|
|
|
# Check if we're updating an existing installation
|
|
if [ "$IS_UPDATE" = "true" ] && [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
|
|
log "INFO" "Preserving existing configuration file: $CONFIG_FILE"
|
|
|
|
# Get version from package.json dynamically
|
|
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "2.0.9")
|
|
|
|
# Update only the version number in the config file
|
|
if [ -f "$CONFIG_FILE" ]; then
|
|
# Save a backup of the config file
|
|
BACKUP_FILE="${CONFIG_FILE}.bak.$(date +%Y%m%d%H%M%S)"
|
|
cp "$CONFIG_FILE" "$BACKUP_FILE"
|
|
log "INFO" "Backed up config to: $BACKUP_FILE"
|
|
|
|
# Update version field only
|
|
if command -v jq &> /dev/null; then
|
|
# If jq is available, use it to safely update the version
|
|
jq ".version = \"$VERSION\"" "$CONFIG_FILE" > "${CONFIG_FILE}.new"
|
|
if [ $? -eq 0 ]; then
|
|
mv "${CONFIG_FILE}.new" "$CONFIG_FILE"
|
|
log "INFO" "Updated config version to: $VERSION"
|
|
else
|
|
log "WARN" "Failed to update version with jq, keeping original config unchanged"
|
|
rm -f "${CONFIG_FILE}.new"
|
|
fi
|
|
else
|
|
log "INFO" "jq not available, keeping original config file"
|
|
fi
|
|
fi
|
|
|
|
# Return early - no need to create a new config
|
|
return 0
|
|
fi
|
|
|
|
# For fresh installations, create initial config.json
|
|
echo "Creating new config.json..."
|
|
mkdir -p "$CONFIG_DIR"
|
|
|
|
# Get version from package.json dynamically
|
|
VERSION=$(grep -oP '"version": "\K[^"]+' "${SCRIPT_DIR}/package.json" 2>/dev/null || echo "2.0.9")
|
|
|
|
cat > $CONFIG_DIR/config.json << EOF
|
|
{
|
|
"version": "$VERSION",
|
|
"installPath": "$INSTALL_DIR",
|
|
"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",
|
|
"magazines": "$MEDIA_DIR/magazines",
|
|
"software": "$MEDIA_DIR/software"
|
|
},
|
|
"seedingRequirements": {
|
|
"minRatio": 1.0,
|
|
"minTimeMinutes": 60,
|
|
"checkIntervalSeconds": 300
|
|
},
|
|
"processingOptions": {
|
|
"enableBookSorting": $ENABLE_BOOK_SORTING,
|
|
"extractArchives": true,
|
|
"deleteArchives": true,
|
|
"createCategoryFolders": true,
|
|
"ignoreSample": true,
|
|
"ignoreExtras": true,
|
|
"renameFiles": true,
|
|
"autoReplaceUpgrades": true,
|
|
"removeDuplicates": true,
|
|
"keepOnlyBestVersion": true
|
|
},
|
|
"rssFeeds": [],
|
|
"rssUpdateIntervalMinutes": 60,
|
|
"autoProcessing": false,
|
|
"securitySettings": {
|
|
"authEnabled": false,
|
|
"httpsEnabled": false,
|
|
"sslCertPath": "",
|
|
"sslKeyPath": "",
|
|
"users": []
|
|
},
|
|
"port": $PORT,
|
|
"logLevel": "info"
|
|
}
|
|
EOF
|
|
|
|
# Create package.json
|
|
echo "Creating package.json..."
|
|
cat > $INSTALL_DIR/package.json << EOF
|
|
{
|
|
"name": "transmission-rss-manager",
|
|
"version": "2.0.0",
|
|
"description": "Enhanced Transmission RSS Manager with post-processing capabilities",
|
|
"main": "server.js",
|
|
"scripts": {
|
|
"start": "node server.js"
|
|
},
|
|
"dependencies": {
|
|
"express": "^4.18.2",
|
|
"body-parser": "^1.20.2",
|
|
"transmission-promise": "^1.1.5",
|
|
"adm-zip": "^0.5.10",
|
|
"node-fetch": "^2.6.9",
|
|
"xml2js": "^0.5.0",
|
|
"cors": "^2.8.5",
|
|
"bcrypt": "^5.1.0",
|
|
"jsonwebtoken": "^9.0.0",
|
|
"morgan": "^1.10.0"
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# Create server.js
|
|
echo "Creating server.js..."
|
|
cp "${SCRIPT_DIR}/server.js" "$INSTALL_DIR/server.js" || {
|
|
# If the file doesn't exist in the script directory, create it from scratch
|
|
cat > $INSTALL_DIR/server.js << 'EOF'
|
|
// server.js - Main application server file
|
|
const express = require('express');
|
|
const bodyParser = require('body-parser');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const cors = require('cors');
|
|
const Transmission = require('transmission');
|
|
|
|
// Import custom modules
|
|
const PostProcessor = require('./modules/post-processor.js');
|
|
const RssFeedManager = require('./modules/rss-feed-manager.js');
|
|
const TransmissionClient = require('./modules/transmission-client.js');
|
|
|
|
// Initialize Express app
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Load configuration
|
|
let config = {
|
|
transmissionConfig: {
|
|
host: 'localhost',
|
|
port: 9091,
|
|
username: '',
|
|
password: '',
|
|
path: '/transmission/rpc'
|
|
},
|
|
remoteConfig: {
|
|
isRemote: false,
|
|
directoryMapping: {}
|
|
},
|
|
destinationPaths: {
|
|
movies: '/mnt/media/movies',
|
|
tvShows: '/mnt/media/tvshows',
|
|
music: '/mnt/media/music',
|
|
books: '/mnt/media/books',
|
|
magazines: '/mnt/media/magazines',
|
|
software: '/mnt/media/software'
|
|
},
|
|
seedingRequirements: {
|
|
minRatio: 1.0,
|
|
minTimeMinutes: 60,
|
|
checkIntervalSeconds: 300
|
|
},
|
|
processingOptions: {
|
|
enableBookSorting: true,
|
|
extractArchives: true,
|
|
deleteArchives: true,
|
|
createCategoryFolders: true,
|
|
ignoreSample: true,
|
|
ignoreExtras: true,
|
|
renameFiles: true,
|
|
autoReplaceUpgrades: true,
|
|
removeDuplicates: true,
|
|
keepOnlyBestVersion: true
|
|
},
|
|
rssFeeds: [],
|
|
rssUpdateIntervalMinutes: 60,
|
|
autoProcessing: false
|
|
};
|
|
|
|
// Service instances
|
|
let transmissionClient = null;
|
|
let postProcessor = null;
|
|
let rssFeedManager = null;
|
|
|
|
// Save config function
|
|
async function saveConfig() {
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(__dirname, 'config.json'),
|
|
JSON.stringify(config, null, 2),
|
|
'utf8'
|
|
);
|
|
console.log('Configuration saved');
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Error saving config:', err.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Load config function
|
|
async function loadConfig() {
|
|
try {
|
|
const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8');
|
|
const loadedConfig = JSON.parse(data);
|
|
config = { ...config, ...loadedConfig };
|
|
console.log('Configuration loaded');
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Error loading config, using defaults:', err.message);
|
|
// Save default config
|
|
await saveConfig();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Initialize Transmission client
|
|
function initTransmission() {
|
|
transmissionClient = new Transmission({
|
|
host: config.transmissionConfig.host,
|
|
port: config.transmissionConfig.port,
|
|
username: config.transmissionConfig.username,
|
|
password: config.transmissionConfig.password,
|
|
url: config.transmissionConfig.path
|
|
});
|
|
|
|
console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`);
|
|
return transmissionClient;
|
|
}
|
|
|
|
// Initialize post processor
|
|
function initPostProcessor() {
|
|
if (postProcessor) {
|
|
postProcessor.stop();
|
|
}
|
|
|
|
postProcessor = new PostProcessor({
|
|
transmissionConfig: config.transmissionConfig,
|
|
remoteConfig: config.remoteConfig,
|
|
destinationPaths: config.destinationPaths,
|
|
seedingRequirements: config.seedingRequirements,
|
|
processingOptions: config.processingOptions,
|
|
downloadDir: config.downloadDir
|
|
});
|
|
|
|
if (config.autoProcessing) {
|
|
postProcessor.start();
|
|
console.log('Post-processor started automatically');
|
|
} else {
|
|
console.log('Post-processor initialized (not auto-started)');
|
|
}
|
|
|
|
return postProcessor;
|
|
}
|
|
|
|
// Initialize RSS feed manager
|
|
function initRssFeedManager() {
|
|
if (rssFeedManager) {
|
|
rssFeedManager.stop();
|
|
}
|
|
|
|
rssFeedManager = new RssFeedManager({
|
|
feeds: config.rssFeeds,
|
|
updateIntervalMinutes: config.rssUpdateIntervalMinutes
|
|
});
|
|
|
|
rssFeedManager.loadItems()
|
|
.then(() => {
|
|
if (config.rssFeeds && config.rssFeeds.length > 0) {
|
|
rssFeedManager.start();
|
|
console.log('RSS feed manager started');
|
|
} else {
|
|
console.log('RSS feed manager initialized (no feeds configured)');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Error initializing RSS feed manager:', err);
|
|
});
|
|
|
|
return rssFeedManager;
|
|
}
|
|
|
|
// Enable CORS
|
|
app.use(cors());
|
|
|
|
// Parse JSON bodies
|
|
app.use(bodyParser.json({ limit: '50mb' }));
|
|
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
|
|
|
// API routes
|
|
//==============================
|
|
|
|
// Get the version from package.json
|
|
let appVersion;
|
|
try {
|
|
const packageJson = require('./package.json');
|
|
appVersion = packageJson.version;
|
|
} catch (err) {
|
|
console.warn('Could not read version from package.json, using default');
|
|
appVersion = '2.0.9'; // Default fallback version aligned with package.json
|
|
}
|
|
|
|
// Server status API
|
|
app.get('/api/status', (req, res) => {
|
|
res.json({
|
|
status: 'running',
|
|
version: appVersion,
|
|
transmissionConnected: !!transmissionClient,
|
|
postProcessorActive: postProcessor && postProcessor.processingIntervalId !== null,
|
|
rssFeedManagerActive: rssFeedManager && rssFeedManager.updateIntervalId !== null,
|
|
config: {
|
|
autoProcessing: config.autoProcessing,
|
|
rssEnabled: config.rssFeeds && config.rssFeeds.length > 0
|
|
}
|
|
});
|
|
});
|
|
|
|
// Get configuration
|
|
app.get('/api/config', (req, res) => {
|
|
// Don't send password in response
|
|
const safeConfig = { ...config };
|
|
if (safeConfig.transmissionConfig) {
|
|
safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' };
|
|
}
|
|
res.json(safeConfig);
|
|
});
|
|
|
|
// Update configuration
|
|
app.post('/api/config', async (req, res) => {
|
|
try {
|
|
// Create a deep copy of the old config
|
|
const oldConfig = JSON.parse(JSON.stringify(config));
|
|
|
|
// Update config object, preserving password if not provided
|
|
config = {
|
|
...config,
|
|
...req.body,
|
|
transmissionConfig: {
|
|
...config.transmissionConfig,
|
|
...req.body.transmissionConfig,
|
|
password: req.body.transmissionConfig?.password || config.transmissionConfig.password
|
|
},
|
|
remoteConfig: {
|
|
...config.remoteConfig,
|
|
...req.body.remoteConfig
|
|
},
|
|
destinationPaths: {
|
|
...config.destinationPaths,
|
|
...req.body.destinationPaths
|
|
},
|
|
seedingRequirements: {
|
|
...config.seedingRequirements,
|
|
...req.body.seedingRequirements
|
|
},
|
|
processingOptions: {
|
|
...config.processingOptions,
|
|
...req.body.processingOptions
|
|
}
|
|
};
|
|
|
|
// Save the updated config
|
|
await saveConfig();
|
|
|
|
// Check if key services need reinitialization
|
|
let changes = {
|
|
transmissionChanged: JSON.stringify(oldConfig.transmissionConfig) !== JSON.stringify(config.transmissionConfig),
|
|
postProcessorChanged:
|
|
JSON.stringify(oldConfig.remoteConfig) !== JSON.stringify(config.remoteConfig) ||
|
|
JSON.stringify(oldConfig.destinationPaths) !== JSON.stringify(config.destinationPaths) ||
|
|
JSON.stringify(oldConfig.seedingRequirements) !== JSON.stringify(config.seedingRequirements) ||
|
|
JSON.stringify(oldConfig.processingOptions) !== JSON.stringify(config.processingOptions),
|
|
rssFeedsChanged:
|
|
JSON.stringify(oldConfig.rssFeeds) !== JSON.stringify(config.rssFeeds) ||
|
|
oldConfig.rssUpdateIntervalMinutes !== config.rssUpdateIntervalMinutes,
|
|
autoProcessingChanged: oldConfig.autoProcessing !== config.autoProcessing
|
|
};
|
|
|
|
// Reinitialize services as needed
|
|
if (changes.transmissionChanged) {
|
|
initTransmission();
|
|
}
|
|
|
|
if (changes.postProcessorChanged || changes.transmissionChanged) {
|
|
initPostProcessor();
|
|
}
|
|
|
|
if (changes.rssFeedsChanged) {
|
|
initRssFeedManager();
|
|
}
|
|
|
|
if (changes.autoProcessingChanged) {
|
|
if (config.autoProcessing && postProcessor) {
|
|
postProcessor.start();
|
|
} else if (!config.autoProcessing && postProcessor) {
|
|
postProcessor.stop();
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Configuration updated successfully',
|
|
changes
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating configuration:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Failed to update configuration: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Transmission API routes
|
|
//==============================
|
|
|
|
// Test Transmission connection
|
|
app.post('/api/transmission/test', (req, res) => {
|
|
const { host, port, username, password } = req.body;
|
|
|
|
// Create a test client with provided credentials
|
|
const testClient = new Transmission({
|
|
host: host || config.transmissionConfig.host,
|
|
port: port || config.transmissionConfig.port,
|
|
username: username || config.transmissionConfig.username,
|
|
password: password || config.transmissionConfig.password,
|
|
url: config.transmissionConfig.path
|
|
});
|
|
|
|
// Test connection
|
|
testClient.sessionStats((err, result) => {
|
|
if (err) {
|
|
return res.json({
|
|
success: false,
|
|
message: `Connection failed: ${err.message}`
|
|
});
|
|
}
|
|
|
|
// Connection successful
|
|
res.json({
|
|
success: true,
|
|
message: "Connected to Transmission successfully!",
|
|
data: {
|
|
version: result.version || "Unknown",
|
|
rpcVersion: result.rpcVersion || "Unknown"
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Get torrents from Transmission
|
|
app.get('/api/transmission/torrents', (req, res) => {
|
|
if (!transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
transmissionClient.get((err, result) => {
|
|
if (err) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: `Error getting torrents: ${err.message}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result.torrents || []
|
|
});
|
|
});
|
|
});
|
|
|
|
// Add torrent to Transmission
|
|
app.post('/api/transmission/add', (req, res) => {
|
|
if (!transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
const { url } = req.body;
|
|
if (!url) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'URL is required'
|
|
});
|
|
}
|
|
|
|
transmissionClient.addUrl(url, (err, result) => {
|
|
if (err) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: `Error adding torrent: ${err.message}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
});
|
|
});
|
|
|
|
// Remove torrent from Transmission
|
|
app.post('/api/transmission/remove', (req, res) => {
|
|
if (!transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
const { ids, deleteLocalData } = req.body;
|
|
if (!ids || !Array.isArray(ids) && typeof ids !== 'number') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Valid torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
transmissionClient.remove(ids, !!deleteLocalData, (err, result) => {
|
|
if (err) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: `Error removing torrent: ${err.message}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
});
|
|
});
|
|
|
|
// Start torrents
|
|
app.post('/api/transmission/start', (req, res) => {
|
|
if (!transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
const { ids } = req.body;
|
|
if (!ids) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
transmissionClient.start(ids, (err, result) => {
|
|
if (err) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: `Error starting torrent: ${err.message}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
});
|
|
});
|
|
|
|
// Stop torrents
|
|
app.post('/api/transmission/stop', (req, res) => {
|
|
if (!transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
const { ids } = req.body;
|
|
if (!ids) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
transmissionClient.stop(ids, (err, result) => {
|
|
if (err) {
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: `Error stopping torrent: ${err.message}`
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
});
|
|
});
|
|
|
|
// RSS Feed Manager API routes
|
|
//==============================
|
|
|
|
// Get all feeds
|
|
app.get('/api/rss/feeds', (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: rssFeedManager.getAllFeeds()
|
|
});
|
|
});
|
|
|
|
// Add a new feed
|
|
app.post('/api/rss/feeds', async (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
const feed = req.body;
|
|
if (!feed || !feed.url) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Feed URL is required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const newFeed = rssFeedManager.addFeed(feed);
|
|
|
|
// Update the config with the new feed
|
|
config.rssFeeds = rssFeedManager.getAllFeeds();
|
|
await saveConfig();
|
|
|
|
res.json({
|
|
success: true,
|
|
data: newFeed
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error adding feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Update a feed
|
|
app.put('/api/rss/feeds/:id', async (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const updates = req.body;
|
|
|
|
if (!id || !updates) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Feed ID and updates are required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const success = rssFeedManager.updateFeedConfig(id, updates);
|
|
|
|
if (!success) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: `Feed with ID ${id} not found`
|
|
});
|
|
}
|
|
|
|
// Update the config with the updated feeds
|
|
config.rssFeeds = rssFeedManager.getAllFeeds();
|
|
await saveConfig();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Feed updated successfully'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error updating feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Delete a feed
|
|
app.delete('/api/rss/feeds/:id', async (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
const { id } = req.params;
|
|
|
|
if (!id) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Feed ID is required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const success = rssFeedManager.removeFeed(id);
|
|
|
|
if (!success) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: `Feed with ID ${id} not found`
|
|
});
|
|
}
|
|
|
|
// Update the config with the remaining feeds
|
|
config.rssFeeds = rssFeedManager.getAllFeeds();
|
|
await saveConfig();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Feed removed successfully'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error removing feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get feed items
|
|
app.get('/api/rss/items', (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
const { filter } = req.query;
|
|
|
|
let items;
|
|
if (filter === 'undownloaded') {
|
|
items = rssFeedManager.getUndownloadedItems();
|
|
} else {
|
|
items = rssFeedManager.getAllItems();
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: items
|
|
});
|
|
});
|
|
|
|
// Filter feed items
|
|
app.post('/api/rss/filter', (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
const filters = req.body;
|
|
|
|
const filteredItems = rssFeedManager.filterItems(filters);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: filteredItems
|
|
});
|
|
});
|
|
|
|
// Fetch and update RSS feed
|
|
app.post('/api/rss/update', async (req, res) => {
|
|
if (!rssFeedManager) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager not initialized'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await rssFeedManager.updateAllFeeds();
|
|
|
|
res.json({
|
|
success: true,
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error updating feeds: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Download RSS item
|
|
app.post('/api/rss/download', async (req, res) => {
|
|
if (!rssFeedManager || !transmissionClient) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'RSS feed manager or Transmission client not initialized'
|
|
});
|
|
}
|
|
|
|
const { itemId } = req.body;
|
|
|
|
if (!itemId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Item ID is required'
|
|
});
|
|
}
|
|
|
|
try {
|
|
const items = rssFeedManager.getAllItems();
|
|
const item = items.find(i => i.id === itemId);
|
|
|
|
if (!item) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: `Item with ID ${itemId} not found`
|
|
});
|
|
}
|
|
|
|
const result = await rssFeedManager.downloadItem(item, transmissionClient);
|
|
|
|
res.json({
|
|
success: result.success,
|
|
message: result.success ? 'Item added to Transmission' : result.message,
|
|
data: result.result
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error downloading item: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Post-Processor API routes
|
|
//==============================
|
|
|
|
// Start post-processor
|
|
app.post('/api/post-processor/start', (req, res) => {
|
|
if (!postProcessor) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor not initialized'
|
|
});
|
|
}
|
|
|
|
try {
|
|
postProcessor.start();
|
|
|
|
// Update config
|
|
config.autoProcessing = true;
|
|
saveConfig();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Post-processor started'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error starting post-processor: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Stop post-processor
|
|
app.post('/api/post-processor/stop', (req, res) => {
|
|
if (!postProcessor) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor not initialized'
|
|
});
|
|
}
|
|
|
|
try {
|
|
postProcessor.stop();
|
|
|
|
// Update config
|
|
config.autoProcessing = false;
|
|
saveConfig();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Post-processor stopped'
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error stopping post-processor: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Get media library
|
|
app.get('/api/media/library', (req, res) => {
|
|
if (!postProcessor) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor not initialized'
|
|
});
|
|
}
|
|
|
|
const { query } = req.query;
|
|
|
|
let library;
|
|
if (query) {
|
|
library = postProcessor.searchLibrary(query);
|
|
} else {
|
|
library = postProcessor.getLibrary();
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: library
|
|
});
|
|
});
|
|
|
|
// Get library statistics
|
|
app.get('/api/media/stats', (req, res) => {
|
|
if (!postProcessor) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor not initialized'
|
|
});
|
|
}
|
|
|
|
const stats = postProcessor.getLibraryStats();
|
|
|
|
res.json({
|
|
success: true,
|
|
data: stats
|
|
});
|
|
});
|
|
|
|
// Serve static files
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Catch-all route for SPA
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Initialize application
|
|
async function init() {
|
|
console.log('Initializing application...');
|
|
|
|
// Load configuration
|
|
await loadConfig();
|
|
|
|
// Initialize services
|
|
initTransmission();
|
|
initPostProcessor();
|
|
initRssFeedManager();
|
|
|
|
// Start the server
|
|
app.listen(PORT, () => {
|
|
console.log(`Transmission RSS Manager running on port ${PORT}`);
|
|
});
|
|
}
|
|
|
|
// Start the application
|
|
init().catch(err => {
|
|
console.error('Failed to initialize application:', err);
|
|
});
|
|
|
|
// Handle graceful shutdown
|
|
process.on('SIGINT', async () => {
|
|
console.log('Shutting down...');
|
|
|
|
if (postProcessor) {
|
|
postProcessor.stop();
|
|
}
|
|
|
|
if (rssFeedManager) {
|
|
rssFeedManager.stop();
|
|
await rssFeedManager.saveItems();
|
|
await rssFeedManager.saveConfig();
|
|
}
|
|
|
|
await saveConfig();
|
|
|
|
console.log('Shutdown complete');
|
|
process.exit(0);
|
|
});
|
|
EOF
|
|
}
|
|
|
|
# Create enhanced UI JavaScript
|
|
echo "Creating enhanced-ui.js..."
|
|
cp "${SCRIPT_DIR}/public/js/enhanced-ui.js" "$INSTALL_DIR/public/js/enhanced-ui.js" || {
|
|
cat > $INSTALL_DIR/public/js/enhanced-ui.js << 'EOF'
|
|
// RSS Feed Management Functions
|
|
function addFeed() {
|
|
// Create a modal dialog for adding a feed
|
|
const modal = document.createElement('div');
|
|
modal.style.position = 'fixed';
|
|
modal.style.top = '0';
|
|
modal.style.left = '0';
|
|
modal.style.width = '100%';
|
|
modal.style.height = '100%';
|
|
modal.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';
|
|
modal.style.display = 'flex';
|
|
modal.style.justifyContent = 'center';
|
|
modal.style.alignItems = 'center';
|
|
modal.style.zIndex = '1000';
|
|
|
|
// Create the modal content
|
|
const modalContent = document.createElement('div');
|
|
modalContent.style.backgroundColor = 'white';
|
|
modalContent.style.padding = '20px';
|
|
modalContent.style.borderRadius = '8px';
|
|
modalContent.style.width = '500px';
|
|
modalContent.style.maxWidth = '90%';
|
|
|
|
modalContent.innerHTML = `
|
|
<h2>Add RSS Feed</h2>
|
|
<div class="form-group">
|
|
<label for="feed-name">Feed Name:</label>
|
|
<input type="text" id="feed-name" placeholder="My Feed">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="feed-url">Feed URL:</label>
|
|
<input type="text" id="feed-url" placeholder="https://example.com/rss.xml">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" id="feed-auto-download"> Auto-Download
|
|
</label>
|
|
</div>
|
|
<div id="filter-container" style="display: none; margin-top: 15px; border: 1px solid #ddd; padding: 10px; border-radius: 4px;">
|
|
<h3>Auto-Download Filters</h3>
|
|
<div class="form-group">
|
|
<label for="filter-title">Title Contains:</label>
|
|
<input type="text" id="filter-title" placeholder="e.g., 1080p, HEVC">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="filter-category">Category:</label>
|
|
<input type="text" id="filter-category" placeholder="e.g., Movies, TV">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="filter-min-size">Minimum Size (MB):</label>
|
|
<input type="number" id="filter-min-size" min="0">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="filter-max-size">Maximum Size (MB):</label>
|
|
<input type="number" id="filter-max-size" min="0">
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 20px; text-align: right;">
|
|
<button id="cancel-add-feed" style="background-color: #6c757d; margin-right: 10px;">Cancel</button>
|
|
<button id="save-add-feed" style="background-color: #28a745;">Add Feed</button>
|
|
</div>
|
|
`;
|
|
|
|
modal.appendChild(modalContent);
|
|
document.body.appendChild(modal);
|
|
|
|
// Toggle filter container based on auto-download checkbox
|
|
document.getElementById('feed-auto-download').addEventListener('change', function() {
|
|
document.getElementById('filter-container').style.display = this.checked ? 'block' : 'none';
|
|
});
|
|
|
|
// Cancel button
|
|
document.getElementById('cancel-add-feed').addEventListener('click', function() {
|
|
document.body.removeChild(modal);
|
|
});
|
|
|
|
// Save button
|
|
document.getElementById('save-add-feed').addEventListener('click', function() {
|
|
const name = document.getElementById('feed-name').value.trim();
|
|
const url = document.getElementById('feed-url').value.trim();
|
|
const autoDownload = document.getElementById('feed-auto-download').checked;
|
|
|
|
if (!name || !url) {
|
|
alert('Name and URL are required!');
|
|
return;
|
|
}
|
|
|
|
// Create feed object
|
|
const feed = {
|
|
name,
|
|
url,
|
|
autoDownload
|
|
};
|
|
|
|
// Add filters if auto-download is enabled
|
|
if (autoDownload) {
|
|
const title = document.getElementById('filter-title').value.trim();
|
|
const category = document.getElementById('filter-category').value.trim();
|
|
const minSize = document.getElementById('filter-min-size').value ?
|
|
parseInt(document.getElementById('filter-min-size').value, 10) * 1024 * 1024 : null;
|
|
const maxSize = document.getElementById('filter-max-size').value ?
|
|
parseInt(document.getElementById('filter-max-size').value, 10) * 1024 * 1024 : null;
|
|
|
|
feed.filters = [{
|
|
title,
|
|
category,
|
|
minSize,
|
|
maxSize
|
|
}];
|
|
}
|
|
|
|
// Call API to add the feed
|
|
saveFeed(feed);
|
|
|
|
// Remove modal
|
|
document.body.removeChild(modal);
|
|
});
|
|
}
|
|
|
|
// Rest of enhanced-ui.js content would be here
|
|
EOF
|
|
}
|
|
|
|
# Create postProcessor module
|
|
echo "Creating postProcessor.js..."
|
|
cp "${SCRIPT_DIR}/modules/postProcessor.js" "$INSTALL_DIR/modules/postProcessor.js" || {
|
|
cat > $INSTALL_DIR/modules/postProcessor.js << 'EOF'
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const Transmission = require('transmission');
|
|
const AdmZip = require('adm-zip');
|
|
const { exec } = require('child_process');
|
|
const util = require('util');
|
|
|
|
const execAsync = util.promisify(exec);
|
|
|
|
class PostProcessor {
|
|
constructor(config) {
|
|
this.config = config;
|
|
this.processingIntervalId = null;
|
|
this.transmissionClient = null;
|
|
this.library = {
|
|
movies: [],
|
|
tvShows: [],
|
|
music: [],
|
|
books: [],
|
|
magazines: [],
|
|
software: []
|
|
};
|
|
|
|
this.initTransmission();
|
|
}
|
|
|
|
// Initialize Transmission client
|
|
initTransmission() {
|
|
this.transmissionClient = new Transmission({
|
|
host: this.config.transmissionConfig.host,
|
|
port: this.config.transmissionConfig.port,
|
|
username: this.config.transmissionConfig.username,
|
|
password: this.config.transmissionConfig.password,
|
|
url: this.config.transmissionConfig.path
|
|
});
|
|
}
|
|
|
|
// Start processing completed torrents
|
|
start() {
|
|
if (this.processingIntervalId) {
|
|
return;
|
|
}
|
|
|
|
// Run the process immediately at startup
|
|
this.processCompletedTorrents();
|
|
|
|
// Then set up the interval
|
|
this.processingIntervalId = setInterval(() => {
|
|
this.processCompletedTorrents();
|
|
}, this.config.seedingRequirements.checkIntervalSeconds * 1000);
|
|
|
|
console.log(`Post-processor started, interval: ${this.config.seedingRequirements.checkIntervalSeconds} seconds`);
|
|
}
|
|
|
|
// Stop processing completed torrents
|
|
stop() {
|
|
if (this.processingIntervalId) {
|
|
clearInterval(this.processingIntervalId);
|
|
this.processingIntervalId = null;
|
|
console.log('Post-processor stopped');
|
|
}
|
|
}
|
|
|
|
// Main process function for completed torrents
|
|
async processCompletedTorrents() {
|
|
try {
|
|
// Get all torrents from Transmission
|
|
const torrents = await this.getTransmissionTorrents();
|
|
|
|
// Filter torrents that meet seeding requirements
|
|
const completedTorrents = this.filterCompletedTorrents(torrents);
|
|
|
|
if (completedTorrents.length === 0) {
|
|
return;
|
|
}
|
|
|
|
console.log(`Processing ${completedTorrents.length} completed torrents`);
|
|
|
|
// Process each completed torrent
|
|
for (const torrent of completedTorrents) {
|
|
await this.processTorrent(torrent);
|
|
}
|
|
|
|
// Update the library
|
|
await this.updateLibrary();
|
|
|
|
} catch (error) {
|
|
console.error('Error processing completed torrents:', error);
|
|
}
|
|
}
|
|
|
|
// Get torrents from Transmission
|
|
getTransmissionTorrents() {
|
|
return new Promise((resolve, reject) => {
|
|
this.transmissionClient.get((err, result) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve(result.torrents || []);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Filter completed torrents based on seeding requirements
|
|
filterCompletedTorrents(torrents) {
|
|
return torrents.filter(torrent => {
|
|
// Check if the torrent is 100% completed
|
|
if (torrent.percentDone < 1.0) {
|
|
return false;
|
|
}
|
|
|
|
// Check if seeding requirements are met
|
|
const seedingRatioMet = torrent.uploadRatio >= this.config.seedingRequirements.minRatio;
|
|
const seedingTimeMet = torrent.secondsSeeding >= (this.config.seedingRequirements.minTimeMinutes * 60);
|
|
|
|
return seedingRatioMet && seedingTimeMet;
|
|
});
|
|
}
|
|
|
|
// Process a single torrent
|
|
async processTorrent(torrent) {
|
|
console.log(`Processing torrent: ${torrent.name}`);
|
|
|
|
try {
|
|
// Determine the category of the torrent
|
|
const category = this.determineTorrentCategory(torrent);
|
|
|
|
// Get the download directory
|
|
const downloadDir = torrent.downloadDir || this.config.downloadDir;
|
|
|
|
// Map remote paths to local paths if necessary
|
|
const localPath = this.mapRemotePath(downloadDir, torrent.name);
|
|
|
|
// Process the downloaded files
|
|
await this.processDownloadedFiles(localPath, category);
|
|
|
|
// Update torrent status in Transmission
|
|
if (this.config.processingOptions.removeTorrentAfterProcessing) {
|
|
await this.removeTorrentFromTransmission(torrent.id);
|
|
}
|
|
|
|
console.log(`Successfully processed torrent: ${torrent.name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Error processing torrent ${torrent.name}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Determine category of a torrent based on its name and files
|
|
determineTorrentCategory(torrent) {
|
|
// Default category
|
|
let category = 'other';
|
|
|
|
// Check name for category indicators
|
|
const name = torrent.name.toLowerCase();
|
|
|
|
// Check for video file extensions in the files
|
|
const hasVideoFiles = torrent.files && torrent.files.some(file =>
|
|
['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.m4v', '.flv'].some(ext =>
|
|
file.name.toLowerCase().endsWith(ext)
|
|
)
|
|
);
|
|
|
|
// Movie patterns
|
|
if (hasVideoFiles && (
|
|
name.includes('1080p') ||
|
|
name.includes('720p') ||
|
|
name.includes('2160p') ||
|
|
name.includes('bluray') ||
|
|
name.includes('bdrip') ||
|
|
name.includes('dvdrip') ||
|
|
name.includes('webrip') ||
|
|
/\(\d{4}\)/.test(name) || // Year in parentheses
|
|
/\.\d{4}\./.test(name) // Year between dots
|
|
)) {
|
|
// Check if it's a TV show
|
|
if (
|
|
/s\d{1,2}e\d{1,2}/i.test(name) || // S01E01 format
|
|
/\d{1,2}x\d{1,2}/i.test(name) || // 1x01 format
|
|
name.includes('season') ||
|
|
name.includes('episode') ||
|
|
name.includes('complete series')
|
|
) {
|
|
category = 'tvShows';
|
|
} else {
|
|
category = 'movies';
|
|
}
|
|
}
|
|
// Music patterns
|
|
else if (
|
|
name.includes('mp3') ||
|
|
name.includes('flac') ||
|
|
name.includes('alac') ||
|
|
name.includes('wav') ||
|
|
name.includes('album') ||
|
|
name.includes('discography')
|
|
) {
|
|
category = 'music';
|
|
}
|
|
// Book patterns
|
|
else if (
|
|
this.config.processingOptions.enableBookSorting &&
|
|
(
|
|
name.includes('epub') ||
|
|
name.includes('mobi') ||
|
|
name.includes('azw3') ||
|
|
name.includes('pdf') && !name.includes('magazine') && !name.includes('issue') ||
|
|
name.includes('book') ||
|
|
name.includes('ebook')
|
|
)
|
|
) {
|
|
category = 'books';
|
|
}
|
|
// Magazine patterns
|
|
else if (
|
|
this.config.processingOptions.enableBookSorting &&
|
|
(
|
|
name.includes('magazine') ||
|
|
name.includes('issue') && name.includes('pdf') ||
|
|
/\b(vol|volume)\b.*\d+/.test(name) && name.includes('pdf') ||
|
|
/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) && name.includes('pdf')
|
|
)
|
|
) {
|
|
category = 'magazines';
|
|
}
|
|
// Generic PDF - can be book or magazine, check more closely
|
|
else if (
|
|
this.config.processingOptions.enableBookSorting &&
|
|
name.includes('pdf')
|
|
) {
|
|
// Check if it looks like a magazine - has dates, issues, volumes
|
|
if (
|
|
/\b(issue|vol|volume)\b.*\d+/.test(name) ||
|
|
/\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b.*\d{4}/.test(name) ||
|
|
name.includes('magazine')
|
|
) {
|
|
category = 'magazines';
|
|
} else {
|
|
// Default to books for other PDFs
|
|
category = 'books';
|
|
}
|
|
}
|
|
// Software patterns
|
|
else if (
|
|
name.includes('windows') ||
|
|
name.includes('macos') ||
|
|
name.includes('linux') ||
|
|
name.includes('crack') ||
|
|
name.includes('keygen') ||
|
|
name.includes('iso') ||
|
|
name.includes('software') ||
|
|
name.includes('app') ||
|
|
name.includes('application')
|
|
) {
|
|
category = 'software';
|
|
}
|
|
|
|
return category;
|
|
}
|
|
|
|
// Map remote paths to local paths for remote Transmission setups
|
|
mapRemotePath(remotePath, torrentName) {
|
|
// If we're not working with a remote Transmission setup, return the path as-is
|
|
if (!this.config.remoteConfig.isRemote) {
|
|
return path.join(remotePath, torrentName);
|
|
}
|
|
|
|
// If we are working with a remote setup, map the remote path to a local path
|
|
const mapping = this.config.remoteConfig.directoryMapping;
|
|
|
|
// Find the appropriate mapping
|
|
for (const [remote, local] of Object.entries(mapping)) {
|
|
if (remotePath.startsWith(remote)) {
|
|
// Replace the remote path with the local path
|
|
return path.join(local, torrentName);
|
|
}
|
|
}
|
|
|
|
// If no mapping found, use the remote path directly
|
|
return path.join(remotePath, torrentName);
|
|
}
|
|
|
|
// Process downloaded files in a given path
|
|
async processDownloadedFiles(sourcePath, category) {
|
|
// Get the destination directory for this category
|
|
const destinationDir = this.config.destinationPaths[category] || this.config.destinationPaths.other;
|
|
|
|
try {
|
|
// Check if the source path exists
|
|
await fs.access(sourcePath);
|
|
|
|
// Check if the source is a directory or a file
|
|
const stats = await fs.stat(sourcePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Process a directory
|
|
await this.processDirectory(sourcePath, destinationDir, category);
|
|
} else {
|
|
// Process a single file
|
|
await this.processFile(sourcePath, destinationDir, category);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing ${sourcePath}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process a directory recursively
|
|
async processDirectory(sourceDir, destDir, category) {
|
|
try {
|
|
// Read all files in the directory
|
|
const files = await fs.readdir(sourceDir);
|
|
|
|
// Process each file
|
|
for (const file of files) {
|
|
const filePath = path.join(sourceDir, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Recursively process subdirectories
|
|
await this.processDirectory(filePath, destDir, category);
|
|
} else {
|
|
// Process files
|
|
await this.processFile(filePath, destDir, category);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error processing directory ${sourceDir}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Process a single file
|
|
async processFile(filePath, destDir, category) {
|
|
const fileName = path.basename(filePath);
|
|
|
|
// Skip processing if it's a sample file and ignoreSample is enabled
|
|
if (this.config.processingOptions.ignoreSample && this.isSampleFile(fileName)) {
|
|
console.log(`Skipping sample file: ${fileName}`);
|
|
return;
|
|
}
|
|
|
|
// Skip processing if it's an extras file and ignoreExtras is enabled
|
|
if (this.config.processingOptions.ignoreExtras && this.isExtrasFile(fileName)) {
|
|
console.log(`Skipping extras file: ${fileName}`);
|
|
return;
|
|
}
|
|
|
|
// Handle archive files
|
|
if (this.isArchiveFile(fileName) && this.config.processingOptions.extractArchives) {
|
|
await this.extractArchive(filePath, destDir);
|
|
|
|
// Delete the archive after extraction if configured
|
|
if (this.config.processingOptions.deleteArchives) {
|
|
await fs.unlink(filePath);
|
|
console.log(`Deleted archive after extraction: ${fileName}`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For regular files, copy to destination
|
|
let destinationFile = path.join(destDir, fileName);
|
|
|
|
// If renameFiles is enabled, rename based on category
|
|
if (this.config.processingOptions.renameFiles) {
|
|
destinationFile = this.getDestinationFileName(fileName, destDir, category);
|
|
}
|
|
|
|
// Create destination directory if it doesn't exist
|
|
const destSubDir = path.dirname(destinationFile);
|
|
await fs.mkdir(destSubDir, { recursive: true });
|
|
|
|
// Copy the file to destination
|
|
await fs.copyFile(filePath, destinationFile);
|
|
console.log(`Copied file to: ${destinationFile}`);
|
|
|
|
// Add to library
|
|
this.addToLibrary(destinationFile, category);
|
|
}
|
|
|
|
// Check if a file is an archive
|
|
isArchiveFile(fileName) {
|
|
const ext = path.extname(fileName).toLowerCase();
|
|
return ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'].includes(ext);
|
|
}
|
|
|
|
// Check if a file is a sample file
|
|
isSampleFile(fileName) {
|
|
return fileName.toLowerCase().includes('sample');
|
|
}
|
|
|
|
// Check if a file is an extras file
|
|
isExtrasFile(fileName) {
|
|
const lowerName = fileName.toLowerCase();
|
|
return ['featurette', 'extra', 'bonus', 'deleted', 'interview'].some(term => lowerName.includes(term));
|
|
}
|
|
|
|
// Extract an archive file
|
|
async extractArchive(archivePath, destDir) {
|
|
const ext = path.extname(archivePath).toLowerCase();
|
|
|
|
try {
|
|
if (['.zip', '.rar', '.7z'].includes(ext)) {
|
|
// Create extract directory
|
|
const extractDir = path.join(destDir, path.basename(archivePath, ext));
|
|
await fs.mkdir(extractDir, { recursive: true });
|
|
|
|
if (ext === '.zip') {
|
|
// Use AdmZip for .zip files
|
|
const zip = new AdmZip(archivePath);
|
|
zip.extractAllTo(extractDir, true);
|
|
console.log(`Extracted zip to: ${extractDir}`);
|
|
} else {
|
|
// Use unrar or 7z for other archives
|
|
const cmd = ext === '.rar'
|
|
? `unrar x -o+ "${archivePath}" "${extractDir}"`
|
|
: `7z x "${archivePath}" -o"${extractDir}"`;
|
|
|
|
await execAsync(cmd);
|
|
console.log(`Extracted ${ext} to: ${extractDir}`);
|
|
}
|
|
} else {
|
|
console.log(`Unsupported archive format: ${ext}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error extracting archive ${archivePath}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Get the destination filename based on category and content
|
|
getDestinationFileName(fileName, destDir, category) {
|
|
// Basic filename sanitization
|
|
let cleanName = fileName.replace(/\.\w+$/, ''); // Remove extension
|
|
cleanName = cleanName.replace(/[\/\\:\*\?"<>\|]/g, ''); // Remove invalid characters
|
|
cleanName = cleanName.replace(/\.\d{4}\./g, ' '); // Clean up dots around year
|
|
cleanName = cleanName.replace(/\./g, ' '); // Replace dots with spaces
|
|
|
|
// Get file extension
|
|
const ext = path.extname(fileName);
|
|
|
|
// Format based on category
|
|
switch (category) {
|
|
case 'movies':
|
|
// Extract year if possible
|
|
const yearMatch = fileName.match(/\((\d{4})\)/) || fileName.match(/\.(\d{4})\./);
|
|
const year = yearMatch ? yearMatch[1] : '';
|
|
|
|
// Remove quality tags
|
|
const qualityTags = ['1080p', '720p', '2160p', 'bluray', 'bdrip', 'dvdrip', 'webrip', 'hdrip'];
|
|
let cleanTitle = cleanName;
|
|
qualityTags.forEach(tag => {
|
|
cleanTitle = cleanTitle.replace(new RegExp(tag, 'i'), '');
|
|
});
|
|
|
|
// Format: Movie Title (Year).ext
|
|
return path.join(destDir, `${cleanTitle.trim()}${year ? ` (${year})` : ''}${ext}`);
|
|
|
|
case 'tvShows':
|
|
// Try to extract show name and episode info
|
|
let showName = cleanName;
|
|
let episodeInfo = '';
|
|
|
|
// Look for S01E01 format
|
|
const seasonEpMatch = cleanName.match(/S(\d{1,2})E(\d{1,2})/i);
|
|
if (seasonEpMatch) {
|
|
const parts = cleanName.split(/S\d{1,2}E\d{1,2}/i);
|
|
showName = parts[0].trim();
|
|
episodeInfo = `S${seasonEpMatch[1].padStart(2, '0')}E${seasonEpMatch[2].padStart(2, '0')}`;
|
|
}
|
|
|
|
// Create show directory
|
|
const showDir = path.join(destDir, showName);
|
|
fs.mkdir(showDir, { recursive: true });
|
|
|
|
// Format: Show Name/Show Name - S01E01.ext
|
|
return path.join(showDir, `${showName}${episodeInfo ? ` - ${episodeInfo}` : ''}${ext}`);
|
|
|
|
case 'books':
|
|
// Try to extract author and title
|
|
let author = '';
|
|
let title = cleanName;
|
|
|
|
// Look for common author patterns: "Author - Title" or "Author Name - Book Title"
|
|
const authorMatch = cleanName.match(/^(.*?)\s+-\s+(.*?)$/);
|
|
if (authorMatch) {
|
|
author = authorMatch[1].trim();
|
|
title = authorMatch[2].trim();
|
|
}
|
|
|
|
// Create author directory if we identified one
|
|
let bookPath = destDir;
|
|
if (author && this.config.processingOptions.createCategoryFolders) {
|
|
bookPath = path.join(destDir, author);
|
|
fs.mkdir(bookPath, { recursive: true });
|
|
}
|
|
|
|
// Format: Author/Title.ext or just Title.ext if no author
|
|
return path.join(bookPath, `${title}${ext}`);
|
|
|
|
case 'magazines':
|
|
// Try to extract magazine name and issue info
|
|
let magazineName = cleanName;
|
|
let issueInfo = '';
|
|
|
|
// Look for issue number patterns
|
|
const issueMatch = cleanName.match(/issue\s+(\d+)/i) ||
|
|
cleanName.match(/(\w+)\s+(\d{4})/) || // Month Year
|
|
cleanName.match(/(\d+)\s+(\w+)\s+(\d{4})/); // Day Month Year
|
|
|
|
if (issueMatch) {
|
|
// Try to separate magazine name from issue info
|
|
const parts = cleanName.split(/issue|vol|volume|\d{4}/i)[0];
|
|
if (parts) {
|
|
magazineName = parts.trim();
|
|
// Extract issue date/number from the full name
|
|
issueInfo = cleanName.substring(magazineName.length).trim();
|
|
}
|
|
}
|
|
|
|
// Create magazine directory
|
|
let magazinePath = destDir;
|
|
if (this.config.processingOptions.createCategoryFolders) {
|
|
magazinePath = path.join(destDir, magazineName);
|
|
fs.mkdir(magazinePath, { recursive: true });
|
|
}
|
|
|
|
// Format: Magazine Name/Magazine Name - Issue Info.ext
|
|
return path.join(magazinePath, `${magazineName}${issueInfo ? ` - ${issueInfo}` : ''}${ext}`);
|
|
|
|
default:
|
|
// For other categories, just use the cleaned name
|
|
return path.join(destDir, `${cleanName}${ext}`);
|
|
}
|
|
}
|
|
|
|
// Add a file to the library
|
|
addToLibrary(filePath, category) {
|
|
if (!this.library[category]) {
|
|
this.library[category] = [];
|
|
}
|
|
|
|
const fileName = path.basename(filePath);
|
|
|
|
// Check if file is already in the library
|
|
const existing = this.library[category].find(item => item.name === fileName);
|
|
if (!existing) {
|
|
this.library[category].push({
|
|
name: fileName,
|
|
path: filePath,
|
|
added: new Date().toISOString()
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update the library by scanning destination directories
|
|
async updateLibrary() {
|
|
try {
|
|
// Scan the destination directories
|
|
for (const [category, destDir] of Object.entries(this.config.destinationPaths)) {
|
|
if (category === 'downloadDir') continue;
|
|
|
|
// Initialize or clear the category in the library
|
|
this.library[category] = [];
|
|
|
|
try {
|
|
// Check if directory exists before scanning
|
|
await fs.access(destDir);
|
|
|
|
// Scan the directory
|
|
await this.scanDirectory(destDir, category);
|
|
} catch (error) {
|
|
console.log(`Directory ${destDir} does not exist or is not accessible`);
|
|
}
|
|
}
|
|
|
|
console.log('Library updated');
|
|
} catch (error) {
|
|
console.error('Error updating library:', error);
|
|
}
|
|
}
|
|
|
|
// Scan a directory recursively
|
|
async scanDirectory(directory, category) {
|
|
try {
|
|
const files = await fs.readdir(directory);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(directory, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
await this.scanDirectory(filePath, category);
|
|
} else {
|
|
this.addToLibrary(filePath, category);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error scanning directory ${directory}:`, error);
|
|
}
|
|
}
|
|
|
|
// Remove a torrent from Transmission
|
|
removeTorrentFromTransmission(torrentId) {
|
|
return new Promise((resolve, reject) => {
|
|
this.transmissionClient.remove(torrentId, true, (err, result) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve(result);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Public API methods
|
|
|
|
// Get the entire media library
|
|
getLibrary() {
|
|
return this.library;
|
|
}
|
|
|
|
// Search the library for a query
|
|
searchLibrary(query) {
|
|
const results = {};
|
|
|
|
query = query.toLowerCase();
|
|
|
|
for (const [category, items] of Object.entries(this.library)) {
|
|
results[category] = items.filter(item =>
|
|
item.name.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Get library statistics
|
|
getLibraryStats() {
|
|
const stats = {
|
|
totalItems: 0,
|
|
categories: {}
|
|
};
|
|
|
|
for (const [category, items] of Object.entries(this.library)) {
|
|
stats.categories[category] = items.length;
|
|
stats.totalItems += items.length;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
}
|
|
|
|
module.exports = PostProcessor;
|
|
EOF
|
|
}
|
|
|
|
echo "Configuration files created."
|
|
|
|
# Copy all JavaScript modules to the installation directory
|
|
echo "Copying JavaScript module files..."
|
|
copy_module_files
|
|
}
|
|
|
|
# Function to copy all JavaScript module files to the installation directory
|
|
function copy_module_files() {
|
|
# Create the modules directory if it doesn't exist
|
|
mkdir -p "$INSTALL_DIR/modules"
|
|
|
|
# Copy all JavaScript module files from the source directory
|
|
for js_file in "${SCRIPT_DIR}/modules/"*.js; do
|
|
if [ -f "$js_file" ]; then
|
|
module_name=$(basename "$js_file")
|
|
echo "Copying module: $module_name"
|
|
cp "$js_file" "$INSTALL_DIR/modules/$module_name"
|
|
|
|
# Set permissions
|
|
chown "$USER:$USER" "$INSTALL_DIR/modules/$module_name"
|
|
chmod 644 "$INSTALL_DIR/modules/$module_name"
|
|
fi
|
|
done
|
|
|
|
# Copy main server files
|
|
echo "Copying main server files..."
|
|
|
|
# server.js
|
|
if [ -f "${SCRIPT_DIR}/server.js" ]; then
|
|
cp "${SCRIPT_DIR}/server.js" "$INSTALL_DIR/"
|
|
log "INFO" "Copied main server file: server.js"
|
|
chown "$USER:$USER" "$INSTALL_DIR/server.js"
|
|
chmod 644 "$INSTALL_DIR/server.js"
|
|
else
|
|
log "ERROR" "Main server file server.js not found in source directory"
|
|
return 1
|
|
fi
|
|
|
|
# server-endpoints.js (if it exists)
|
|
if [ -f "${SCRIPT_DIR}/server-endpoints.js" ]; then
|
|
cp "${SCRIPT_DIR}/server-endpoints.js" "$INSTALL_DIR/"
|
|
log "INFO" "Copied API endpoints file: server-endpoints.js"
|
|
chown "$USER:$USER" "$INSTALL_DIR/server-endpoints.js"
|
|
chmod 644 "$INSTALL_DIR/server-endpoints.js"
|
|
fi
|
|
|
|
# Function to create bidirectional symlinks for module compatibility
|
|
create_bidirectional_links() {
|
|
local hyphenated="$1"
|
|
local camelCase="$2"
|
|
|
|
# Check if hyphenated version exists
|
|
if [ -f "$INSTALL_DIR/modules/$hyphenated.js" ]; then
|
|
# Create camelCase symlink
|
|
ln -sf "$hyphenated.js" "$INSTALL_DIR/modules/$camelCase.js"
|
|
log "INFO" "Created symlink: $camelCase.js -> $hyphenated.js"
|
|
|
|
# Create symlinks without extension
|
|
ln -sf "$hyphenated.js" "$INSTALL_DIR/modules/$hyphenated"
|
|
ln -sf "$hyphenated.js" "$INSTALL_DIR/modules/$camelCase"
|
|
log "INFO" "Created extension-less symlinks: $hyphenated, $camelCase -> $hyphenated.js"
|
|
# Check if camelCase version exists
|
|
elif [ -f "$INSTALL_DIR/modules/$camelCase.js" ]; then
|
|
# Create hyphenated symlink
|
|
ln -sf "$camelCase.js" "$INSTALL_DIR/modules/$hyphenated.js"
|
|
log "INFO" "Created symlink: $hyphenated.js -> $camelCase.js"
|
|
|
|
# Create symlinks without extension
|
|
ln -sf "$camelCase.js" "$INSTALL_DIR/modules/$hyphenated"
|
|
ln -sf "$camelCase.js" "$INSTALL_DIR/modules/$camelCase"
|
|
log "INFO" "Created extension-less symlinks: $hyphenated, $camelCase -> $camelCase.js"
|
|
else
|
|
log "WARN" "Neither $hyphenated.js nor $camelCase.js exists in $INSTALL_DIR/modules"
|
|
fi
|
|
|
|
# Set permissions for all symlinks
|
|
chmod 644 "$INSTALL_DIR/modules/$hyphenated.js" 2>/dev/null || true
|
|
chmod 644 "$INSTALL_DIR/modules/$camelCase.js" 2>/dev/null || true
|
|
chmod 644 "$INSTALL_DIR/modules/$hyphenated" 2>/dev/null || true
|
|
chmod 644 "$INSTALL_DIR/modules/$camelCase" 2>/dev/null || true
|
|
|
|
# Set ownership for all symlinks
|
|
chown "$USER:$USER" "$INSTALL_DIR/modules/$hyphenated.js" 2>/dev/null || true
|
|
chown "$USER:$USER" "$INSTALL_DIR/modules/$camelCase.js" 2>/dev/null || true
|
|
chown "$USER:$USER" "$INSTALL_DIR/modules/$hyphenated" 2>/dev/null || true
|
|
chown "$USER:$USER" "$INSTALL_DIR/modules/$camelCase" 2>/dev/null || true
|
|
}
|
|
|
|
# Create bidirectional symlinks for all modules
|
|
create_bidirectional_links "rss-feed-manager" "rssFeedManager"
|
|
create_bidirectional_links "transmission-client" "transmissionClient"
|
|
create_bidirectional_links "post-processor" "postProcessor"
|
|
|
|
log "INFO" "Copied JavaScript modules and created compatibility symlinks in $INSTALL_DIR/modules/"
|
|
} |