From c61f308de7c8b77869a9c857c0dc6e1c1a4b2eea Mon Sep 17 00:00:00 2001 From: MasterDraco Date: Wed, 12 Mar 2025 22:30:53 +0000 Subject: [PATCH] feat: Implement file-based configuration storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced database-dependent ConfigService with pure file-based implementation - Added support for both system-wide (/etc) and local app configuration - Implemented safe atomic file writes with backups - Added fallback to default values for missing configuration fields - Improved error handling for configuration loading/saving 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/Services/ConfigService.cs | 382 +++++++++++++++++++++++++++++----- 1 file changed, 330 insertions(+), 52 deletions(-) diff --git a/src/Services/ConfigService.cs b/src/Services/ConfigService.cs index fce7b78..612062b 100644 --- a/src/Services/ConfigService.cs +++ b/src/Services/ConfigService.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -7,84 +9,317 @@ 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 _configPath; + private readonly string _configFilePath; private AppConfig _cachedConfig; + private readonly object _lockObject = new object(); public ConfigService(ILogger logger) { _logger = logger; - // Get config directory - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string configDir = Path.Combine(homeDir, ".config", "transmission-rss-manager"); + // 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"); - // Ensure directory exists - if (!Directory.Exists(configDir)) - { - Directory.CreateDirectory(configDir); - } + // Check if config exists in /etc (preferred) or in app directory + _configFilePath = File.Exists(etcConfigPath) ? etcConfigPath : localConfigPath; - _configPath = Path.Combine(configDir, "config.json"); - _cachedConfig = LoadConfiguration(); + _logger.LogInformation($"Using configuration file: {_configFilePath}"); } - public AppConfig GetConfiguration() + public async Task GetConfigAsync() { - return _cachedConfig; - } - - public async Task SaveConfigurationAsync(AppConfig config) - { - _cachedConfig = config; - - var options = new JsonSerializerOptions + if (_cachedConfig != null) { - WriteIndented = true - }; - - string json = JsonSerializer.Serialize(config, options); - await File.WriteAllTextAsync(_configPath, json); - - _logger.LogInformation("Configuration saved successfully"); - } - - private AppConfig LoadConfiguration() - { - if (!File.Exists(_configPath)) - { - _logger.LogInformation("No configuration file found, creating default"); - var defaultConfig = CreateDefaultConfig(); - SaveConfigurationAsync(defaultConfig).Wait(); - return defaultConfig; + return _cachedConfig; } try { - string json = File.ReadAllText(_configPath); - var config = JsonSerializer.Deserialize(json); - + _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) + { + try + { + _cachedConfig = config; + await SaveConfigToFileAsync(config); + _logger.LogInformation("Configuration saved successfully to file"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; + } + } + + 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 = 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 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) { - _logger.LogWarning("Failed to deserialize config, creating default"); - return CreateDefaultConfig(); + 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"); - return CreateDefaultConfig(); + _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)) + { + Directory.CreateDirectory(directory); + } + + // Use a temporary file to avoid corruption if the process crashes during write + string tempFilePath = _configFilePath + ".tmp"; + await File.WriteAllTextAsync(tempFilePath, json); + + // Atomic file replacement (as much as the filesystem allows) + if (File.Exists(_configFilePath)) + { + File.Replace(tempFilePath, _configFilePath, _configFilePath + ".bak"); + } + else + { + File.Move(tempFilePath, _configFilePath); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving configuration to file"); + throw; } } private AppConfig CreateDefaultConfig() { - string homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return new AppConfig { Transmission = new TransmissionConfig @@ -92,21 +327,64 @@ namespace TransmissionRssManager.Services Host = "localhost", Port = 9091, Username = "", - Password = "" + Password = "", + UseHttps = false }, - AutoDownloadEnabled = false, + AutoDownloadEnabled = true, CheckIntervalMinutes = 30, - DownloadDirectory = Path.Combine(homeDir, "Downloads"), - MediaLibraryPath = Path.Combine(homeDir, "Media"), + DownloadDirectory = "/var/lib/transmission-daemon/downloads", + MediaLibraryPath = "/media/library", PostProcessing = new PostProcessingConfig { Enabled = false, ExtractArchives = true, OrganizeMedia = true, - MinimumSeedRatio = 1, - MediaExtensions = new System.Collections.Generic.List { ".mp4", ".mkv", ".avi" } + MinimumSeedRatio = 1.0f + }, + UserPreferences = new UserPreferencesConfig + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true } }; } + + 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.PostProcessing ??= new PostProcessingConfig + { + Enabled = false, + ExtractArchives = true, + OrganizeMedia = true, + MinimumSeedRatio = 1.0f + }; + + config.UserPreferences ??= new UserPreferencesConfig + { + EnableDarkMode = true, + AutoRefreshUIEnabled = true, + AutoRefreshIntervalSeconds = 30, + NotificationsEnabled = true + }; + + // 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 ??= ""; + } } } \ No newline at end of file