/** * Transmission RSS Manager - Main Application Script * @description Core functionality for the web interface */ // Application state const appState = { darkMode: localStorage.getItem('darkMode') === 'true', currentTab: 'home', notifications: [], config: null, feeds: [], items: [], torrents: [], library: {}, isLoading: false, authToken: localStorage.getItem('authToken') || null, user: null }; // Initialize app when DOM is loaded document.addEventListener('DOMContentLoaded', () => { initializeApp(); }); /** * Initialize the application */ function initializeApp() { // Apply theme based on saved preference applyTheme(); // Add event listeners addEventListeners(); // Check authentication status checkAuthStatus(); // Load initial data loadInitialData(); // Initialize notifications system initNotifications(); // Initialize system status (new in v2.0.0) if (typeof initSystemStatus === 'function') { initSystemStatus(); } } /** * Apply theme (light/dark) to the document */ function applyTheme() { if (appState.darkMode) { document.documentElement.setAttribute('data-theme', 'dark'); document.getElementById('theme-toggle-icon').className = 'fas fa-sun'; } else { document.documentElement.setAttribute('data-theme', 'light'); document.getElementById('theme-toggle-icon').className = 'fas fa-moon'; } } /** * Set up all event listeners */ function addEventListeners() { // Theme toggle document.getElementById('theme-toggle').addEventListener('click', toggleTheme); // Tab navigation document.querySelectorAll('.nav-link').forEach(link => { link.addEventListener('click', (e) => { e.preventDefault(); const tabId = e.currentTarget.getAttribute('data-tab'); switchTab(tabId); }); }); // Action buttons document.getElementById('btn-update-feeds').addEventListener('click', updateFeeds); document.getElementById('btn-add-feed').addEventListener('click', showAddFeedModal); document.getElementById('add-torrent-form').addEventListener('submit', handleAddTorrent); // Form submissions document.getElementById('settings-form').addEventListener('submit', saveSettings); document.getElementById('login-form')?.addEventListener('submit', handleLogin); // Processor controls document.getElementById('btn-start-processor').addEventListener('click', startProcessor); document.getElementById('btn-stop-processor').addEventListener('click', stopProcessor); // Filter handlers document.querySelectorAll('input[name="item-filter"]').forEach(input => { input.addEventListener('change', loadRssItems); }); document.querySelectorAll('input[name="library-filter"]').forEach(input => { input.addEventListener('change', filterLibrary); }); // Search handlers document.getElementById('library-search')?.addEventListener('input', debounce(searchLibrary, 300)); } /** * Check authentication status */ function checkAuthStatus() { if (!appState.authToken) { // If login form exists, show it; otherwise we're on a public page const loginForm = document.getElementById('login-form'); if (loginForm) { document.getElementById('app-container').classList.add('d-none'); document.getElementById('login-container').classList.remove('d-none'); } return; } // Validate token fetch('/api/auth/validate', { headers: { 'Authorization': `Bearer ${appState.authToken}` } }) .then(response => { if (!response.ok) throw new Error('Invalid token'); return response.json(); }) .then(data => { appState.user = data.user; updateUserInfo(); // Show app content if (document.getElementById('login-container')) { document.getElementById('login-container').classList.add('d-none'); document.getElementById('app-container').classList.remove('d-none'); } }) .catch(error => { console.error('Auth validation error:', error); localStorage.removeItem('authToken'); appState.authToken = null; appState.user = null; // Show login if present if (document.getElementById('login-form')) { document.getElementById('app-container').classList.add('d-none'); document.getElementById('login-container').classList.remove('d-none'); } }); } /** * Handle user login * @param {Event} e - Submit event */ function handleLogin(e) { e.preventDefault(); const username = document.getElementById('username').value; const password = document.getElementById('password').value; setLoading(true); fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }) .then(response => { if (!response.ok) throw new Error('Invalid credentials'); return response.json(); }) .then(data => { localStorage.setItem('authToken', data.token); appState.authToken = data.token; appState.user = data.user; document.getElementById('login-container').classList.add('d-none'); document.getElementById('app-container').classList.remove('d-none'); // Load data loadInitialData(); showNotification('Welcome back!', 'success'); }) .catch(error => { showNotification('Login failed: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Update user info in the UI */ function updateUserInfo() { if (!appState.user) return; const userInfoEl = document.getElementById('user-info'); if (userInfoEl) { userInfoEl.textContent = appState.user.username; } } /** * Toggle between light and dark theme */ function toggleTheme() { appState.darkMode = !appState.darkMode; localStorage.setItem('darkMode', appState.darkMode); applyTheme(); } /** * Switch to a different tab * @param {string} tabId - ID of the tab to switch to */ function switchTab(tabId) { // Update active tab document.querySelectorAll('.nav-link').forEach(link => { link.classList.toggle('active', link.getAttribute('data-tab') === tabId); }); // Show the selected tab content document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.toggle('active', tab.id === tabId); }); // Update state appState.currentTab = tabId; // Load tab-specific data if (tabId === 'torrents-tab') { loadTorrents(); } else if (tabId === 'rss-tab') { loadRssFeeds(); loadRssItems(); } else if (tabId === 'media-tab') { loadLibrary(); } else if (tabId === 'settings-tab') { loadSettings(); } } /** * Load initial data for the application */ function loadInitialData() { setLoading(true); // Load system status first loadSystemStatus() .then(() => { // Then load data for the current tab switch (appState.currentTab) { case 'home': loadTorrents(); break; case 'torrents-tab': loadTorrents(); break; case 'rss-tab': loadRssFeeds(); loadRssItems(); break; case 'media-tab': loadLibrary(); break; case 'settings-tab': loadSettings(); break; } }) .finally(() => { setLoading(false); }); } /** * Load system status information * @returns {Promise} - Resolves when data is loaded */ function loadSystemStatus() { return fetch('/api/status', { headers: authHeaders() }) .then(handleResponse) .then(data => { updateStatusDisplay(data); return data; }) .catch(error => { showNotification('Error loading status: ' + error.message, 'danger'); }); } /** * Update the status display with current system status * @param {Object} data - Status data from API */ function updateStatusDisplay(data) { const statusContainer = document.getElementById('status-container'); if (!statusContainer) return; const statusHtml = `
System Status
Transmission
Post-Processor
RSS Manager
No active torrents.
'; return; } let html = `Name | Status | Progress | Size | Ratio | Actions |
---|---|---|---|---|---|
${torrent.name} | ${status} |
|
${size} | ${torrent.uploadRatio.toFixed(2)} | ${torrent.status === 0 ? `` : `` } |
No RSS feeds configured. Click "Add New Feed" to add one.
'; return; } let html = `Name | URL | Auto-Download | Actions |
---|---|---|---|
${feed.name} | ${feed.url} | ${feed.autoDownload ? 'Yes' : 'No'} |
No RSS items found.
'; return; } let html = `Title | Feed | Size | Date | Actions |
---|---|---|---|---|
${item.title} | ${feed.name} | ${size} | ${date} |
No media files in library.
'; return; } let html = ''; categories.forEach(category => { const items = appState.library[category]; if (!items || items.length === 0) { return; } const displayName = getCategoryTitle(category); html += `Added: ${new Date(item.added).toLocaleDateString()}
No media files match the selected category.
'; } /** * Filter the library display based on selected category */ function filterLibrary() { updateLibraryDisplay(); } /** * Search the library * @param {Event} e - Input event */ function searchLibrary(e) { const query = e.target.value.toLowerCase(); if (!query) { // If search is cleared, show everything updateLibraryDisplay(); return; } setLoading(true); fetch(`/api/media/library?query=${encodeURIComponent(query)}`, { headers: authHeaders() }) .then(handleResponse) .then(data => { // Temporarily update the library for display const originalLibrary = appState.library; appState.library = data.data || {}; updateLibraryDisplay(); // Restore original library data appState.library = originalLibrary; }) .catch(error => { showNotification('Error searching library: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Open a media file * @param {string} path - Path to the media file */ function openMediaFile(path) { // In a real application, this would open the file in a media player // For now, we'll just open a notification showNotification(`Opening file: ${path}`, 'info'); // In a real app, you might do: // window.open(`/media/stream?path=${encodeURIComponent(path)}`, '_blank'); } /** * Load settings data from the server */ function loadSettings() { setLoading(true); fetch('/api/config', { headers: authHeaders() }) .then(handleResponse) .then(data => { appState.config = data; updateSettingsForm(); }) .catch(error => { showNotification('Error loading settings: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Update the settings form with current configuration */ function updateSettingsForm() { const config = appState.config; if (!config) return; // Transmission settings document.getElementById('transmission-host').value = config.transmissionConfig?.host || ''; document.getElementById('transmission-port').value = config.transmissionConfig?.port || ''; document.getElementById('transmission-user').value = config.transmissionConfig?.username || ''; // Don't set password field for security // Processing settings document.getElementById('seeding-ratio').value = config.seedingRequirements?.minRatio || ''; document.getElementById('seeding-time').value = config.seedingRequirements?.minTimeMinutes || ''; document.getElementById('check-interval').value = config.seedingRequirements?.checkIntervalSeconds || ''; // Path settings document.getElementById('movies-path').value = config.destinationPaths?.movies || ''; document.getElementById('tvshows-path').value = config.destinationPaths?.tvShows || ''; document.getElementById('music-path').value = config.destinationPaths?.music || ''; document.getElementById('books-path').value = config.destinationPaths?.books || ''; document.getElementById('magazines-path').value = config.destinationPaths?.magazines || ''; document.getElementById('software-path').value = config.destinationPaths?.software || ''; // Processing options document.getElementById('extract-archives').checked = config.processingOptions?.extractArchives || false; document.getElementById('delete-archives').checked = config.processingOptions?.deleteArchives || false; document.getElementById('create-category-folders').checked = config.processingOptions?.createCategoryFolders || false; document.getElementById('rename-files').checked = config.processingOptions?.renameFiles || false; document.getElementById('ignore-sample').checked = config.processingOptions?.ignoreSample || false; document.getElementById('ignore-extras').checked = config.processingOptions?.ignoreExtras || false; // RSS settings document.getElementById('rss-interval').value = config.rssUpdateIntervalMinutes || ''; // Security settings if they exist if (document.getElementById('https-enabled')) { document.getElementById('https-enabled').checked = config.securitySettings?.httpsEnabled || false; document.getElementById('auth-enabled').checked = config.securitySettings?.authEnabled || false; } } /** * Save application settings * @param {Event} e - Form submit event */ function saveSettings(e) { e.preventDefault(); const settingsData = { transmissionConfig: { host: document.getElementById('transmission-host').value.trim(), port: parseInt(document.getElementById('transmission-port').value.trim(), 10), username: document.getElementById('transmission-user').value.trim() }, seedingRequirements: { minRatio: parseFloat(document.getElementById('seeding-ratio').value.trim()), minTimeMinutes: parseInt(document.getElementById('seeding-time').value.trim(), 10), checkIntervalSeconds: parseInt(document.getElementById('check-interval').value.trim(), 10) }, destinationPaths: { movies: document.getElementById('movies-path').value.trim(), tvShows: document.getElementById('tvshows-path').value.trim(), music: document.getElementById('music-path').value.trim(), books: document.getElementById('books-path').value.trim(), magazines: document.getElementById('magazines-path').value.trim(), software: document.getElementById('software-path').value.trim() }, processingOptions: { extractArchives: document.getElementById('extract-archives').checked, deleteArchives: document.getElementById('delete-archives').checked, createCategoryFolders: document.getElementById('create-category-folders').checked, renameFiles: document.getElementById('rename-files').checked, ignoreSample: document.getElementById('ignore-sample').checked, ignoreExtras: document.getElementById('ignore-extras').checked }, rssUpdateIntervalMinutes: parseInt(document.getElementById('rss-interval').value.trim(), 10) }; // Add password only if provided const password = document.getElementById('transmission-pass').value.trim(); if (password) { settingsData.transmissionConfig.password = password; } // Add security settings if they exist if (document.getElementById('https-enabled')) { settingsData.securitySettings = { httpsEnabled: document.getElementById('https-enabled').checked, authEnabled: document.getElementById('auth-enabled').checked }; } setLoading(true); fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(settingsData) }) .then(handleResponse) .then(data => { showNotification('Settings saved successfully', 'success'); loadSettings(); // Reload settings to get the updated values loadSystemStatus(); // Reload status to reflect any changes }) .catch(error => { showNotification('Error saving settings: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Test the connection to the Transmission server */ function testTransmissionConnection() { const host = document.getElementById('transmission-host').value.trim(); const port = document.getElementById('transmission-port').value.trim(); const username = document.getElementById('transmission-user').value.trim(); const password = document.getElementById('transmission-pass').value.trim(); setLoading(true); fetch('/api/transmission/test', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify({ host, port, username, password }) }) .then(handleResponse) .then(data => { if (data.success) { showNotification(`${data.message} (Version: ${data.data.version})`, 'success'); } else { showNotification(data.message, 'danger'); } }) .catch(error => { showNotification('Error testing connection: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Start the post-processor */ function startProcessor() { setLoading(true); fetch('/api/post-processor/start', { method: 'POST', headers: authHeaders() }) .then(handleResponse) .then(data => { showNotification('Post-processor started successfully', 'success'); loadSystemStatus(); }) .catch(error => { showNotification('Error starting post-processor: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Stop the post-processor */ function stopProcessor() { setLoading(true); fetch('/api/post-processor/stop', { method: 'POST', headers: authHeaders() }) .then(handleResponse) .then(data => { showNotification('Post-processor stopped successfully', 'success'); loadSystemStatus(); }) .catch(error => { showNotification('Error stopping post-processor: ' + error.message, 'danger'); }) .finally(() => { setLoading(false); }); } /** * Initialize the notifications system */ function initNotifications() { // Create notifications container if it doesn't exist if (!document.getElementById('notifications-container')) { const container = document.createElement('div'); container.id = 'notifications-container'; container.style.position = 'fixed'; container.style.top = '20px'; container.style.right = '20px'; container.style.zIndex = '1060'; document.body.appendChild(container); } } /** * Show a notification message * @param {string} message - Message to display * @param {string} type - Type of notification (success, danger, warning, info) */ function showNotification(message, type = 'info') { const container = document.getElementById('notifications-container'); const notification = document.createElement('div'); notification.className = `alert alert-${type}`; notification.innerHTML = message; notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; container.appendChild(notification); // Fade in setTimeout(() => { notification.style.opacity = '1'; notification.style.transform = 'translateY(0)'; }, 10); // Auto-remove after a delay setTimeout(() => { notification.style.opacity = '0'; notification.style.transform = 'translateY(-20px)'; setTimeout(() => { notification.remove(); }, 300); }, 5000); } /** * Get the title display name for a category * @param {string} category - Category key * @returns {string} - Formatted category title */ function getCategoryTitle(category) { switch(category) { case 'movies': return 'Movies'; case 'tvShows': return 'TV Shows'; case 'music': return 'Music'; case 'books': return 'Books'; case 'magazines': return 'Magazines'; case 'software': return 'Software'; default: return category.charAt(0).toUpperCase() + category.slice(1); } } /** * Get the status string for a torrent * @param {Object} torrent - Torrent object * @returns {string} - Status string */ function getTorrentStatus(torrent) { const statusMap = { 0: 'Stopped', 1: 'Check Waiting', 2: 'Checking', 3: 'Download Waiting', 4: 'Downloading', 5: 'Seed Waiting', 6: 'Seeding' }; return statusMap[torrent.status] || 'Unknown'; } /** * Format bytes to human-readable size * @param {number} bytes - Size in bytes * @param {number} decimals - Number of decimal places * @returns {string} - Formatted size string */ function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } /** * Set the loading state of the application * @param {boolean} isLoading - Whether the app is loading */ function setLoading(isLoading) { appState.isLoading = isLoading; // Update loading spinner if it exists const spinner = document.getElementById('loading-spinner'); if (spinner) { spinner.style.display = isLoading ? 'block' : 'none'; } } /** * Create auth headers including token if available * @returns {Object} - Headers object */ function authHeaders() { return appState.authToken ? { 'Authorization': `Bearer ${appState.authToken}` } : {}; } /** * Handle API response with error checking * @param {Response} response - Fetch API response * @returns {Promise} - Resolves to response data */ function handleResponse(response) { if (!response.ok) { // Try to get error message from response return response.json() .then(data => { throw new Error(data.message || `HTTP error ${response.status}`); }) .catch(e => { // If JSON parsing fails, throw generic error if (e instanceof SyntaxError) { throw new Error(`HTTP error ${response.status}`); } throw e; }); } return response.json(); } /** * Create a debounced version of a function * @param {Function} func - Function to debounce * @param {number} wait - Milliseconds to wait * @returns {Function} - Debounced function */ function debounce(func, wait) { let timeout; return function(...args) { const context = this; clearTimeout(timeout); timeout = setTimeout(() => func.apply(context, args), wait); }; }