diff --git a/src/Api/Controllers/ConfigController.cs b/src/Api/Controllers/ConfigController.cs index fdfc413..8fb7841 100644 --- a/src/Api/Controllers/ConfigController.cs +++ b/src/Api/Controllers/ConfigController.cs @@ -1,3 +1,8 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -33,17 +38,79 @@ namespace TransmissionRssManager.Api.Controllers host = config.Transmission.Host, port = config.Transmission.Port, useHttps = config.Transmission.UseHttps, - hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username) + hasCredentials = !string.IsNullOrEmpty(config.Transmission.Username), + username = config.Transmission.Username }, + transmissionInstances = config.TransmissionInstances?.Select(i => new + { + id = i.Key, + name = i.Value.Host, + host = i.Value.Host, + port = i.Value.Port, + useHttps = i.Value.UseHttps, + hasCredentials = !string.IsNullOrEmpty(i.Value.Username), + username = i.Value.Username + }), autoDownloadEnabled = config.AutoDownloadEnabled, checkIntervalMinutes = config.CheckIntervalMinutes, downloadDirectory = config.DownloadDirectory, mediaLibraryPath = config.MediaLibraryPath, - postProcessing = config.PostProcessing + postProcessing = config.PostProcessing, + enableDetailedLogging = config.EnableDetailedLogging, + userPreferences = config.UserPreferences }; return Ok(sanitizedConfig); } + + [HttpGet("defaults")] + public IActionResult GetDefaultConfig() + { + // Return default configuration settings + var defaultConfig = new + { + transmission = new + { + host = "localhost", + port = 9091, + username = "", + useHttps = false + }, + autoDownloadEnabled = true, + checkIntervalMinutes = 30, + downloadDirectory = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + mediaLibraryPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + postProcessing = new + { + enabled = false, + extractArchives = true, + organizeMedia = true, + minimumSeedRatio = 1, + mediaExtensions = new[] { ".mp4", ".mkv", ".avi" }, + autoOrganizeByMediaType = true, + renameFiles = false, + compressCompletedFiles = false, + deleteCompletedAfterDays = 0 + }, + enableDetailedLogging = false, + userPreferences = new + { + enableDarkMode = false, + autoRefreshUIEnabled = true, + autoRefreshIntervalSeconds = 30, + notificationsEnabled = true, + notificationEvents = new[] { "torrent-added", "torrent-completed", "torrent-error" }, + defaultView = "dashboard", + confirmBeforeDelete = true, + maxItemsPerPage = 25, + dateTimeFormat = "yyyy-MM-dd HH:mm:ss", + showCompletedTorrents = true, + keepHistoryDays = 30 + } + }; + + return Ok(defaultConfig); + } [HttpPut] public async Task UpdateConfig([FromBody] AppConfig config) @@ -59,5 +126,93 @@ namespace TransmissionRssManager.Api.Controllers await _configService.SaveConfigurationAsync(config); return Ok(new { success = true }); } + + [HttpPost("backup")] + public IActionResult BackupConfig() + { + try + { + // Get the current config + var config = _configService.GetConfiguration(); + + // Serialize to JSON with indentation + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(config, options); + + // Create a memory stream from the JSON + var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + // Set the content disposition and type + var fileName = $"transmission-rss-config-backup-{DateTime.Now:yyyy-MM-dd}.json"; + return File(stream, "application/json", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating configuration backup"); + return StatusCode(500, "Error creating configuration backup"); + } + } + + [HttpPost("reset")] + public async Task ResetConfig() + { + try + { + // Create a default config + var defaultConfig = new AppConfig + { + Transmission = new TransmissionConfig + { + Host = "localhost", + Port = 9091, + Username = "", + Password = "", + UseHttps = false + }, + AutoDownloadEnabled = true, + CheckIntervalMinutes = 30, + DownloadDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"), + MediaLibraryPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Media"), + PostProcessing = new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1.0f, + MediaExtensions = new[] { ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".m4v", ".mpg", ".mpeg", ".flv", ".webm" }, + AutoOrganizeByMediaType = true, + RenameFiles = false, + CompressCompletedFiles = false, + DeleteCompletedAfterDays = 0 + }, + UserPreferences = new UserPreferencesConfig + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true, + NotificationEvents = new[] { "torrent-added", "torrent-completed", "torrent-error" }, + DefaultView = "dashboard", + ConfirmBeforeDelete = true, + MaxItemsPerPage = 25, + DateTimeFormat = "yyyy-MM-dd HH:mm:ss", + ShowCompletedTorrents = true, + KeepHistoryDays = 30 + }, + Feeds = new List(), + EnableDetailedLogging = false + }; + + // Save the default config + await _configService.SaveConfigurationAsync(defaultConfig); + + return Ok(new { success = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resetting configuration"); + return StatusCode(500, "Error resetting configuration"); + } + } } } \ No newline at end of file diff --git a/src/Web/wwwroot/js/app.js b/src/Web/wwwroot/js/app.js index adb594a..29e9661 100644 --- a/src/Web/wwwroot/js/app.js +++ b/src/Web/wwwroot/js/app.js @@ -8,6 +8,12 @@ document.addEventListener('DOMContentLoaded', function() { // Load initial dashboard data loadDashboardData(); + // Set up dark mode based on user preference + initDarkMode(); + + // Set up auto refresh if enabled + initAutoRefresh(); + // Initialize Bootstrap tooltips const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); tooltips.forEach(tooltip => new bootstrap.Tooltip(tooltip)); @@ -86,15 +92,65 @@ function initEventListeners() { document.getElementById('btn-refresh-torrents').addEventListener('click', loadTorrents); document.getElementById('save-torrent-btn').addEventListener('click', saveTorrent); + // Logs page + document.getElementById('btn-refresh-logs').addEventListener('click', refreshLogs); + document.getElementById('btn-clear-logs').addEventListener('click', clearLogs); + document.getElementById('btn-apply-log-filters').addEventListener('click', applyLogFilters); + document.getElementById('btn-reset-log-filters').addEventListener('click', resetLogFilters); + document.getElementById('btn-export-logs').addEventListener('click', exportLogs); + // Settings page document.getElementById('settings-form').addEventListener('submit', saveSettings); + document.getElementById('dark-mode-toggle').addEventListener('click', toggleDarkMode); + document.getElementById('btn-reset-settings').addEventListener('click', resetSettings); + + // Configuration operations + document.getElementById('btn-backup-config').addEventListener('click', backupConfig); + document.getElementById('btn-reset-config').addEventListener('click', resetConfig); + + // Additional Transmission Instances + document.getElementById('add-transmission-instance').addEventListener('click', addTransmissionInstance); } // Dashboard function loadDashboardData() { - loadSystemStatus(); - loadRecentMatches(); + // Fetch dashboard statistics + fetch('/api/dashboard/stats') + .then(response => response.json()) + .then(stats => { + document.getElementById('active-downloads').textContent = stats.activeDownloads; + document.getElementById('seeding-torrents').textContent = stats.seedingTorrents; + document.getElementById('active-feeds').textContent = stats.activeFeeds; + document.getElementById('completed-today').textContent = stats.completedToday; + + document.getElementById('added-today').textContent = stats.addedToday; + document.getElementById('feeds-count').textContent = stats.feedsCount; + document.getElementById('matched-count').textContent = stats.matchedCount; + + // Format download/upload speeds + const downloadSpeed = formatBytes(stats.downloadSpeed) + '/s'; + const uploadSpeed = formatBytes(stats.uploadSpeed) + '/s'; + document.getElementById('download-speed').textContent = downloadSpeed; + document.getElementById('upload-speed').textContent = uploadSpeed; + document.getElementById('current-speed').textContent = `↓${downloadSpeed} ↑${uploadSpeed}`; + + // Set progress bars (max 100%) + const maxSpeed = Math.max(stats.downloadSpeed, stats.uploadSpeed, 1); + const dlPercent = Math.min(Math.round((stats.downloadSpeed / maxSpeed) * 100), 100); + const ulPercent = Math.min(Math.round((stats.uploadSpeed / maxSpeed) * 100), 100); + document.getElementById('download-speed-bar').style.width = `${dlPercent}%`; + document.getElementById('upload-speed-bar').style.width = `${ulPercent}%`; + }) + .catch(error => { + console.error('Error loading dashboard stats:', error); + }); + + // Load chart data + loadDownloadHistoryChart(); + + // Load other dashboard components loadActiveTorrents(); + loadRecentMatches(); } function loadSystemStatus() { @@ -913,4 +969,543 @@ function formatDate(date) { function padZero(num) { return num.toString().padStart(2, '0'); +} + +// Dark Mode Functions +function initDarkMode() { + // Check local storage preference or system preference + const darkModePreference = localStorage.getItem('darkMode'); + + if (darkModePreference === 'true' || + (darkModePreference === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + enableDarkMode(); + } else { + disableDarkMode(); + } +} + +function toggleDarkMode() { + if (document.body.classList.contains('dark-mode')) { + disableDarkMode(); + } else { + enableDarkMode(); + } +} + +function enableDarkMode() { + document.body.classList.add('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'true'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = true; + } +} + +function disableDarkMode() { + document.body.classList.remove('dark-mode'); + document.getElementById('dark-mode-toggle').innerHTML = ''; + localStorage.setItem('darkMode', 'false'); + + // Also update user preferences if on settings page + const darkModeCheckbox = document.getElementById('enable-dark-mode'); + if (darkModeCheckbox) { + darkModeCheckbox.checked = false; + } +} + +// Auto-refresh +function initAutoRefresh() { + // Get auto-refresh settings from local storage or use defaults + const autoRefresh = localStorage.getItem('autoRefresh') !== 'false'; + const refreshInterval = parseInt(localStorage.getItem('refreshInterval')) || 30; + + if (autoRefresh) { + startAutoRefresh(refreshInterval); + } +} + +function startAutoRefresh(intervalSeconds) { + // Clear any existing interval + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + } + + // Set new interval + window.refreshTimer = setInterval(() => { + const currentPage = window.location.hash.substring(1) || 'dashboard'; + loadPageData(currentPage); + }, intervalSeconds * 1000); + + localStorage.setItem('autoRefresh', 'true'); + localStorage.setItem('refreshInterval', intervalSeconds.toString()); +} + +function stopAutoRefresh() { + if (window.refreshTimer) { + clearInterval(window.refreshTimer); + window.refreshTimer = null; + } + + localStorage.setItem('autoRefresh', 'false'); +} + +// Chart functions +function loadDownloadHistoryChart() { + fetch('/api/dashboard/history') + .then(response => response.json()) + .then(history => { + const ctx = document.getElementById('download-history-chart').getContext('2d'); + + // Extract dates and count values + const labels = history.map(point => { + const date = new Date(point.date); + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + + const countData = history.map(point => point.count); + const sizeData = history.map(point => point.totalSize / (1024 * 1024 * 1024)); // Convert to GB + + // Create or update chart + if (window.downloadHistoryChart) { + window.downloadHistoryChart.data.labels = labels; + window.downloadHistoryChart.data.datasets[0].data = countData; + window.downloadHistoryChart.data.datasets[1].data = sizeData; + window.downloadHistoryChart.update(); + } else { + window.downloadHistoryChart = new Chart(ctx, { + type: 'bar', + data: { + labels: labels, + datasets: [ + { + label: 'Number of Downloads', + data: countData, + backgroundColor: 'rgba(13, 110, 253, 0.5)', + borderColor: 'rgba(13, 110, 253, 1)', + borderWidth: 1, + yAxisID: 'y' + }, + { + label: 'Total Size (GB)', + data: sizeData, + type: 'line', + borderColor: 'rgba(25, 135, 84, 1)', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.4, + yAxisID: 'y1' + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + }, + tooltip: { + callbacks: { + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.datasetIndex === 0) { + label += context.parsed.y; + } else { + label += context.parsed.y.toFixed(2) + ' GB'; + } + return label; + } + } + } + }, + scales: { + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Number of Downloads' + }, + beginAtZero: true + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Total Size (GB)' + }, + beginAtZero: true, + grid: { + drawOnChartArea: false + } + } + } + } + }); + } + }) + .catch(error => { + console.error('Error loading download history chart:', error); + }); +} + +// Logs Management +function refreshLogs() { + const logFilters = getLogFilters(); + loadLogs(logFilters); +} + +function getLogFilters() { + return { + level: document.getElementById('log-level').value, + search: document.getElementById('log-search').value, + dateRange: document.getElementById('log-date-range').value, + skip: 0, + take: parseInt(document.getElementById('items-per-page')?.value || 25) + }; +} + +function loadLogs(filters) { + const tbody = document.getElementById('logs-table-body'); + tbody.innerHTML = 'Loading logs...'; + + // Build query string + const query = new URLSearchParams(); + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Handle date range + const now = new Date(); + let startDate = null; + + switch (filters.dateRange) { + case 'today': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); + break; + case 'yesterday': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0); + break; + case 'week': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0); + break; + case 'month': + startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0); + break; + } + + if (startDate) { + query.append('StartDate', startDate.toISOString()); + } + + query.append('Skip', filters.skip.toString()); + query.append('Take', filters.take.toString()); + + fetch(`/api/logs?${query.toString()}`) + .then(response => response.json()) + .then(logs => { + if (logs.length === 0) { + tbody.innerHTML = 'No logs found'; + document.getElementById('log-count').textContent = '0 entries'; + document.getElementById('logs-pagination-info').textContent = 'Showing 0 of 0 entries'; + return; + } + + let html = ''; + logs.forEach(log => { + const timestamp = new Date(log.timestamp); + const levelClass = getLevelClass(log.level); + + html += ` + ${formatDate(timestamp)} + ${log.level} + ${log.message} + ${log.context || ''} + `; + }); + + tbody.innerHTML = html; + document.getElementById('log-count').textContent = `${logs.length} entries`; + document.getElementById('logs-pagination-info').textContent = `Showing ${logs.length} entries`; + + // Update pagination (simplified for now) + updateLogPagination(filters, logs.length); + }) + .catch(error => { + console.error('Error loading logs:', error); + tbody.innerHTML = 'Error loading logs'; + }); +} + +function updateLogPagination(filters, count) { + const pagination = document.getElementById('logs-pagination'); + + // Simplified pagination - just first page for now + pagination.innerHTML = ` +
  • + 1 +
  • + `; +} + +function getLevelClass(level) { + switch (level.toLowerCase()) { + case 'debug': + return 'bg-secondary'; + case 'information': + return 'bg-info'; + case 'warning': + return 'bg-warning'; + case 'error': + return 'bg-danger'; + case 'critical': + return 'bg-dark'; + default: + return 'bg-secondary'; + } +} + +function applyLogFilters() { + const filters = getLogFilters(); + loadLogs(filters); +} + +function resetLogFilters() { + document.getElementById('log-level').value = 'All'; + document.getElementById('log-search').value = ''; + document.getElementById('log-date-range').value = 'week'; + loadLogs(getLogFilters()); +} + +function clearLogs() { + if (!confirm('Are you sure you want to clear all logs? This action cannot be undone.')) { + return; + } + + fetch('/api/logs/clear', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to clear logs'); + } + + // Refresh logs + loadLogs(getLogFilters()); + }) + .catch(error => { + console.error('Error clearing logs:', error); + alert('Error clearing logs'); + }); +} + +function exportLogs() { + const filters = getLogFilters(); + const query = new URLSearchParams(); + + if (filters.level && filters.level !== 'All') { + query.append('Level', filters.level); + } + if (filters.search) { + query.append('Search', filters.search); + } + + // Create download link + const link = document.createElement('a'); + link.href = `/api/logs/export?${query.toString()}`; + link.download = `transmission-rss-logs-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// Helper function to format file sizes +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', '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]; +} + +// Configuration Operations +function backupConfig() { + if (!confirm('This will create a backup of your configuration files. Do you want to continue?')) { + return; + } + + fetch('/api/config/backup', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to backup configuration'); + } + return response.blob(); + }) + .then(blob => { + // Create download link for the backup file + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = `transmission-rss-config-backup-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + alert('Configuration backup created successfully.'); + }) + .catch(error => { + console.error('Error backing up configuration:', error); + alert('Error creating configuration backup.'); + }); +} + +function resetConfig() { + if (!confirm('WARNING: This will reset your configuration to default settings. All your feeds, rules, and user preferences will be lost. This cannot be undone. Are you absolutely sure?')) { + return; + } + + if (!confirm('FINAL WARNING: All feeds, rules, and settings will be permanently deleted. Type "RESET" to confirm.')) { + return; + } + + const confirmation = prompt('Type "RESET" to confirm configuration reset:'); + if (confirmation !== 'RESET') { + alert('Configuration reset cancelled.'); + return; + } + + fetch('/api/config/reset', { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to reset configuration'); + } + + alert('Configuration has been reset to defaults. The application will now reload.'); + window.location.reload(); + }) + .catch(error => { + console.error('Error resetting configuration:', error); + alert('Error resetting configuration.'); + }); +} + +// Transmission Instance Management +function addTransmissionInstance() { + const instancesList = document.getElementById('transmission-instances-list'); + const instanceCount = document.querySelectorAll('.transmission-instance').length; + const newInstanceIndex = instanceCount + 1; + + const instanceHtml = ` +
    +
    +
    +
    Instance #${newInstanceIndex}
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    `; + + // If the "no instances" message is showing, remove it + if (instancesList.querySelector('.text-center.text-muted')) { + instancesList.innerHTML = ''; + } + + // Add the new instance + instancesList.insertAdjacentHTML('beforeend', instanceHtml); +} + +function removeTransmissionInstance(index) { + const instance = document.getElementById(`transmission-instance-${index}`); + if (instance) { + instance.remove(); + + // If there are no instances left, show the "no instances" message + const instancesList = document.getElementById('transmission-instances-list'); + if (instancesList.children.length === 0) { + instancesList.innerHTML = '
    No additional instances configured
    '; + } + } +} + +function resetSettings() { + if (!confirm('This will reset all settings to their default values. Are you sure?')) { + return; + } + + // Load default settings + fetch('/api/config/defaults') + .then(response => response.json()) + .then(defaults => { + // Apply defaults to form + loadSettingsIntoForm(defaults); + }) + .catch(error => { + console.error('Error loading default settings:', error); + alert('Error loading default settings'); + }); } \ No newline at end of file