transmission-rss-manager/modules/file-creator-module.sh
MasterDraco 4c68a1ac07 Fix remote Transmission detection and config directory creation
- Fix bug where installer doesn't ask if Transmission is remote
- Fix missing configuration directory in /etc/transmission-rss-manager
- Create symlink between config locations to ensure app always finds config
- Ensure CONFIG_DIR is properly exported in the environment
- Update version to 2.0.4

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-05 08:39:21 +00:00

1765 lines
48 KiB
Bash

#!/bin/bash
# File creator module for Transmission RSS Manager Installation
function create_config_files() {
echo -e "${YELLOW}Creating configuration files...${NC}"
# Create initial config.json
echo "Creating config.json..."
mkdir -p "$CONFIG_DIR"
cat > $CONFIG_DIR/config.json << EOF
{
"version": "2.0.3",
"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/postProcessor');
const RssFeedManager = require('./modules/rssFeedManager');
// Initialize Express app
const app = express();
const PORT = process.env.PORT || 3000;
// Load configuration
let config = {
transmissionConfig: {
host: 'localhost',
port: 9091,
username: '',
password: '',
path: '/transmission/rpc'
},
remoteConfig: {
isRemote: false,
directoryMapping: {}
},
destinationPaths: {
movies: '/mnt/media/movies',
tvShows: '/mnt/media/tvshows',
music: '/mnt/media/music',
books: '/mnt/media/books',
magazines: '/mnt/media/magazines',
software: '/mnt/media/software'
},
seedingRequirements: {
minRatio: 1.0,
minTimeMinutes: 60,
checkIntervalSeconds: 300
},
processingOptions: {
enableBookSorting: true,
extractArchives: true,
deleteArchives: true,
createCategoryFolders: true,
ignoreSample: true,
ignoreExtras: true,
renameFiles: true,
autoReplaceUpgrades: true,
removeDuplicates: true,
keepOnlyBestVersion: true
},
rssFeeds: [],
rssUpdateIntervalMinutes: 60,
autoProcessing: false
};
// Service instances
let transmissionClient = null;
let postProcessor = null;
let rssFeedManager = null;
// Save config function
async function saveConfig() {
try {
await fs.writeFile(
path.join(__dirname, 'config.json'),
JSON.stringify(config, null, 2),
'utf8'
);
console.log('Configuration saved');
return true;
} catch (err) {
console.error('Error saving config:', err.message);
return false;
}
}
// Load config function
async function loadConfig() {
try {
const data = await fs.readFile(path.join(__dirname, 'config.json'), 'utf8');
const loadedConfig = JSON.parse(data);
config = { ...config, ...loadedConfig };
console.log('Configuration loaded');
return true;
} catch (err) {
console.error('Error loading config, using defaults:', err.message);
// Save default config
await saveConfig();
return false;
}
}
// Initialize Transmission client
function initTransmission() {
transmissionClient = new Transmission({
host: config.transmissionConfig.host,
port: config.transmissionConfig.port,
username: config.transmissionConfig.username,
password: config.transmissionConfig.password,
url: config.transmissionConfig.path
});
console.log(`Transmission client initialized for ${config.transmissionConfig.host}:${config.transmissionConfig.port}`);
return transmissionClient;
}
// Initialize post processor
function initPostProcessor() {
if (postProcessor) {
postProcessor.stop();
}
postProcessor = new PostProcessor({
transmissionConfig: config.transmissionConfig,
remoteConfig: config.remoteConfig,
destinationPaths: config.destinationPaths,
seedingRequirements: config.seedingRequirements,
processingOptions: config.processingOptions,
downloadDir: config.downloadDir
});
if (config.autoProcessing) {
postProcessor.start();
console.log('Post-processor started automatically');
} else {
console.log('Post-processor initialized (not auto-started)');
}
return postProcessor;
}
// Initialize RSS feed manager
function initRssFeedManager() {
if (rssFeedManager) {
rssFeedManager.stop();
}
rssFeedManager = new RssFeedManager({
feeds: config.rssFeeds,
updateIntervalMinutes: config.rssUpdateIntervalMinutes
});
rssFeedManager.loadItems()
.then(() => {
if (config.rssFeeds && config.rssFeeds.length > 0) {
rssFeedManager.start();
console.log('RSS feed manager started');
} else {
console.log('RSS feed manager initialized (no feeds configured)');
}
})
.catch(err => {
console.error('Error initializing RSS feed manager:', err);
});
return rssFeedManager;
}
// Enable CORS
app.use(cors());
// Parse JSON bodies
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
// API routes
//==============================
// Server status API
app.get('/api/status', (req, res) => {
res.json({
status: 'running',
version: '1.2.0',
transmissionConnected: !!transmissionClient,
postProcessorActive: postProcessor && postProcessor.processingIntervalId !== null,
rssFeedManagerActive: rssFeedManager && rssFeedManager.updateIntervalId !== null,
config: {
autoProcessing: config.autoProcessing,
rssEnabled: config.rssFeeds && config.rssFeeds.length > 0
}
});
});
// Get configuration
app.get('/api/config', (req, res) => {
// Don't send password in response
const safeConfig = { ...config };
if (safeConfig.transmissionConfig) {
safeConfig.transmissionConfig = { ...safeConfig.transmissionConfig, password: '••••••••' };
}
res.json(safeConfig);
});
// Update configuration
app.post('/api/config', async (req, res) => {
try {
// Create a deep copy of the old config
const oldConfig = JSON.parse(JSON.stringify(config));
// Update config object, preserving password if not provided
config = {
...config,
...req.body,
transmissionConfig: {
...config.transmissionConfig,
...req.body.transmissionConfig,
password: req.body.transmissionConfig?.password || config.transmissionConfig.password
},
remoteConfig: {
...config.remoteConfig,
...req.body.remoteConfig
},
destinationPaths: {
...config.destinationPaths,
...req.body.destinationPaths
},
seedingRequirements: {
...config.seedingRequirements,
...req.body.seedingRequirements
},
processingOptions: {
...config.processingOptions,
...req.body.processingOptions
}
};
// Save the updated config
await saveConfig();
// Check if key services need reinitialization
let changes = {
transmissionChanged: JSON.stringify(oldConfig.transmissionConfig) !== JSON.stringify(config.transmissionConfig),
postProcessorChanged:
JSON.stringify(oldConfig.remoteConfig) !== JSON.stringify(config.remoteConfig) ||
JSON.stringify(oldConfig.destinationPaths) !== JSON.stringify(config.destinationPaths) ||
JSON.stringify(oldConfig.seedingRequirements) !== JSON.stringify(config.seedingRequirements) ||
JSON.stringify(oldConfig.processingOptions) !== JSON.stringify(config.processingOptions),
rssFeedsChanged:
JSON.stringify(oldConfig.rssFeeds) !== JSON.stringify(config.rssFeeds) ||
oldConfig.rssUpdateIntervalMinutes !== config.rssUpdateIntervalMinutes,
autoProcessingChanged: oldConfig.autoProcessing !== config.autoProcessing
};
// Reinitialize services as needed
if (changes.transmissionChanged) {
initTransmission();
}
if (changes.postProcessorChanged || changes.transmissionChanged) {
initPostProcessor();
}
if (changes.rssFeedsChanged) {
initRssFeedManager();
}
if (changes.autoProcessingChanged) {
if (config.autoProcessing && postProcessor) {
postProcessor.start();
} else if (!config.autoProcessing && postProcessor) {
postProcessor.stop();
}
}
res.json({
success: true,
message: 'Configuration updated successfully',
changes
});
} catch (error) {
console.error('Error updating configuration:', error);
res.status(500).json({
success: false,
message: `Failed to update configuration: ${error.message}`
});
}
});
// Transmission API routes
//==============================
// Test Transmission connection
app.post('/api/transmission/test', (req, res) => {
const { host, port, username, password } = req.body;
// Create a test client with provided credentials
const testClient = new Transmission({
host: host || config.transmissionConfig.host,
port: port || config.transmissionConfig.port,
username: username || config.transmissionConfig.username,
password: password || config.transmissionConfig.password,
url: config.transmissionConfig.path
});
// Test connection
testClient.sessionStats((err, result) => {
if (err) {
return res.json({
success: false,
message: `Connection failed: ${err.message}`
});
}
// Connection successful
res.json({
success: true,
message: "Connected to Transmission successfully!",
data: {
version: result.version || "Unknown",
rpcVersion: result.rpcVersion || "Unknown"
}
});
});
});
// Get torrents from Transmission
app.get('/api/transmission/torrents', (req, res) => {
if (!transmissionClient) {
return res.status(400).json({
success: false,
message: 'Transmission client not initialized'
});
}
transmissionClient.get((err, result) => {
if (err) {
return res.status(500).json({
success: false,
message: `Error getting torrents: ${err.message}`
});
}
res.json({
success: true,
data: result.torrents || []
});
});
});
// Add torrent to Transmission
app.post('/api/transmission/add', (req, res) => {
if (!transmissionClient) {
return res.status(400).json({
success: false,
message: 'Transmission client not initialized'
});
}
const { url } = req.body;
if (!url) {
return res.status(400).json({
success: false,
message: 'URL is required'
});
}
transmissionClient.addUrl(url, (err, result) => {
if (err) {
return res.status(500).json({
success: false,
message: `Error adding torrent: ${err.message}`
});
}
res.json({
success: true,
data: result
});
});
});
// Remove torrent from Transmission
app.post('/api/transmission/remove', (req, res) => {
if (!transmissionClient) {
return res.status(400).json({
success: false,
message: 'Transmission client not initialized'
});
}
const { ids, deleteLocalData } = req.body;
if (!ids || !Array.isArray(ids) && typeof ids !== 'number') {
return res.status(400).json({
success: false,
message: 'Valid torrent ID(s) required'
});
}
transmissionClient.remove(ids, !!deleteLocalData, (err, result) => {
if (err) {
return res.status(500).json({
success: false,
message: `Error removing torrent: ${err.message}`
});
}
res.json({
success: true,
data: result
});
});
});
// Start torrents
app.post('/api/transmission/start', (req, res) => {
if (!transmissionClient) {
return res.status(400).json({
success: false,
message: 'Transmission client not initialized'
});
}
const { ids } = req.body;
if (!ids) {
return res.status(400).json({
success: false,
message: 'Torrent ID(s) required'
});
}
transmissionClient.start(ids, (err, result) => {
if (err) {
return res.status(500).json({
success: false,
message: `Error starting torrent: ${err.message}`
});
}
res.json({
success: true,
data: result
});
});
});
// Stop torrents
app.post('/api/transmission/stop', (req, res) => {
if (!transmissionClient) {
return res.status(400).json({
success: false,
message: 'Transmission client not initialized'
});
}
const { ids } = req.body;
if (!ids) {
return res.status(400).json({
success: false,
message: 'Torrent ID(s) required'
});
}
transmissionClient.stop(ids, (err, result) => {
if (err) {
return res.status(500).json({
success: false,
message: `Error stopping torrent: ${err.message}`
});
}
res.json({
success: true,
data: result
});
});
});
// RSS Feed Manager API routes
//==============================
// Get all feeds
app.get('/api/rss/feeds', (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
res.json({
success: true,
data: rssFeedManager.getAllFeeds()
});
});
// Add a new feed
app.post('/api/rss/feeds', async (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
const feed = req.body;
if (!feed || !feed.url) {
return res.status(400).json({
success: false,
message: 'Feed URL is required'
});
}
try {
const newFeed = rssFeedManager.addFeed(feed);
// Update the config with the new feed
config.rssFeeds = rssFeedManager.getAllFeeds();
await saveConfig();
res.json({
success: true,
data: newFeed
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error adding feed: ${error.message}`
});
}
});
// Update a feed
app.put('/api/rss/feeds/:id', async (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
const { id } = req.params;
const updates = req.body;
if (!id || !updates) {
return res.status(400).json({
success: false,
message: 'Feed ID and updates are required'
});
}
try {
const success = rssFeedManager.updateFeedConfig(id, updates);
if (!success) {
return res.status(404).json({
success: false,
message: `Feed with ID ${id} not found`
});
}
// Update the config with the updated feeds
config.rssFeeds = rssFeedManager.getAllFeeds();
await saveConfig();
res.json({
success: true,
message: 'Feed updated successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error updating feed: ${error.message}`
});
}
});
// Delete a feed
app.delete('/api/rss/feeds/:id', async (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: 'Feed ID is required'
});
}
try {
const success = rssFeedManager.removeFeed(id);
if (!success) {
return res.status(404).json({
success: false,
message: `Feed with ID ${id} not found`
});
}
// Update the config with the remaining feeds
config.rssFeeds = rssFeedManager.getAllFeeds();
await saveConfig();
res.json({
success: true,
message: 'Feed removed successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error removing feed: ${error.message}`
});
}
});
// Get feed items
app.get('/api/rss/items', (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
const { filter } = req.query;
let items;
if (filter === 'undownloaded') {
items = rssFeedManager.getUndownloadedItems();
} else {
items = rssFeedManager.getAllItems();
}
res.json({
success: true,
data: items
});
});
// Filter feed items
app.post('/api/rss/filter', (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
const filters = req.body;
const filteredItems = rssFeedManager.filterItems(filters);
res.json({
success: true,
data: filteredItems
});
});
// Fetch and update RSS feed
app.post('/api/rss/update', async (req, res) => {
if (!rssFeedManager) {
return res.status(400).json({
success: false,
message: 'RSS feed manager not initialized'
});
}
try {
const result = await rssFeedManager.updateAllFeeds();
res.json({
success: true,
data: result
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error updating feeds: ${error.message}`
});
}
});
// Download RSS item
app.post('/api/rss/download', async (req, res) => {
if (!rssFeedManager || !transmissionClient) {
return res.status(400).json({
success: false,
message: 'RSS feed manager or Transmission client not initialized'
});
}
const { itemId } = req.body;
if (!itemId) {
return res.status(400).json({
success: false,
message: 'Item ID is required'
});
}
try {
const items = rssFeedManager.getAllItems();
const item = items.find(i => i.id === itemId);
if (!item) {
return res.status(404).json({
success: false,
message: `Item with ID ${itemId} not found`
});
}
const result = await rssFeedManager.downloadItem(item, transmissionClient);
res.json({
success: result.success,
message: result.success ? 'Item added to Transmission' : result.message,
data: result.result
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error downloading item: ${error.message}`
});
}
});
// Post-Processor API routes
//==============================
// Start post-processor
app.post('/api/post-processor/start', (req, res) => {
if (!postProcessor) {
return res.status(400).json({
success: false,
message: 'Post-processor not initialized'
});
}
try {
postProcessor.start();
// Update config
config.autoProcessing = true;
saveConfig();
res.json({
success: true,
message: 'Post-processor started'
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error starting post-processor: ${error.message}`
});
}
});
// Stop post-processor
app.post('/api/post-processor/stop', (req, res) => {
if (!postProcessor) {
return res.status(400).json({
success: false,
message: 'Post-processor not initialized'
});
}
try {
postProcessor.stop();
// Update config
config.autoProcessing = false;
saveConfig();
res.json({
success: true,
message: 'Post-processor stopped'
});
} catch (error) {
res.status(500).json({
success: false,
message: `Error stopping post-processor: ${error.message}`
});
}
});
// Get media library
app.get('/api/media/library', (req, res) => {
if (!postProcessor) {
return res.status(400).json({
success: false,
message: 'Post-processor not initialized'
});
}
const { query } = req.query;
let library;
if (query) {
library = postProcessor.searchLibrary(query);
} else {
library = postProcessor.getLibrary();
}
res.json({
success: true,
data: library
});
});
// Get library statistics
app.get('/api/media/stats', (req, res) => {
if (!postProcessor) {
return res.status(400).json({
success: false,
message: 'Post-processor not initialized'
});
}
const stats = postProcessor.getLibraryStats();
res.json({
success: true,
data: stats
});
});
// Serve static files
app.use(express.static(path.join(__dirname, 'public')));
// Catch-all route for SPA
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Initialize application
async function init() {
console.log('Initializing application...');
// Load configuration
await loadConfig();
// Initialize services
initTransmission();
initPostProcessor();
initRssFeedManager();
// Start the server
app.listen(PORT, () => {
console.log(`Transmission RSS Manager running on port ${PORT}`);
});
}
// Start the application
init().catch(err => {
console.error('Failed to initialize application:', err);
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('Shutting down...');
if (postProcessor) {
postProcessor.stop();
}
if (rssFeedManager) {
rssFeedManager.stop();
await rssFeedManager.saveItems();
await rssFeedManager.saveConfig();
}
await saveConfig();
console.log('Shutdown complete');
process.exit(0);
});
EOF
}
# Create enhanced UI 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."
}