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

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;