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>
1444 lines
41 KiB
JavaScript
1444 lines
41 KiB
JavaScript
/**
|
|
* Transmission RSS Manager
|
|
* Main Server Application
|
|
*/
|
|
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const bodyParser = require('body-parser');
|
|
const cors = require('cors');
|
|
const morgan = require('morgan');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const jwt = require('jsonwebtoken');
|
|
const bcrypt = require('bcrypt');
|
|
const { exec } = require('child_process');
|
|
const util = require('util');
|
|
const execAsync = util.promisify(exec);
|
|
const semver = require('semver'); // For semantic version comparison
|
|
|
|
// Import custom modules
|
|
let RssFeedManager, TransmissionClient, PostProcessor;
|
|
|
|
/**
|
|
* Helper function to try multiple module paths
|
|
* This function tries to require a module using different naming conventions
|
|
* to work around issues with module resolution in different Node.js environments
|
|
*
|
|
* @param {string} baseName - The base module name without extension or path
|
|
* @returns {Object} The loaded module
|
|
* @throws {Error} If module cannot be loaded from any path
|
|
*/
|
|
function loadModule(baseName) {
|
|
// Generate all possible module paths
|
|
const paths = [
|
|
`./modules/${baseName}.js`, // With extension
|
|
`./modules/${baseName}`, // Without extension
|
|
|
|
// Convert hyphenated to camelCase
|
|
`./modules/${baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}.js`,
|
|
`./modules/${baseName.replace(/-([a-z])/g, (_, c) => c.toUpperCase())}`,
|
|
|
|
// Convert camelCase to hyphenated
|
|
`./modules/${baseName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}.js`,
|
|
`./modules/${baseName.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}`
|
|
];
|
|
|
|
let lastError = null;
|
|
for (const modulePath of paths) {
|
|
try {
|
|
return require(modulePath);
|
|
} catch (err) {
|
|
if (err.code !== 'MODULE_NOT_FOUND' || !err.message.includes(modulePath)) {
|
|
// This is a real error in the module, not just not finding it
|
|
throw err;
|
|
}
|
|
lastError = err;
|
|
}
|
|
}
|
|
|
|
// If we get here, we couldn't load the module from any path
|
|
const error = new Error(`Could not load module '${baseName}' from any path. Original error: ${lastError.message}`);
|
|
error.original = lastError;
|
|
throw error;
|
|
}
|
|
|
|
// Try loading modules with improved error reporting
|
|
try {
|
|
RssFeedManager = loadModule('rss-feed-manager');
|
|
console.log('Successfully loaded RssFeedManager module');
|
|
|
|
TransmissionClient = loadModule('transmission-client');
|
|
console.log('Successfully loaded TransmissionClient module');
|
|
|
|
PostProcessor = loadModule('post-processor');
|
|
console.log('Successfully loaded PostProcessor module');
|
|
} catch (err) {
|
|
console.error('Fatal error loading required module:', err.message);
|
|
console.error('Please make sure all module files exist and are valid JavaScript');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Constants and configuration
|
|
const DEFAULT_CONFIG_PATH = '/etc/transmission-rss-manager/config.json';
|
|
const FALLBACK_CONFIG_PATH = path.join(__dirname, 'config.json');
|
|
const DEFAULT_PORT = 3000;
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'transmission-rss-manager-secret';
|
|
const JWT_EXPIRY = '24h';
|
|
|
|
// Get the version from package.json (single source of truth)
|
|
const PACKAGE_JSON = require('./package.json');
|
|
const APP_VERSION = PACKAGE_JSON.version;
|
|
|
|
// Create Express app
|
|
const app = express();
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(bodyParser.json());
|
|
app.use(morgan('combined'));
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
// Application state
|
|
let config = null;
|
|
let transmissionClient = null;
|
|
let rssFeedManager = null;
|
|
let postProcessor = null;
|
|
let isInitialized = false;
|
|
let server = null;
|
|
|
|
/**
|
|
* Initialize the application with configuration
|
|
*/
|
|
async function initializeApp() {
|
|
try {
|
|
// Load configuration
|
|
config = await loadConfig();
|
|
console.log('Configuration loaded');
|
|
|
|
// Initialize transmission client
|
|
transmissionClient = new TransmissionClient(config);
|
|
console.log('Transmission client initialized');
|
|
|
|
// Initialize RSS feed manager
|
|
// Ensure config has the feeds property (mapped from rssFeeds)
|
|
const rssFeedManagerConfig = {
|
|
...config,
|
|
feeds: config.rssFeeds || []
|
|
};
|
|
rssFeedManager = new RssFeedManager(rssFeedManagerConfig);
|
|
await rssFeedManager.start();
|
|
console.log('RSS feed manager started');
|
|
|
|
// Initialize post processor
|
|
postProcessor = new PostProcessor(config, transmissionClient);
|
|
|
|
// Start post processor if auto-processing is enabled
|
|
if (config.autoProcessing) {
|
|
postProcessor.start();
|
|
console.log('Post-processor auto-started');
|
|
} else {
|
|
console.log('Post-processor not auto-starting (autoProcessing disabled)');
|
|
}
|
|
|
|
isInitialized = true;
|
|
console.log('Application initialized successfully');
|
|
} catch (error) {
|
|
console.error('Failed to initialize application:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load configuration from file
|
|
* @returns {Promise<Object>} Configuration object
|
|
*/
|
|
async function loadConfig() {
|
|
try {
|
|
// Define default configuration
|
|
const defaultConfig = {
|
|
version: APP_VERSION, // Use the version from package.json
|
|
installPath: __dirname, // Store the path to the installation directory
|
|
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,
|
|
securitySettings: {
|
|
authEnabled: false,
|
|
httpsEnabled: false,
|
|
sslCertPath: "",
|
|
sslKeyPath: "",
|
|
users: []
|
|
},
|
|
port: DEFAULT_PORT,
|
|
logLevel: "info"
|
|
};
|
|
|
|
// Try primary config location first
|
|
try {
|
|
// Try to read existing config from primary location
|
|
console.log(`Trying to load config from: ${DEFAULT_CONFIG_PATH}`);
|
|
const configData = await fs.readFile(DEFAULT_CONFIG_PATH, 'utf8');
|
|
const loadedConfig = JSON.parse(configData);
|
|
|
|
// Use recursive merge function to merge configs
|
|
const mergedConfig = mergeConfigs(defaultConfig, loadedConfig);
|
|
|
|
// If version is different, save updated config
|
|
if (loadedConfig.version !== APP_VERSION) {
|
|
console.log(`Updating config from version ${loadedConfig.version || 'unknown'} to ${APP_VERSION}`);
|
|
mergedConfig.version = APP_VERSION;
|
|
await saveConfig(mergedConfig);
|
|
}
|
|
|
|
console.log(`Config loaded from ${DEFAULT_CONFIG_PATH}`);
|
|
return mergedConfig;
|
|
} catch (primaryError) {
|
|
// If file not found in primary location, try fallback location
|
|
if (primaryError.code === 'ENOENT') {
|
|
console.log(`Config not found at ${DEFAULT_CONFIG_PATH}, trying fallback location...`);
|
|
|
|
try {
|
|
const fallbackData = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
|
|
const fallbackConfig = JSON.parse(fallbackData);
|
|
|
|
// Merge configs
|
|
const mergedConfig = mergeConfigs(defaultConfig, fallbackConfig);
|
|
|
|
// If version is different, save updated config
|
|
if (fallbackConfig.version !== APP_VERSION) {
|
|
console.log(`Updating config from version ${fallbackConfig.version || 'unknown'} to ${APP_VERSION}`);
|
|
mergedConfig.version = APP_VERSION;
|
|
}
|
|
|
|
// Try to save to primary location, but don't fail if we can't
|
|
try {
|
|
// Create directory if it doesn't exist
|
|
await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
|
|
await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(mergedConfig, null, 2), 'utf8');
|
|
console.log(`Migrated config from ${FALLBACK_CONFIG_PATH} to ${DEFAULT_CONFIG_PATH}`);
|
|
} catch (saveError) {
|
|
console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${saveError.message}`);
|
|
console.warn('Continuing with fallback config location');
|
|
}
|
|
|
|
console.log(`Config loaded from fallback location: ${FALLBACK_CONFIG_PATH}`);
|
|
return mergedConfig;
|
|
} catch (fallbackError) {
|
|
// If file not found in fallback location, create default config
|
|
if (fallbackError.code === 'ENOENT') {
|
|
console.log('Config file not found in either location, creating default configuration');
|
|
|
|
// Try to save to primary location first
|
|
try {
|
|
await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
|
|
await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(defaultConfig, null, 2), 'utf8');
|
|
console.log(`Created default config at ${DEFAULT_CONFIG_PATH}`);
|
|
} catch (saveError) {
|
|
console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${saveError.message}`);
|
|
console.warn('Saving to fallback location instead');
|
|
|
|
// Save to fallback location instead
|
|
await fs.writeFile(FALLBACK_CONFIG_PATH, JSON.stringify(defaultConfig, null, 2), 'utf8');
|
|
console.log(`Created default config at ${FALLBACK_CONFIG_PATH}`);
|
|
}
|
|
|
|
return defaultConfig;
|
|
}
|
|
|
|
// For other errors, rethrow
|
|
throw fallbackError;
|
|
}
|
|
}
|
|
|
|
// For other errors, rethrow
|
|
throw primaryError;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading configuration:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively merge configurations
|
|
* @param {Object} defaultConfig - Default configuration object
|
|
* @param {Object} userConfig - User configuration object
|
|
* @returns {Object} Merged configuration object
|
|
*/
|
|
function mergeConfigs(defaultConfig, userConfig) {
|
|
// Create a new object to avoid modifying the originals
|
|
const merged = {};
|
|
|
|
// Add all properties from default config
|
|
for (const key in defaultConfig) {
|
|
// If property exists in user config
|
|
if (key in userConfig) {
|
|
// If both are objects (not arrays or null), recursively merge
|
|
if (
|
|
typeof defaultConfig[key] === 'object' &&
|
|
defaultConfig[key] !== null &&
|
|
!Array.isArray(defaultConfig[key]) &&
|
|
typeof userConfig[key] === 'object' &&
|
|
userConfig[key] !== null &&
|
|
!Array.isArray(userConfig[key])
|
|
) {
|
|
merged[key] = mergeConfigs(defaultConfig[key], userConfig[key]);
|
|
} else {
|
|
// Use user value
|
|
merged[key] = userConfig[key];
|
|
}
|
|
} else {
|
|
// Property doesn't exist in user config, use default
|
|
merged[key] = defaultConfig[key];
|
|
}
|
|
}
|
|
|
|
// Add any properties from user config that aren't in default
|
|
for (const key in userConfig) {
|
|
if (!(key in defaultConfig)) {
|
|
merged[key] = userConfig[key];
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Save configuration to file
|
|
* @param {Object} config - Configuration object
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function saveConfig(config) {
|
|
try {
|
|
// Always try to save to the primary config location first
|
|
try {
|
|
// Make sure directory exists
|
|
await fs.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
|
|
await fs.writeFile(DEFAULT_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
console.log(`Configuration saved to ${DEFAULT_CONFIG_PATH}`);
|
|
return;
|
|
} catch (primaryError) {
|
|
console.warn(`Could not save config to ${DEFAULT_CONFIG_PATH}: ${primaryError.message}`);
|
|
console.warn('Trying fallback location...');
|
|
|
|
// If we couldn't save to the primary location, try the fallback
|
|
await fs.writeFile(FALLBACK_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
console.log(`Configuration saved to fallback location: ${FALLBACK_CONFIG_PATH}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving configuration:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the server
|
|
*/
|
|
async function startServer() {
|
|
try {
|
|
// Initialize the application
|
|
await initializeApp();
|
|
|
|
// Determine port
|
|
const port = config.port || process.env.PORT || DEFAULT_PORT;
|
|
|
|
// Create server based on configuration
|
|
if (config.securitySettings?.httpsEnabled &&
|
|
config.securitySettings?.sslCertPath &&
|
|
config.securitySettings?.sslKeyPath) {
|
|
try {
|
|
const sslOptions = {
|
|
key: await fs.readFile(config.securitySettings.sslKeyPath),
|
|
cert: await fs.readFile(config.securitySettings.sslCertPath)
|
|
};
|
|
|
|
server = https.createServer(sslOptions, app);
|
|
console.log('HTTPS server created');
|
|
} catch (error) {
|
|
console.error('Error creating HTTPS server, falling back to HTTP:', error);
|
|
server = http.createServer(app);
|
|
}
|
|
} else {
|
|
server = http.createServer(app);
|
|
console.log('HTTP server created');
|
|
}
|
|
|
|
// Start the server
|
|
server.listen(port, () => {
|
|
console.log(`Transmission RSS Manager server running on port ${port}`);
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Authentication middleware
|
|
function authenticateJWT(req, res, next) {
|
|
// Skip authentication if not enabled
|
|
if (!config.securitySettings?.authEnabled) {
|
|
return next();
|
|
}
|
|
|
|
// Get token from header
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader) {
|
|
return res.status(401).json({ success: false, message: 'Authentication required' });
|
|
}
|
|
|
|
const token = authHeader.split(' ')[1];
|
|
if (!token) {
|
|
return res.status(401).json({ success: false, message: 'Authentication token required' });
|
|
}
|
|
|
|
// Verify token
|
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
|
if (err) {
|
|
return res.status(403).json({ success: false, message: 'Invalid or expired token' });
|
|
}
|
|
|
|
req.user = user;
|
|
next();
|
|
});
|
|
}
|
|
|
|
// API Routes
|
|
|
|
// Status endpoint
|
|
app.get('/api/status', authenticateJWT, async (req, res) => {
|
|
try {
|
|
if (!isInitialized) {
|
|
return res.status(503).json({
|
|
success: false,
|
|
message: 'Application is initializing, please try again'
|
|
});
|
|
}
|
|
|
|
// Get Transmission connection status
|
|
const transmissionStatus = await transmissionClient.getStatus();
|
|
|
|
// Return status information
|
|
res.json({
|
|
success: true,
|
|
status: 'running',
|
|
version: APP_VERSION, // Use the dynamic APP_VERSION from package.json
|
|
transmissionConnected: transmissionStatus.connected,
|
|
transmissionVersion: transmissionStatus.version,
|
|
transmissionStats: {
|
|
downloadSpeed: transmissionStatus.downloadSpeed,
|
|
uploadSpeed: transmissionStatus.uploadSpeed,
|
|
activeTorrentCount: transmissionStatus.activeTorrentCount,
|
|
torrentCount: transmissionStatus.torrentCount
|
|
},
|
|
rssFeedManagerActive: !!rssFeedManager.updateIntervalId,
|
|
postProcessorActive: !!postProcessor.processIntervalId,
|
|
config: {
|
|
autoProcessing: config.autoProcessing,
|
|
rssEnabled: Array.isArray(config.rssFeeds) && config.rssFeeds.length > 0
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting status:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error getting status: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Configuration endpoint
|
|
app.get('/api/config', authenticateJWT, (req, res) => {
|
|
// Return configuration (remove sensitive information)
|
|
const sanitizedConfig = { ...config };
|
|
|
|
// Remove passwords from response
|
|
if (sanitizedConfig.transmissionConfig) {
|
|
delete sanitizedConfig.transmissionConfig.password;
|
|
}
|
|
|
|
if (sanitizedConfig.securitySettings?.users) {
|
|
sanitizedConfig.securitySettings.users = sanitizedConfig.securitySettings.users.map(user => ({
|
|
...user,
|
|
password: undefined
|
|
}));
|
|
}
|
|
|
|
res.json(sanitizedConfig);
|
|
});
|
|
|
|
app.post('/api/config', authenticateJWT, async (req, res) => {
|
|
try {
|
|
// Merge the new config with the existing one
|
|
const newConfig = { ...config, ...req.body };
|
|
|
|
// Preserve existing Transmission configuration values when not explicitly provided
|
|
if (newConfig.transmissionConfig && config.transmissionConfig) {
|
|
// First create a copy of the existing configuration
|
|
const preservedTransConfig = { ...config.transmissionConfig };
|
|
|
|
// Only update values that are explicitly provided and not empty
|
|
if (!req.body.transmissionConfig?.host) {
|
|
newConfig.transmissionConfig.host = preservedTransConfig.host;
|
|
}
|
|
|
|
if (!req.body.transmissionConfig?.port) {
|
|
newConfig.transmissionConfig.port = preservedTransConfig.port;
|
|
}
|
|
|
|
if (!req.body.transmissionConfig?.path) {
|
|
newConfig.transmissionConfig.path = preservedTransConfig.path;
|
|
}
|
|
|
|
if (!req.body.transmissionConfig?.username) {
|
|
newConfig.transmissionConfig.username = preservedTransConfig.username;
|
|
}
|
|
|
|
// Always preserve password if not provided
|
|
if (!newConfig.transmissionConfig.password) {
|
|
newConfig.transmissionConfig.password = preservedTransConfig.password;
|
|
}
|
|
}
|
|
|
|
// Preserve remote configuration settings if not explicitly provided
|
|
if (newConfig.remoteConfig && config.remoteConfig) {
|
|
// Make sure isRemote setting is preserved if not explicitly set
|
|
if (req.body.remoteConfig?.isRemote === undefined) {
|
|
newConfig.remoteConfig.isRemote = config.remoteConfig.isRemote;
|
|
}
|
|
|
|
// Preserve directory mappings if not provided
|
|
if (!req.body.remoteConfig?.directoryMapping && config.remoteConfig.directoryMapping) {
|
|
newConfig.remoteConfig.directoryMapping = { ...config.remoteConfig.directoryMapping };
|
|
}
|
|
}
|
|
|
|
// Keep user passwords
|
|
if (newConfig.securitySettings?.users && config.securitySettings?.users) {
|
|
newConfig.securitySettings.users = newConfig.securitySettings.users.map(newUser => {
|
|
const existingUser = config.securitySettings.users.find(u => u.username === newUser.username);
|
|
|
|
if (existingUser && !newUser.password) {
|
|
return { ...newUser, password: existingUser.password };
|
|
}
|
|
|
|
return newUser;
|
|
});
|
|
}
|
|
|
|
// Save the updated config
|
|
await saveConfig(newConfig);
|
|
|
|
// Update the application state
|
|
config = newConfig;
|
|
|
|
// Restart components if necessary
|
|
if (rssFeedManager) {
|
|
rssFeedManager.stop();
|
|
rssFeedManager = new RssFeedManager(config);
|
|
await rssFeedManager.start();
|
|
}
|
|
|
|
if (transmissionClient) {
|
|
transmissionClient = new TransmissionClient(config);
|
|
}
|
|
|
|
if (postProcessor) {
|
|
postProcessor.stop();
|
|
postProcessor = new PostProcessor(config, transmissionClient);
|
|
|
|
if (config.autoProcessing) {
|
|
postProcessor.start();
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Configuration updated successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Error updating configuration:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error updating configuration: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// RSS Feed API
|
|
app.get('/api/rss/feeds', authenticateJWT, (req, res) => {
|
|
try {
|
|
const feeds = rssFeedManager.getAllFeeds();
|
|
res.json({
|
|
success: true,
|
|
data: feeds
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error getting RSS feeds: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/rss/feeds', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const feed = req.body;
|
|
const newFeed = rssFeedManager.addFeed(feed);
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'RSS feed added successfully',
|
|
data: newFeed
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error adding RSS feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.put('/api/rss/feeds/:id', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const feedId = req.params.id;
|
|
const updates = req.body;
|
|
|
|
const result = rssFeedManager.updateFeedConfig(feedId, updates);
|
|
|
|
if (result) {
|
|
res.json({
|
|
success: true,
|
|
message: 'RSS feed updated successfully'
|
|
});
|
|
} else {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: 'RSS feed not found'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error updating RSS feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.delete('/api/rss/feeds/:id', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const feedId = req.params.id;
|
|
const result = rssFeedManager.removeFeed(feedId);
|
|
|
|
if (result) {
|
|
res.json({
|
|
success: true,
|
|
message: 'RSS feed deleted successfully'
|
|
});
|
|
} else {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: 'RSS feed not found'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error deleting RSS feed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/rss/items', authenticateJWT, (req, res) => {
|
|
try {
|
|
let items;
|
|
const filter = req.query.filter;
|
|
|
|
if (filter === 'undownloaded') {
|
|
items = rssFeedManager.getUndownloadedItems();
|
|
} else {
|
|
items = rssFeedManager.getAllItems();
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: items
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error getting RSS items: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/rss/download', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { itemId } = req.body;
|
|
|
|
if (!itemId) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Item ID is required'
|
|
});
|
|
}
|
|
|
|
// Find the item
|
|
const items = rssFeedManager.getAllItems();
|
|
const item = items.find(i => i.id === itemId);
|
|
|
|
if (!item) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: 'Item not found'
|
|
});
|
|
}
|
|
|
|
// Download the item
|
|
const result = await rssFeedManager.downloadItem(item, transmissionClient.client);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Item added to Transmission',
|
|
data: result.result
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.message
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error downloading item: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/rss/update', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const result = await rssFeedManager.updateAllFeeds();
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'RSS feeds updated successfully',
|
|
data: result
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error updating RSS feeds: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Transmission API
|
|
app.get('/api/transmission/torrents', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const result = await transmissionClient.getTorrents();
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
data: result.torrents
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.error
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error getting torrents: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/transmission/add', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { url } = req.body;
|
|
|
|
if (!url) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'URL is required'
|
|
});
|
|
}
|
|
|
|
const result = await transmissionClient.addTorrent(url);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Torrent added successfully',
|
|
data: result
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.error
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error adding torrent: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/transmission/start', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { ids } = req.body;
|
|
|
|
if (!ids) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
const result = await transmissionClient.startTorrents(ids);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: result.message
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.error
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error starting torrent: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/transmission/stop', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { ids } = req.body;
|
|
|
|
if (!ids) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
const result = await transmissionClient.stopTorrents(ids);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: result.message
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.error
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error stopping torrent: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/transmission/remove', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { ids, deleteLocalData } = req.body;
|
|
|
|
if (!ids) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Torrent ID(s) required'
|
|
});
|
|
}
|
|
|
|
const result = await transmissionClient.removeTorrents(ids, deleteLocalData);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: result.message
|
|
});
|
|
} else {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: result.error
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error removing torrent: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/transmission/test', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const { host, port, username, password, path: rpcPath } = req.body;
|
|
|
|
// Debug info
|
|
console.log('Testing Transmission connection with params:', {
|
|
host: host || 'from config',
|
|
port: port || 'from config',
|
|
username: username ? 'provided' : 'from config',
|
|
path: rpcPath || 'from config',
|
|
});
|
|
|
|
// Create a temporary client for testing
|
|
const testConfig = {
|
|
transmissionConfig: {
|
|
host: host || config.transmissionConfig.host,
|
|
port: port || config.transmissionConfig.port,
|
|
username: username || config.transmissionConfig.username,
|
|
password: password || config.transmissionConfig.password,
|
|
path: rpcPath || config.transmissionConfig.path
|
|
},
|
|
// Also include remoteConfig to ensure proper remote handling
|
|
remoteConfig: {
|
|
// If host is provided and different from localhost, set isRemote to true
|
|
isRemote: host && host !== 'localhost' ? true : config.remoteConfig?.isRemote || false,
|
|
directoryMapping: config.remoteConfig?.directoryMapping || {}
|
|
}
|
|
};
|
|
|
|
// Log the actual test config (without password)
|
|
console.log('Test configuration:', {
|
|
host: testConfig.transmissionConfig.host,
|
|
port: testConfig.transmissionConfig.port,
|
|
path: testConfig.transmissionConfig.path,
|
|
isRemote: testConfig.remoteConfig.isRemote
|
|
});
|
|
|
|
try {
|
|
const testClient = new TransmissionClient(testConfig);
|
|
const status = await testClient.getStatus();
|
|
|
|
if (status.connected) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Successfully connected to Transmission server',
|
|
data: {
|
|
version: status.version,
|
|
rpcVersion: status.rpcVersion
|
|
}
|
|
});
|
|
} else {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: `Failed to connect: ${status.error}`
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error testing Transmission connection:', error);
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error testing connection: ${error.message}`
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Connection test failed: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Post-Processor API
|
|
app.post('/api/post-processor/start', authenticateJWT, (req, res) => {
|
|
try {
|
|
const result = postProcessor.start();
|
|
|
|
if (result) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Post-processor started successfully'
|
|
});
|
|
} else {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor is already running'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error starting post-processor: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.post('/api/post-processor/stop', authenticateJWT, (req, res) => {
|
|
try {
|
|
const result = postProcessor.stop();
|
|
|
|
if (result) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Post-processor stopped successfully'
|
|
});
|
|
} else {
|
|
res.status(400).json({
|
|
success: false,
|
|
message: 'Post-processor is not running'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error stopping post-processor: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
// Media Library API
|
|
app.get('/api/media/library', authenticateJWT, async (req, res) => {
|
|
try {
|
|
const query = req.query.query;
|
|
const library = await getMediaLibrary(query);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: library
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Error getting media library: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get media library content
|
|
* @param {string} searchQuery - Optional search query
|
|
* @returns {Promise<Object>} Media library content
|
|
*/
|
|
async function getMediaLibrary(searchQuery) {
|
|
const library = {
|
|
movies: [],
|
|
tvShows: [],
|
|
music: [],
|
|
books: [],
|
|
magazines: [],
|
|
software: []
|
|
};
|
|
|
|
// Get destination paths from config
|
|
const destinations = config.destinationPaths || {};
|
|
|
|
// Process each category
|
|
for (const [category, destinationPath] of Object.entries(destinations)) {
|
|
if (!destinationPath || typeof destinationPath !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Check if directory exists
|
|
await fs.access(destinationPath);
|
|
|
|
// Get directory listing
|
|
const files = await fs.readdir(destinationPath, { withFileTypes: true });
|
|
|
|
// Process each file/directory
|
|
for (const file of files) {
|
|
const fullPath = path.join(destinationPath, file.name);
|
|
|
|
try {
|
|
const stats = await fs.stat(fullPath);
|
|
|
|
// Create an item object
|
|
const item = {
|
|
name: file.name,
|
|
path: fullPath,
|
|
isDirectory: stats.isDirectory(),
|
|
size: stats.size,
|
|
added: stats.ctime.toISOString()
|
|
};
|
|
|
|
// Filter by search query if provided
|
|
if (searchQuery && !file.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
continue;
|
|
}
|
|
|
|
// Add to the appropriate category
|
|
switch (category) {
|
|
case 'movies':
|
|
library.movies.push(item);
|
|
break;
|
|
case 'tvShows':
|
|
library.tvShows.push(item);
|
|
break;
|
|
case 'music':
|
|
library.music.push(item);
|
|
break;
|
|
case 'books':
|
|
library.books.push(item);
|
|
break;
|
|
case 'magazines':
|
|
library.magazines.push(item);
|
|
break;
|
|
case 'software':
|
|
library.software.push(item);
|
|
break;
|
|
}
|
|
} catch (itemError) {
|
|
console.error(`Error processing media library item ${fullPath}:`, itemError);
|
|
}
|
|
}
|
|
|
|
// Sort by most recently added
|
|
const sortKey = category === 'tvShows' ? 'tvShows' : category;
|
|
if (Array.isArray(library[sortKey])) {
|
|
library[sortKey].sort((a, b) => new Date(b.added) - new Date(a.added));
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error accessing media directory ${destinationPath}:`, error);
|
|
}
|
|
}
|
|
|
|
return library;
|
|
}
|
|
|
|
// Authentication API
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
try {
|
|
// Skip authentication if not enabled
|
|
if (!config.securitySettings?.authEnabled) {
|
|
return res.json({
|
|
success: true,
|
|
token: jwt.sign({ username: 'admin' }, JWT_SECRET, { expiresIn: JWT_EXPIRY }),
|
|
user: { username: 'admin', role: 'admin' }
|
|
});
|
|
}
|
|
|
|
const { username, password } = req.body;
|
|
|
|
if (!username || !password) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Username and password are required'
|
|
});
|
|
}
|
|
|
|
// Find user
|
|
const users = config.securitySettings?.users || [];
|
|
const user = users.find(u => u.username === username);
|
|
|
|
if (!user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid credentials'
|
|
});
|
|
}
|
|
|
|
// Check password
|
|
let passwordMatch = false;
|
|
|
|
if (user.password.startsWith('$2')) {
|
|
// Bcrypt hashed password
|
|
passwordMatch = await bcrypt.compare(password, user.password);
|
|
} else {
|
|
// Legacy plain text password
|
|
passwordMatch = password === user.password;
|
|
|
|
// Update to hashed password
|
|
if (passwordMatch) {
|
|
user.password = await bcrypt.hash(password, 10);
|
|
await saveConfig(config);
|
|
}
|
|
}
|
|
|
|
if (!passwordMatch) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Invalid credentials'
|
|
});
|
|
}
|
|
|
|
// Generate token
|
|
const token = jwt.sign(
|
|
{ username: user.username, role: user.role || 'user' },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRY }
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
token,
|
|
user: {
|
|
username: user.username,
|
|
role: user.role || 'user'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: `Authentication error: ${error.message}`
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/auth/validate', authenticateJWT, (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
user: req.user
|
|
});
|
|
});
|
|
|
|
// System status and update endpoints
|
|
app.get('/api/system/status', authenticateJWT, async (req, res) => {
|
|
try {
|
|
// Get system uptime
|
|
const uptimeSeconds = Math.floor(process.uptime());
|
|
const hours = Math.floor(uptimeSeconds / 3600);
|
|
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
|
const seconds = uptimeSeconds % 60;
|
|
const uptime = `${hours}h ${minutes}m ${seconds}s`;
|
|
|
|
// Check transmission connection
|
|
let transmissionStatus = 'Connected';
|
|
try {
|
|
const status = await transmissionClient.getStatus();
|
|
if (!status.connected) {
|
|
transmissionStatus = 'Disconnected';
|
|
}
|
|
} catch (err) {
|
|
transmissionStatus = 'Disconnected';
|
|
}
|
|
|
|
res.json({
|
|
status: 'success',
|
|
data: {
|
|
version: APP_VERSION,
|
|
uptime,
|
|
transmissionStatus
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting system status:', error);
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to get system status'
|
|
});
|
|
}
|
|
});
|
|
|
|
// Check for updates
|
|
app.get('/api/system/check-updates', authenticateJWT, async (req, res) => {
|
|
try {
|
|
// Check if git is available and if this is a git repository
|
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
|
if (!isGitRepo) {
|
|
return res.json({
|
|
status: 'error',
|
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
|
});
|
|
}
|
|
|
|
// Get current version
|
|
const currentVersion = APP_VERSION;
|
|
|
|
// Check for test mode flag which forces update availability for testing
|
|
const testMode = req.query.test === 'true';
|
|
|
|
if (testMode) {
|
|
// In test mode, always return that an update is available
|
|
return res.json({
|
|
status: 'success',
|
|
data: {
|
|
updateAvailable: true,
|
|
currentVersion,
|
|
remoteVersion: '2.1.0-test',
|
|
commitsBehind: 1,
|
|
testMode: true
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
// Normal mode - fetch latest updates without applying them
|
|
await execAsync('git fetch');
|
|
|
|
// Check if we're behind the remote repository
|
|
const { stdout } = await execAsync('git rev-list HEAD..origin/main --count');
|
|
const behindCount = parseInt(stdout.trim());
|
|
|
|
if (behindCount > 0) {
|
|
// Get the new version from the remote package.json
|
|
const { stdout: remotePackageJson } = await execAsync('git show origin/main:package.json');
|
|
const remotePackage = JSON.parse(remotePackageJson);
|
|
const remoteVersion = remotePackage.version;
|
|
|
|
// Compare versions semantically - only consider it an update if remote version is higher
|
|
const isNewerVersion = semver.gt(remoteVersion, currentVersion);
|
|
|
|
return res.json({
|
|
status: 'success',
|
|
data: {
|
|
updateAvailable: isNewerVersion,
|
|
currentVersion,
|
|
remoteVersion,
|
|
commitsBehind: behindCount,
|
|
newerVersion: isNewerVersion
|
|
}
|
|
});
|
|
} else {
|
|
return res.json({
|
|
status: 'success',
|
|
data: {
|
|
updateAvailable: false,
|
|
currentVersion
|
|
}
|
|
});
|
|
}
|
|
} catch (gitError) {
|
|
console.error('Git error checking for updates:', gitError);
|
|
|
|
// Even if git commands fail, return a valid response with error information
|
|
return res.json({
|
|
status: 'error',
|
|
message: 'Error checking git repository: ' + gitError.message,
|
|
data: {
|
|
updateAvailable: false,
|
|
currentVersion,
|
|
error: true,
|
|
errorDetails: gitError.message
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking for updates:', error);
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to check for updates: ' + error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Apply updates
|
|
app.post('/api/system/update', authenticateJWT, async (req, res) => {
|
|
try {
|
|
// Check if git is available and if this is a git repository
|
|
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
|
|
if (!isGitRepo) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'This installation is not set up for updates. Please use the bootstrap installer.'
|
|
});
|
|
}
|
|
|
|
// Run the update script
|
|
const updateScriptPath = path.join(__dirname, 'scripts', 'update.sh');
|
|
|
|
// Make sure the update script is executable
|
|
await execAsync(`chmod +x ${updateScriptPath}`);
|
|
|
|
// Execute the update script
|
|
const { stdout, stderr } = await execAsync(updateScriptPath);
|
|
|
|
// If we get here, the update was successful
|
|
// The service will be restarted by the update script
|
|
res.json({
|
|
status: 'success',
|
|
message: 'Update applied successfully. The service will restart.',
|
|
data: {
|
|
output: stdout,
|
|
errors: stderr
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error applying update:', error);
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to apply update: ' + error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Catch-all route for SPA
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Start the server
|
|
startServer();
|
|
|
|
// Handle process termination
|
|
process.on('SIGTERM', () => {
|
|
console.log('SIGTERM received, shutting down gracefully');
|
|
|
|
if (server) {
|
|
server.close(() => {
|
|
console.log('Server closed');
|
|
process.exit(0);
|
|
});
|
|
} else {
|
|
process.exit(0);
|
|
}
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('SIGINT received, shutting down gracefully');
|
|
|
|
if (server) {
|
|
server.close(() => {
|
|
console.log('Server closed');
|
|
process.exit(0);
|
|
});
|
|
} else {
|
|
process.exit(0);
|
|
}
|
|
});
|
|
|
|
module.exports = app; |