This commit addresses multiple code consistency and reliability issues across the codebase: 1. Version consistency - use package.json version (2.0.9) throughout 2. Improved module loading with better error handling and consistent symlinks 3. Enhanced data directory handling with better error checking 4. Fixed redundant code in main-installer.sh 5. Improved error handling in transmission-client.js 6. Added extensive module symlink creation 7. Better file path handling and permission checks 8. Enhanced API response handling 💡 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1886 lines
53 KiB
Bash
1886 lines
53 KiB
Bash
#!/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}"
|
|
|
|
# Create initial config.json
|
|
echo "Creating 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
|
|
|
|
# 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/"
|
|
} |