- Fixed update detection in install scripts - Added Git availability checks to update system - Improved error handling for update endpoint - Added detailed Git requirements to README - Added troubleshooting section for update issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1501 lines
42 KiB
JavaScript
1501 lines
42 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()}`
|
|
];
|
|
|
|
console.log(`Attempting to load module: ${baseName}`);
|
|
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 endpoints - consolidated from server-endpoints.js
|
|
// Ensure TransmissionClient has the necessary methods
|
|
if (!TransmissionClient.prototype.sessionGet && TransmissionClient.prototype.getStatus) {
|
|
// Add compatibility method if missing
|
|
TransmissionClient.prototype.sessionGet = async function() {
|
|
return this.getStatus();
|
|
};
|
|
}
|
|
|
|
// System status endpoint
|
|
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.sessionGet();
|
|
if (!status || (status.connected === false)) {
|
|
transmissionStatus = 'Disconnected';
|
|
}
|
|
} catch (err) {
|
|
console.error('Transmission connection error:', 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
|
|
let isGitRepo = false;
|
|
let isGitAvailable = false;
|
|
|
|
try {
|
|
// First check if git command is available
|
|
await execAsync('which git');
|
|
isGitAvailable = true;
|
|
|
|
// Then check if directory is a git repository
|
|
isGitRepo = await fs.access(path.join(__dirname, '.git'))
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
} catch (error) {
|
|
console.error('Error checking git availability:', error);
|
|
isGitAvailable = false;
|
|
}
|
|
|
|
if (!isGitAvailable) {
|
|
return res.json({
|
|
status: 'error',
|
|
message: 'Git is not installed or not available. Please install Git to enable updates.'
|
|
});
|
|
}
|
|
|
|
if (!isGitRepo) {
|
|
return res.json({
|
|
status: 'error',
|
|
message: 'This installation is not set up as a Git repository. 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
|
|
let isGitRepo = false;
|
|
let isGitAvailable = false;
|
|
|
|
try {
|
|
// First check if git command is available
|
|
await execAsync('which git');
|
|
isGitAvailable = true;
|
|
|
|
// Then check if directory is a git repository
|
|
isGitRepo = await fs.access(path.join(__dirname, '.git'))
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
} catch (error) {
|
|
console.error('Error checking git availability:', error);
|
|
isGitAvailable = false;
|
|
}
|
|
|
|
if (!isGitAvailable) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'Git is not installed or not available. Please install Git to enable updates.'
|
|
});
|
|
}
|
|
|
|
if (!isGitRepo) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'This installation is not set up as a Git repository. 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; |