transmission-rss-manager/modules/file-creator-module.sh
MasterDraco 6dc2df3cee Fix code consistency and reliability issues
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>
2025-03-07 11:38:14 +00:00

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/"
}