using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using TransmissionRssManager.Core; namespace TransmissionRssManager.Services { /// /// Service for managing application configuration /// File-based implementation that does not use a database /// public class ConfigService : IConfigService { private readonly ILogger _logger; private readonly string _configFilePath; private AppConfig? _cachedConfig; private readonly object _lockObject = new object(); public ConfigService(ILogger logger) { _logger = logger; // Determine the appropriate config file path string baseDir = AppContext.BaseDirectory; string etcConfigPath = "/etc/transmission-rss-manager/appsettings.json"; string localConfigPath = Path.Combine(baseDir, "appsettings.json"); // Check if config exists in /etc (preferred) or in app directory _configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath; _logger.LogInformation($"Using configuration file: {_configFilePath}"); } // Implement the interface methods required by IConfigService public AppConfig GetConfiguration() { // Non-async method required by interface _logger.LogDebug($"GetConfiguration called, cached config is {(_cachedConfig == null ? "null" : "available")}"); if (_cachedConfig != null) { _logger.LogDebug("Returning cached configuration"); return _cachedConfig; } try { // Load synchronously since this is a sync method _logger.LogInformation("Loading configuration from file (sync method)"); _cachedConfig = LoadConfigFromFileSync(); // Log what we loaded if (_cachedConfig != null) { _logger.LogInformation($"Loaded configuration with {_cachedConfig.Feeds?.Count ?? 0} feeds, " + $"transmission host: {_cachedConfig.Transmission?.Host}, " + $"autoDownload: {_cachedConfig.AutoDownloadEnabled}"); } return _cachedConfig; } catch (Exception ex) { _logger.LogError(ex, "Error loading configuration, using default values"); _cachedConfig = CreateDefaultConfig(); return _cachedConfig; } } public async Task SaveConfigurationAsync(AppConfig config) { try { if (config == null) { _logger.LogError("Cannot save null configuration"); throw new ArgumentNullException(nameof(config)); } _logger.LogInformation($"SaveConfigurationAsync called with config: " + $"transmission host = {config.Transmission?.Host}, " + $"autoDownload = {config.AutoDownloadEnabled}"); // Create deep copy to ensure we don't have reference issues string json = JsonSerializer.Serialize(config); AppConfig configCopy = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (configCopy == null) { throw new InvalidOperationException("Failed to create copy of configuration for saving"); } // Ensure all properties are properly set EnsureCompleteConfig(configCopy); // Update cached config _cachedConfig = configCopy; _logger.LogInformation("About to save configuration to file"); await SaveConfigToFileAsync(configCopy); _logger.LogInformation("Configuration saved successfully to file"); } catch (Exception ex) { _logger.LogError(ex, "Error saving configuration to file"); throw; } } // Additional methods for backward compatibility public async Task GetConfigAsync() { if (_cachedConfig != null) { return _cachedConfig; } try { _cachedConfig = await LoadConfigFromFileAsync(); return _cachedConfig; } catch (Exception ex) { _logger.LogError(ex, "Error loading configuration, using default values"); return CreateDefaultConfig(); } } public async Task SaveConfigAsync(AppConfig config) { await SaveConfigurationAsync(config); } public async Task GetSettingAsync(string key, string defaultValue = "") { var config = await GetConfigAsync(); switch (key) { case "Transmission.Host": return config.Transmission.Host ?? defaultValue; case "Transmission.Port": return config.Transmission.Port.ToString(); case "Transmission.Username": return config.Transmission.Username ?? defaultValue; case "Transmission.Password": return config.Transmission.Password ?? defaultValue; case "Transmission.UseHttps": return config.Transmission.UseHttps.ToString(); case "AutoDownloadEnabled": return config.AutoDownloadEnabled.ToString(); case "CheckIntervalMinutes": return config.CheckIntervalMinutes.ToString(); case "DownloadDirectory": return config.DownloadDirectory ?? defaultValue; case "MediaLibraryPath": return config.MediaLibraryPath ?? defaultValue; case "PostProcessing.Enabled": return config.PostProcessing.Enabled.ToString(); case "PostProcessing.ExtractArchives": return config.PostProcessing.ExtractArchives.ToString(); case "PostProcessing.OrganizeMedia": return config.PostProcessing.OrganizeMedia.ToString(); case "PostProcessing.MinimumSeedRatio": return config.PostProcessing.MinimumSeedRatio.ToString(); case "UserPreferences.EnableDarkMode": return config.UserPreferences.EnableDarkMode.ToString(); case "UserPreferences.AutoRefreshUIEnabled": return config.UserPreferences.AutoRefreshUIEnabled.ToString(); case "UserPreferences.AutoRefreshIntervalSeconds": return config.UserPreferences.AutoRefreshIntervalSeconds.ToString(); case "UserPreferences.NotificationsEnabled": return config.UserPreferences.NotificationsEnabled.ToString(); default: _logger.LogWarning($"Unknown setting key: {key}"); return defaultValue; } } public async Task SaveSettingAsync(string key, string value) { var config = await GetConfigAsync(); bool changed = false; try { switch (key) { case "Transmission.Host": config.Transmission.Host = value; changed = true; break; case "Transmission.Port": if (int.TryParse(value, out int port)) { config.Transmission.Port = port; changed = true; } break; case "Transmission.Username": config.Transmission.Username = value; changed = true; break; case "Transmission.Password": config.Transmission.Password = value; changed = true; break; case "Transmission.UseHttps": if (bool.TryParse(value, out bool useHttps)) { config.Transmission.UseHttps = useHttps; changed = true; } break; case "AutoDownloadEnabled": if (bool.TryParse(value, out bool autoDownload)) { config.AutoDownloadEnabled = autoDownload; changed = true; } break; case "CheckIntervalMinutes": if (int.TryParse(value, out int interval)) { config.CheckIntervalMinutes = interval; changed = true; } break; case "DownloadDirectory": config.DownloadDirectory = value; changed = true; break; case "MediaLibraryPath": config.MediaLibraryPath = value; changed = true; break; case "PostProcessing.Enabled": if (bool.TryParse(value, out bool ppEnabled)) { config.PostProcessing.Enabled = ppEnabled; changed = true; } break; case "PostProcessing.ExtractArchives": if (bool.TryParse(value, out bool extractArchives)) { config.PostProcessing.ExtractArchives = extractArchives; changed = true; } break; case "PostProcessing.OrganizeMedia": if (bool.TryParse(value, out bool organizeMedia)) { config.PostProcessing.OrganizeMedia = organizeMedia; changed = true; } break; case "PostProcessing.MinimumSeedRatio": if (float.TryParse(value, out float seedRatio)) { config.PostProcessing.MinimumSeedRatio = (int)seedRatio; changed = true; } break; case "UserPreferences.EnableDarkMode": if (bool.TryParse(value, out bool darkMode)) { config.UserPreferences.EnableDarkMode = darkMode; changed = true; } break; case "UserPreferences.AutoRefreshUIEnabled": if (bool.TryParse(value, out bool autoRefresh)) { config.UserPreferences.AutoRefreshUIEnabled = autoRefresh; changed = true; } break; case "UserPreferences.AutoRefreshIntervalSeconds": if (int.TryParse(value, out int refreshInterval)) { config.UserPreferences.AutoRefreshIntervalSeconds = refreshInterval; changed = true; } break; case "UserPreferences.NotificationsEnabled": if (bool.TryParse(value, out bool notifications)) { config.UserPreferences.NotificationsEnabled = notifications; changed = true; } break; default: _logger.LogWarning($"Unknown setting key: {key}"); break; } if (changed) { await SaveConfigAsync(config); } } catch (Exception ex) { _logger.LogError(ex, $"Error saving setting {key}"); throw; } } private AppConfig LoadConfigFromFileSync() { try { if (!File.Exists(_configFilePath)) { _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); var defaultConfig = CreateDefaultConfig(); // Save synchronously since we're in a sync method File.WriteAllText(_configFilePath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); return defaultConfig; } string json = File.ReadAllText(_configFilePath); var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (config == null) { throw new InvalidOperationException("Failed to deserialize configuration"); } // Fill in any missing values with defaults EnsureCompleteConfig(config); return config; } catch (Exception ex) { _logger.LogError(ex, "Error loading configuration from file"); throw; } } private async Task LoadConfigFromFileAsync() { try { if (!File.Exists(_configFilePath)) { _logger.LogWarning($"Configuration file not found at {_configFilePath}, creating default config"); var defaultConfig = CreateDefaultConfig(); await SaveConfigToFileAsync(defaultConfig); return defaultConfig; } string json = await File.ReadAllTextAsync(_configFilePath); var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (config == null) { throw new InvalidOperationException("Failed to deserialize configuration"); } // Fill in any missing values with defaults EnsureCompleteConfig(config); return config; } catch (Exception ex) { _logger.LogError(ex, "Error loading configuration from file"); throw; } } private async Task SaveConfigToFileAsync(AppConfig config) { try { string json = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); // Create directory if it doesn't exist string directory = Path.GetDirectoryName(_configFilePath); if (!Directory.Exists(directory) && !string.IsNullOrEmpty(directory)) { _logger.LogInformation($"Creating directory: {directory}"); Directory.CreateDirectory(directory); } // Log detailed info about the file we're trying to write to _logger.LogInformation($"Attempting to save configuration to {_configFilePath}"); bool canWriteToOriginalPath = false; try { // Check if we have write permissions to the directory var directoryInfo = new DirectoryInfo(directory); _logger.LogInformation($"Directory exists: {directoryInfo.Exists}, Directory path: {directoryInfo.FullName}"); // Check if we have write permissions to the file var fileInfo = new FileInfo(_configFilePath); if (fileInfo.Exists) { _logger.LogInformation($"File exists: {fileInfo.Exists}, File path: {fileInfo.FullName}, Is read-only: {fileInfo.IsReadOnly}"); // Try to make the file writable if it's read-only if (fileInfo.IsReadOnly) { _logger.LogWarning("Configuration file is read-only, attempting to make it writable"); try { fileInfo.IsReadOnly = false; canWriteToOriginalPath = true; } catch (Exception ex) { _logger.LogError(ex, "Failed to make file writable"); } } else { canWriteToOriginalPath = true; } } else { // If file doesn't exist, check if we can write to the directory try { // Try to create a test file string testFilePath = Path.Combine(directory, "writetest.tmp"); File.WriteAllText(testFilePath, "test"); File.Delete(testFilePath); canWriteToOriginalPath = true; } catch (Exception ex) { _logger.LogError(ex, "Cannot write to directory"); } } } catch (Exception permEx) { _logger.LogError(permEx, "Error checking file permissions"); } string configFilePath = _configFilePath; // If we can't write to the original path, use a fallback path in a location we know we can write to if (!canWriteToOriginalPath) { string fallbackPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); _logger.LogWarning($"Cannot write to original path, using fallback path: {fallbackPath}"); configFilePath = fallbackPath; // Update the config file path for future loads _configFilePath = fallbackPath; } try { // Write directly to the file - first try direct write _logger.LogInformation($"Writing configuration to {configFilePath}"); await File.WriteAllTextAsync(configFilePath, json); _logger.LogInformation("Configuration successfully saved by direct write"); return; } catch (Exception writeEx) { _logger.LogError(writeEx, "Direct write failed, trying with temporary file"); } // If direct write fails, try with temporary file string tempDirectory = AppContext.BaseDirectory; string tempFilePath = Path.Combine(tempDirectory, $"appsettings.{Guid.NewGuid():N}.tmp"); _logger.LogInformation($"Writing to temporary file: {tempFilePath}"); await File.WriteAllTextAsync(tempFilePath, json); try { _logger.LogInformation($"Copying from {tempFilePath} to {configFilePath}"); File.Copy(tempFilePath, configFilePath, true); _logger.LogInformation("Configuration successfully saved via temp file"); } catch (Exception copyEx) { _logger.LogError(copyEx, "Error copying from temp file to destination"); // If copy fails, keep the temp file and use it as the config path _logger.LogWarning($"Using temporary file as permanent config: {tempFilePath}"); _configFilePath = tempFilePath; } finally { try { if (File.Exists(tempFilePath) && tempFilePath != _configFilePath) { File.Delete(tempFilePath); } } catch (Exception ex) { _logger.LogWarning(ex, "Could not delete temp file"); } } } catch (Exception ex) { _logger.LogError(ex, "Error saving configuration to file"); throw; } } private AppConfig CreateDefaultConfig() { var defaultConfig = new AppConfig { Transmission = new TransmissionConfig { Host = "localhost", Port = 9091, Username = "", Password = "", UseHttps = false }, TransmissionInstances = new Dictionary(), Feeds = new List(), AutoDownloadEnabled = true, CheckIntervalMinutes = 30, DownloadDirectory = "/var/lib/transmission-daemon/downloads", MediaLibraryPath = "/media/library", EnableDetailedLogging = false, PostProcessing = new PostProcessingConfig { Enabled = false, ExtractArchives = true, OrganizeMedia = true, MinimumSeedRatio = 1, MediaExtensions = new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, AutoOrganizeByMediaType = true, RenameFiles = false, CompressCompletedFiles = false, DeleteCompletedAfterDays = 0 }, UserPreferences = new TransmissionRssManager.Core.UserPreferences { EnableDarkMode = true, AutoRefreshUIEnabled = true, AutoRefreshIntervalSeconds = 30, NotificationsEnabled = true, NotificationEvents = new List { "torrent-added", "torrent-completed", "torrent-error" }, DefaultView = "dashboard", ConfirmBeforeDelete = true, MaxItemsPerPage = 25, DateTimeFormat = "yyyy-MM-dd HH:mm:ss", ShowCompletedTorrents = true, KeepHistoryDays = 30 } }; _logger.LogInformation("Created default configuration"); return defaultConfig; } private void EnsureCompleteConfig(AppConfig config) { // Create new instances for any null nested objects config.Transmission ??= new TransmissionConfig { Host = "localhost", Port = 9091, Username = "", Password = "", UseHttps = false }; config.TransmissionInstances ??= new Dictionary(); config.Feeds ??= new List(); config.PostProcessing ??= new PostProcessingConfig { Enabled = false, ExtractArchives = true, OrganizeMedia = true, MinimumSeedRatio = 1, MediaExtensions = new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }, AutoOrganizeByMediaType = true, RenameFiles = false, CompressCompletedFiles = false, DeleteCompletedAfterDays = 0 }; // Ensure PostProcessing MediaExtensions is not null config.PostProcessing.MediaExtensions ??= new List { ".mp4", ".mkv", ".avi", ".mov", ".wmv" }; config.UserPreferences ??= new TransmissionRssManager.Core.UserPreferences { EnableDarkMode = true, AutoRefreshUIEnabled = true, AutoRefreshIntervalSeconds = 30, NotificationsEnabled = true, NotificationEvents = new List { "torrent-added", "torrent-completed", "torrent-error" }, DefaultView = "dashboard", ConfirmBeforeDelete = true, MaxItemsPerPage = 25, DateTimeFormat = "yyyy-MM-dd HH:mm:ss", ShowCompletedTorrents = true, KeepHistoryDays = 30 }; // Ensure UserPreferences.NotificationEvents is not null config.UserPreferences.NotificationEvents ??= new List { "torrent-added", "torrent-completed", "torrent-error" }; // Ensure default values for string properties if they're null config.DownloadDirectory ??= "/var/lib/transmission-daemon/downloads"; config.MediaLibraryPath ??= "/media/library"; config.Transmission.Host ??= "localhost"; config.Transmission.Username ??= ""; config.Transmission.Password ??= ""; config.UserPreferences.DefaultView ??= "dashboard"; config.UserPreferences.DateTimeFormat ??= "yyyy-MM-dd HH:mm:ss"; _logger.LogDebug("Config validated and completed with default values where needed"); } } }