
This repository contains Transmission RSS Manager with the following changes: - Fixed dark mode navigation tab visibility issue - Improved text contrast in dark mode throughout the app - Created dedicated dark-mode.css for better organization - Enhanced JavaScript for dynamic styling in dark mode - Added complete installation scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
648 lines
27 KiB
C#
648 lines
27 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Service for managing application configuration
|
|
/// File-based implementation that does not use a database
|
|
/// </summary>
|
|
public class ConfigService : IConfigService
|
|
{
|
|
private readonly ILogger<ConfigService> _logger;
|
|
private readonly string _configFilePath;
|
|
private AppConfig? _cachedConfig;
|
|
private readonly object _lockObject = new object();
|
|
|
|
public ConfigService(ILogger<ConfigService> 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<AppConfig>(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<AppConfig> 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<string> 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<AppConfig>(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<AppConfig> 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<AppConfig>(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<string, TransmissionConfig>(),
|
|
Feeds = new List<RssFeed>(),
|
|
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<string> { ".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<string> { "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<string, TransmissionConfig>();
|
|
config.Feeds ??= new List<RssFeed>();
|
|
|
|
config.PostProcessing ??= new PostProcessingConfig
|
|
{
|
|
Enabled = false,
|
|
ExtractArchives = true,
|
|
OrganizeMedia = true,
|
|
MinimumSeedRatio = 1,
|
|
MediaExtensions = new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv" },
|
|
AutoOrganizeByMediaType = true,
|
|
RenameFiles = false,
|
|
CompressCompletedFiles = false,
|
|
DeleteCompletedAfterDays = 0
|
|
};
|
|
|
|
// Ensure PostProcessing MediaExtensions is not null
|
|
config.PostProcessing.MediaExtensions ??= new List<string> { ".mp4", ".mkv", ".avi", ".mov", ".wmv" };
|
|
|
|
config.UserPreferences ??= new TransmissionRssManager.Core.UserPreferences
|
|
{
|
|
EnableDarkMode = true,
|
|
AutoRefreshUIEnabled = true,
|
|
AutoRefreshIntervalSeconds = 30,
|
|
NotificationsEnabled = true,
|
|
NotificationEvents = new List<string> { "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<string> { "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");
|
|
}
|
|
}
|
|
} |