MasterDraco 2705989ff6 Fix version display with direct package.json reading
- Added direct package.json file reading instead of require caching
- Enhanced system status endpoint to always return current version
- Added manual refresh button to floating notification
- Implemented aggressive cache-busting for all version checks
- Added double-check system status after update completion
- Added detailed logging for version retrieval for debugging
- Improved error handling for package.json reading
- Added immediate user feedback for refresh operations

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-10 18:33:53 +00:00

1529 lines
44 KiB
JavaScript

/**
* Transmission RSS Manager
* Main Server Application
*/
const express = require('express');
const path = require('path');
const fsPromises = require('fs').promises;
const fs = require('fs'); // Regular fs module for synchronous operations
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)
// Re-read the package.json file each time to ensure we get the latest version
const APP_VERSION = (() => {
try {
// Use synchronous file read to ensure we have the version before continuing
const packageJsonPath = path.join(__dirname, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
return packageJson.version;
} catch (err) {
console.error('Error reading package.json version:', err);
// Fallback to requiring package.json if file read fails
const PACKAGE_JSON = require('./package.json');
return 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 fsPromises.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 fsPromises.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 fsPromises.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
await fsPromises.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 fsPromises.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
await fsPromises.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 fsPromises.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 fsPromises.mkdir(path.dirname(DEFAULT_CONFIG_PATH), { recursive: true });
await fsPromises.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 fsPromises.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 fsPromises.readFile(config.securitySettings.sslKeyPath),
cert: await fsPromises.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 fsPromises.access(destinationPath);
// Get directory listing
const files = await fsPromises.readdir(destinationPath, { withFileTypes: true });
// Process each file/directory
for (const file of files) {
const fullPath = path.join(destinationPath, file.name);
try {
const stats = await fsPromises.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';
}
// Read version directly from package.json to ensure it's always current
let currentVersion = APP_VERSION;
try {
const packageJsonPath = path.join(__dirname, 'package.json');
const packageJsonContent = await fsPromises.readFile(packageJsonPath, 'utf8');
const packageData = JSON.parse(packageJsonContent);
currentVersion = packageData.version;
// Log the version for debugging
console.log(`System status endpoint returning version: ${currentVersion}`);
} catch (err) {
console.error('Error reading package.json in status endpoint:', err);
// Fall back to the cached APP_VERSION
}
res.json({
status: 'success',
data: {
version: currentVersion,
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 fsPromises.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 fsPromises.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;