From 4d79307d2499addcf6007cbc3dc61e1c00742073 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 16 May 2025 18:06:18 +0300 Subject: [PATCH] #9 --- .../DependencyInjection/NotificationsDI.cs | 5 +- .../Configuration/ConfigManager.cs | 229 +++++++++++++- .../JsonConfigurationProvider.cs | 284 +++++++++++++++++- .../Infrastructure/Verticals/Arr/ArrClient.cs | 25 +- .../DownloadClient/DownloadService.cs | 31 +- .../DownloadClient/QBittorrent/QBitService.cs | 180 +++++------ .../Notifications/Apprise/AppriseProvider.cs | 9 +- .../Notifiarr/NotifiarrProvider.cs | 7 +- .../Notifications/NotificationProvider.cs | 8 +- 9 files changed, 650 insertions(+), 128 deletions(-) diff --git a/code/Executable/DependencyInjection/NotificationsDI.cs b/code/Executable/DependencyInjection/NotificationsDI.cs index 54b7ddc6..63cce8fb 100644 --- a/code/Executable/DependencyInjection/NotificationsDI.cs +++ b/code/Executable/DependencyInjection/NotificationsDI.cs @@ -1,4 +1,4 @@ -using Infrastructure.Verticals.Notifications; +using Infrastructure.Verticals.Notifications; using Infrastructure.Verticals.Notifications.Apprise; using Infrastructure.Verticals.Notifications.Notifiarr; @@ -8,8 +8,7 @@ public static class NotificationsDI { public static IServiceCollection AddNotifications(this IServiceCollection services, IConfiguration configuration) => services - .Configure(configuration.GetSection(NotifiarrConfig.SectionName)) - .Configure(configuration.GetSection(AppriseConfig.SectionName)) + // Notification configs are now managed through ConfigManager .AddTransient() .AddTransient() .AddTransient() diff --git a/code/Infrastructure/Configuration/ConfigManager.cs b/code/Infrastructure/Configuration/ConfigManager.cs index a15d424c..54ebabfa 100644 --- a/code/Infrastructure/Configuration/ConfigManager.cs +++ b/code/Infrastructure/Configuration/ConfigManager.cs @@ -14,15 +14,22 @@ namespace Infrastructure.Configuration; /// public interface IConfigManager { - // Configuration files + // Configuration files - Async methods Task GetConfigurationAsync(string configFileName) where T : class, new(); Task SaveConfigurationAsync(string configFileName, T config) where T : class; Task UpdateConfigurationPropertyAsync(string configFileName, string propertyPath, T value); Task MergeConfigurationAsync(string configFileName, T newValues) where T : class; Task DeleteConfigurationAsync(string configFileName); IEnumerable ListConfigurationFiles(); + + // Configuration files - Sync methods + T? GetConfiguration(string configFileName) where T : class, new(); + bool SaveConfiguration(string configFileName, T config) where T : class; + bool UpdateConfigurationProperty(string configFileName, string propertyPath, T value); + bool MergeConfiguration(string configFileName, T newValues) where T : class; + bool DeleteConfiguration(string configFileName); - // Specific configuration types (for common configs) + // Specific configuration types - Async methods Task GetSonarrConfigAsync(); Task GetRadarrConfigAsync(); Task GetLidarrConfigAsync(); @@ -40,6 +47,25 @@ public interface IConfigManager Task SaveDownloadCleanerConfigAsync(DownloadCleanerConfig config); Task SaveDownloadClientConfigAsync(DownloadClientConfig config); Task SaveIgnoredDownloadsConfigAsync(IgnoredDownloadsConfig config); + + // Specific configuration types - Sync methods + SonarrConfig? GetSonarrConfig(); + RadarrConfig? GetRadarrConfig(); + LidarrConfig? GetLidarrConfig(); + ContentBlockerConfig? GetContentBlockerConfig(); + QueueCleanerConfig? GetQueueCleanerConfig(); + DownloadCleanerConfig? GetDownloadCleanerConfig(); + DownloadClientConfig? GetDownloadClientConfig(); + IgnoredDownloadsConfig? GetIgnoredDownloadsConfig(); + + bool SaveSonarrConfig(SonarrConfig config); + bool SaveRadarrConfig(RadarrConfig config); + bool SaveLidarrConfig(LidarrConfig config); + bool SaveContentBlockerConfig(ContentBlockerConfig config); + bool SaveQueueCleanerConfig(QueueCleanerConfig config); + bool SaveDownloadCleanerConfig(DownloadCleanerConfig config); + bool SaveDownloadClientConfig(DownloadClientConfig config); + bool SaveIgnoredDownloadsConfig(IgnoredDownloadsConfig config); } public class ConfigManager : IConfigManager @@ -133,7 +159,7 @@ public class ConfigManager : IConfigManager public ContentBlockerConfig? GetContentBlockerConfig() { - return GetContentBlockerConfigAsync().GetAwaiter().GetResult(); + return _configProvider.ReadConfiguration(ContentBlockerConfigFile); } public async Task GetQueueCleanerConfigAsync() @@ -151,6 +177,11 @@ public class ConfigManager : IConfigManager return await _configProvider.ReadConfigurationAsync(DownloadClientConfigFile); } + public async Task GetIgnoredDownloadsConfigAsync() + { + return await _configProvider.ReadConfigurationAsync(IgnoredDownloadsConfigFile); + } + public Task SaveSonarrConfigAsync(SonarrConfig config) { try @@ -266,4 +297,196 @@ public class ConfigManager : IConfigManager return Task.FromResult(false); } } + + // Generic synchronous configuration methods + public T? GetConfiguration(string fileName) where T : class, new() + { + return _jsonConfigurationProvider.ReadConfiguration(fileName); + } + + public Common.Configuration.QueueCleaner.QueueCleanerConfig? GetQueueCleanerConfig() + { + return GetConfiguration("queue-cleaner.json"); + } + + public bool SaveConfiguration(string configFileName, T config) where T : class + { + // Validate if it's an IConfig + if (config is IConfig configurable) + { + try + { + configurable.Validate(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Configuration validation failed for {fileName}", configFileName); + return false; + } + } + + return _configProvider.WriteConfiguration(configFileName, config); + } + + public bool UpdateConfigurationProperty(string configFileName, string propertyPath, T value) + { + return _configProvider.UpdateConfigurationProperty(configFileName, propertyPath, value); + } + + public bool MergeConfiguration(string configFileName, T newValues) where T : class + { + return _configProvider.MergeConfiguration(configFileName, newValues); + } + + public bool DeleteConfiguration(string configFileName) + { + return _configProvider.DeleteConfiguration(configFileName); + } + + // Specific synchronous configuration methods for typed configs + public SonarrConfig? GetSonarrConfig() + { + return _configProvider.ReadConfiguration(SonarrConfigFile); + } + + public RadarrConfig? GetRadarrConfig() + { + return _configProvider.ReadConfiguration(RadarrConfigFile); + } + + public LidarrConfig? GetLidarrConfig() + { + return _configProvider.ReadConfiguration(LidarrConfigFile); + } + + public QueueCleanerConfig? GetQueueCleanerConfig() + { + return _configProvider.ReadConfiguration(QueueCleanerConfigFile); + } + + public DownloadCleanerConfig? GetDownloadCleanerConfig() + { + return _configProvider.ReadConfiguration(DownloadCleanerConfigFile); + } + + public DownloadClientConfig? GetDownloadClientConfig() + { + return _configProvider.ReadConfiguration(DownloadClientConfigFile); + } + + public IgnoredDownloadsConfig? GetIgnoredDownloadsConfig() + { + return _configProvider.ReadConfiguration(IgnoredDownloadsConfigFile); + } + + public bool SaveSonarrConfig(SonarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(SonarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Sonarr configuration validation failed"); + return false; + } + } + + public bool SaveRadarrConfig(RadarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(RadarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Radarr configuration validation failed"); + return false; + } + } + + public bool SaveLidarrConfig(LidarrConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(LidarrConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "Lidarr configuration validation failed"); + return false; + } + } + + public bool SaveContentBlockerConfig(ContentBlockerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(ContentBlockerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "ContentBlocker configuration validation failed"); + return false; + } + } + + public bool SaveQueueCleanerConfig(QueueCleanerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(QueueCleanerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "QueueCleaner configuration validation failed"); + return false; + } + } + + public bool SaveDownloadCleanerConfig(DownloadCleanerConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(DownloadCleanerConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "DownloadCleaner configuration validation failed"); + return false; + } + } + + public bool SaveDownloadClientConfig(DownloadClientConfig config) + { + try + { + config.Validate(); + return _configProvider.WriteConfiguration(DownloadClientConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "DownloadClient configuration validation failed"); + return false; + } + } + + public bool SaveIgnoredDownloadsConfig(IgnoredDownloadsConfig config) + { + try + { + return _configProvider.WriteConfiguration(IgnoredDownloadsConfigFile, config); + } + catch (Exception ex) + { + _logger.LogError(ex, "IgnoredDownloads configuration save failed"); + return false; + } + } } diff --git a/code/Infrastructure/Configuration/JsonConfigurationProvider.cs b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs index d7bcdac5..d344fe38 100644 --- a/code/Infrastructure/Configuration/JsonConfigurationProvider.cs +++ b/code/Infrastructure/Configuration/JsonConfigurationProvider.cs @@ -66,7 +66,7 @@ public class JsonConfigurationProvider } /// - /// Reads a configuration from a JSON file. + /// Reads a configuration from a JSON file asynchronously. /// public async Task ReadConfigurationAsync(string fileName) where T : class, new() { @@ -107,7 +107,48 @@ public class JsonConfigurationProvider } /// - /// Writes a configuration to a JSON file in a thread-safe manner. + /// Reads a configuration from a JSON file synchronously. + /// + public T? ReadConfiguration(string fileName) where T : class, new() + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + fileLock.Wait(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return new T(); + } + + var json = File.ReadAllText(fullPath); + + if (string.IsNullOrWhiteSpace(json)) + { + _logger.LogWarning("Configuration file is empty: {file}", fullPath); + return new T(); + } + + var config = JsonSerializer.Deserialize(json, _serializerOptions); + _logger.LogDebug("Read configuration from {file}", fullPath); + return config ?? new T(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error reading configuration from {file}", fullPath); + return new T(); + } + finally + { + fileLock.Release(); + } + } + + /// + /// Writes a configuration to a JSON file in a thread-safe manner asynchronously. /// public async Task WriteConfigurationAsync(string fileName, T configuration) where T : class { @@ -151,6 +192,51 @@ public class JsonConfigurationProvider } } + /// + /// Writes a configuration to a JSON file in a thread-safe manner synchronously. + /// + public bool WriteConfiguration(string fileName, T configuration) where T : class + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + fileLock.Wait(); + + // Create backup if file exists + if (File.Exists(fullPath)) + { + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + _logger.LogDebug("Created backup of configuration file: {backup}", backupPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + // Continue anyway - prefer having new config to having no config + } + } + + var json = JsonSerializer.Serialize(configuration, _serializerOptions); + File.WriteAllText(fullPath, json); + + _logger.LogInformation("Wrote configuration to {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error writing configuration to {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + /// /// Updates a specific property within a JSON configuration file. /// @@ -233,6 +319,89 @@ public class JsonConfigurationProvider fileLock.Release(); } } + + /// + /// Updates a specific property within a JSON configuration file synchronously. + /// + public bool UpdateConfigurationProperty(string fileName, string propertyPath, T value) + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + fileLock.Wait(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return false; + } + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + + var json = File.ReadAllText(fullPath); + var jsonNode = JsonNode.Parse(json)?.AsObject(); + + if (jsonNode == null) + { + _logger.LogError("Failed to parse configuration file: {file}", fullPath); + return false; + } + + // Handle simple property paths like "propertyName" + if (!propertyPath.Contains('.')) + { + jsonNode[propertyPath] = JsonValue.Create(value); + } + else + { + // Handle nested property paths like "parent.child.property" + var parts = propertyPath.Split('.'); + var current = jsonNode; + + for (int i = 0; i < parts.Length - 1; i++) + { + if (current[parts[i]] is JsonObject nestedObject) + { + current = nestedObject; + } + else + { + var newObject = new JsonObject(); + current[parts[i]] = newObject; + current = newObject; + } + } + + current[parts[^1]] = JsonValue.Create(value); + } + + var updatedJson = jsonNode.ToJsonString(_serializerOptions); + File.WriteAllText(fullPath, updatedJson); + + _logger.LogInformation("Updated property {property} in {file}", propertyPath, fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating property {property} in {file}", propertyPath, fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } /// /// Merges an existing configuration with new values. @@ -300,6 +469,73 @@ public class JsonConfigurationProvider fileLock.Release(); } } + + /// + /// Merges an existing configuration with new values synchronously. + /// + public bool MergeConfiguration(string fileName, T newValues) where T : class + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + fileLock.Wait(); + + T currentConfig; + + if (File.Exists(fullPath)) + { + var json = File.ReadAllText(fullPath); + currentConfig = JsonSerializer.Deserialize(json, _serializerOptions) ?? Activator.CreateInstance(); + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + } + else + { + currentConfig = Activator.CreateInstance() ?? throw new InvalidOperationException($"Failed to create instance of {typeof(T).Name}"); + } + + // Merge properties using JsonNode + var currentJson = JsonSerializer.Serialize(currentConfig, _serializerOptions); + var currentNode = JsonNode.Parse(currentJson)?.AsObject(); + + var newJson = JsonSerializer.Serialize(newValues, _serializerOptions); + var newNode = JsonNode.Parse(newJson)?.AsObject(); + + if (currentNode == null || newNode == null) + { + _logger.LogError("Failed to parse configuration for merging: {file}", fullPath); + return false; + } + + MergeJsonNodes(currentNode, newNode); + + var mergedJson = currentNode.ToJsonString(_serializerOptions); + File.WriteAllText(fullPath, mergedJson); + + _logger.LogInformation("Merged configuration in {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error merging configuration in {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } private void MergeJsonNodes(JsonObject target, JsonObject source) { @@ -370,6 +606,50 @@ public class JsonConfigurationProvider } } + /// + /// Deletes a configuration file synchronously. + /// + public bool DeleteConfiguration(string fileName) + { + var fileLock = GetFileLock(fileName); + var fullPath = GetFullPath(fileName); + + try + { + fileLock.Wait(); + + if (!File.Exists(fullPath)) + { + _logger.LogWarning("Configuration file does not exist: {file}", fullPath); + return true; // Already gone + } + + // Create backup + var backupPath = $"{fullPath}.bak"; + try + { + File.Copy(fullPath, backupPath, true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to create backup of configuration file: {file}", fullPath); + } + + File.Delete(fullPath); + _logger.LogInformation("Deleted configuration file: {file}", fullPath); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting configuration file: {file}", fullPath); + return false; + } + finally + { + fileLock.Release(); + } + } + /// /// Lists all configuration files in the configuration directory. /// diff --git a/code/Infrastructure/Verticals/Arr/ArrClient.cs b/code/Infrastructure/Verticals/Arr/ArrClient.cs index 77f4cab3..65fe5e00 100644 --- a/code/Infrastructure/Verticals/Arr/ArrClient.cs +++ b/code/Infrastructure/Verticals/Arr/ArrClient.cs @@ -10,7 +10,7 @@ using Infrastructure.Interceptors; using Infrastructure.Verticals.Arr.Interfaces; using Infrastructure.Verticals.ItemStriker; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Infrastructure.Configuration; using Newtonsoft.Json; namespace Infrastructure.Verticals.Arr; @@ -19,24 +19,21 @@ public abstract class ArrClient : IArrClient { protected readonly ILogger _logger; protected readonly HttpClient _httpClient; - protected readonly LoggingConfig _loggingConfig; - protected readonly QueueCleanerConfig _queueCleanerConfig; + protected readonly IConfigManager _configManager; protected readonly IStriker _striker; protected readonly IDryRunInterceptor _dryRunInterceptor; protected ArrClient( ILogger logger, IHttpClientFactory httpClientFactory, - IOptions loggingConfig, - IOptions queueCleanerConfig, + IConfigManager configManager, IStriker striker, IDryRunInterceptor dryRunInterceptor ) { _logger = logger; _httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName); - _loggingConfig = loggingConfig.Value; - _queueCleanerConfig = queueCleanerConfig.Value; + _configManager = configManager; _striker = striker; _dryRunInterceptor = dryRunInterceptor; } @@ -75,7 +72,8 @@ public abstract class ArrClient : IArrClient public virtual async Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes) { - if (_queueCleanerConfig.ImportFailedIgnorePrivate && isPrivateDownload) + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + if (queueCleanerConfig?.ImportFailedIgnorePrivate == true && isPrivateDownload) { // ignore private trackers _logger.LogDebug("skip failed import check | download is private | {name}", record.Title); @@ -109,7 +107,8 @@ public abstract class ArrClient : IArrClient return false; } - ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : _queueCleanerConfig.ImportFailedMaxStrikes; + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + ushort maxStrikes = arrMaxStrikes > 0 ? (ushort)arrMaxStrikes : (queueCleanerConfig?.ImportFailedMaxStrikes ?? 0); return await _striker.StrikeAndCheckLimit( record.DownloadId, @@ -215,7 +214,8 @@ public abstract class ArrClient : IArrClient private bool HasIgnoredPatterns(QueueRecord record) { - if (_queueCleanerConfig.ImportFailedIgnorePatterns?.Count is null or 0) + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + if (queueCleanerConfig?.ImportFailedIgnorePatterns?.Count is null or 0) { // no patterns are configured return false; @@ -234,10 +234,11 @@ public abstract class ArrClient : IArrClient .ToList() .ForEach(x => messages.Add(x)); + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); return messages.Any( - m => _queueCleanerConfig.ImportFailedIgnorePatterns.Any( + m => queueCleanerConfig?.ImportFailedIgnorePatterns?.Any( p => !string.IsNullOrWhiteSpace(p.Trim()) && m.Contains(p, StringComparison.InvariantCultureIgnoreCase) - ) + ) ?? false ); } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs index d3cccb81..06952025 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs @@ -17,16 +17,14 @@ using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Infrastructure.Configuration; namespace Infrastructure.Verticals.DownloadClient; public abstract class DownloadService : IDownloadService { protected readonly ILogger _logger; - protected readonly QueueCleanerConfig _queueCleanerConfig; - protected readonly ContentBlockerConfig _contentBlockerConfig; - protected readonly DownloadCleanerConfig _downloadCleanerConfig; + protected readonly IConfigManager _configManager; protected readonly IMemoryCache _cache; protected readonly IFilenameEvaluator _filenameEvaluator; protected readonly IStriker _striker; @@ -40,9 +38,7 @@ public abstract class DownloadService : IDownloadService protected DownloadService( ILogger logger, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, + IConfigManager configManager, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, @@ -52,9 +48,7 @@ public abstract class DownloadService : IDownloadService ) { _logger = logger; - _queueCleanerConfig = queueCleanerConfig.Value; - _contentBlockerConfig = contentBlockerConfig.Value; - _downloadCleanerConfig = downloadCleanerConfig.Value; + _configManager = configManager; _cache = cache; _filenameEvaluator = filenameEvaluator; _striker = striker; @@ -111,7 +105,8 @@ public abstract class DownloadService : IDownloadService protected void ResetStalledStrikesOnProgress(string hash, long downloaded) { - if (!_queueCleanerConfig.StalledResetStrikesOnProgress) + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + if (queueCleanerConfig == null || !queueCleanerConfig.StalledResetStrikesOnProgress) { return; } @@ -129,7 +124,8 @@ public abstract class DownloadService : IDownloadService protected void ResetSlowSpeedStrikesOnProgress(string downloadName, string hash) { - if (!_queueCleanerConfig.SlowResetStrikesOnProgress) + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + if (queueCleanerConfig == null || !queueCleanerConfig.SlowResetStrikesOnProgress) { return; } @@ -147,7 +143,8 @@ public abstract class DownloadService : IDownloadService protected void ResetSlowTimeStrikesOnProgress(string downloadName, string hash) { - if (!_queueCleanerConfig.SlowResetStrikesOnProgress) + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + if (queueCleanerConfig == null || !queueCleanerConfig.SlowResetStrikesOnProgress) { return; } @@ -176,8 +173,10 @@ public abstract class DownloadService : IDownloadService { _logger.LogTrace("slow speed | {speed}/s | {name}", currentSpeed.ToString(), downloadName); + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + int maxStrikes = queueCleanerConfig?.SlowMaxStrikes ?? 0; bool shouldRemove = await _striker - .StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.SlowMaxStrikes, StrikeType.SlowSpeed); + .StrikeAndCheckLimit(downloadHash, downloadName, maxStrikes, StrikeType.SlowSpeed); if (shouldRemove) { @@ -193,8 +192,10 @@ public abstract class DownloadService : IDownloadService { _logger.LogTrace("slow estimated time | {time} | {name}", currentTime.ToString(), downloadName); + var queueCleanerConfig = _configManager.GetQueueCleanerConfig(); + int maxStrikes = queueCleanerConfig?.SlowMaxStrikes ?? 0; bool shouldRemove = await _striker - .StrikeAndCheckLimit(downloadHash, downloadName, _queueCleanerConfig.SlowMaxStrikes, StrikeType.SlowTime); + .StrikeAndCheckLimit(downloadHash, downloadName, maxStrikes, StrikeType.SlowTime); if (shouldRemove) { diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs index 88546bc9..182fa9fa 100644 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs @@ -17,23 +17,22 @@ using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using Infrastructure.Configuration; using QBittorrent.Client; namespace Infrastructure.Verticals.DownloadClient.QBittorrent; public class QBitService : DownloadService, IQBitService { - private readonly QBitConfig _config; - private readonly QBittorrentClient _client; + protected readonly IHttpClientFactory _httpClientFactory; + protected readonly IConfigManager _configManager; + protected readonly QBittorrentClient _client; + protected readonly ILogger _logger; public QBitService( ILogger logger, IHttpClientFactory httpClientFactory, - IOptions config, - IOptions queueCleanerConfig, - IOptions contentBlockerConfig, - IOptions downloadCleanerConfig, + IConfigManager configManager, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, @@ -41,27 +40,40 @@ public class QBitService : DownloadService, IQBitService IDryRunInterceptor dryRunInterceptor, IHardLinkFileService hardLinkFileService ) : base( - logger, queueCleanerConfig, contentBlockerConfig, downloadCleanerConfig, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService + logger, configManager, cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService ) { - _config = config.Value; - _config.Validate(); - UriBuilder uriBuilder = new(_config.Url); - uriBuilder.Path = string.IsNullOrEmpty(_config.UrlBase) - ? uriBuilder.Path - : $"{uriBuilder.Path.TrimEnd('/')}/{_config.UrlBase.TrimStart('/')}"; - _client = new(httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri); + _logger = logger; + _httpClientFactory = httpClientFactory; + _configManager = configManager; + + // Get configuration for initialization + var config = _configManager.GetConfiguration("qbit.json"); + if (config != null) + { + config.Validate(); + UriBuilder uriBuilder = new(config.Url); + uriBuilder.Path = string.IsNullOrEmpty(config.UrlBase) + ? uriBuilder.Path + : $"{uriBuilder.Path.TrimEnd('/')}/{config.UrlBase.TrimStart('/')}"; + _client = new(_httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), uriBuilder.Uri); + } + else + { + _logger.LogError("Failed to load QBit configuration"); + throw new InvalidOperationException("QBit configuration is missing or invalid"); + } } public override async Task LoginAsync() { - if (string.IsNullOrEmpty(_config.Username) && string.IsNullOrEmpty(_config.Password)) + var config = _configManager.GetConfiguration("qbit.json"); + if (config == null || (string.IsNullOrEmpty(config.Username) && string.IsNullOrEmpty(config.Password))) { return; } - - await _client.LoginAsync(_config.Username, _config.Password); + + await _client.LoginAsync(config.Username, config.Password); } /// @@ -76,11 +88,11 @@ public class QBitService : DownloadService, IQBitService _logger.LogDebug("failed to find torrent {hash} in the download client", hash); return result; } - + result.Found = true; IReadOnlyList trackers = await GetTrackersAsync(hash); - + if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) { @@ -105,14 +117,14 @@ public class QBitService : DownloadService, IQBitService if (files?.Count is > 0 && files.All(x => x.Priority is TorrentContentPriority.Skip)) { result.ShouldRemove = true; - + // if all files were blocked by qBittorrent if (download is { CompletionOn: not null, Downloaded: null or 0 }) { result.DeleteReason = DeleteReason.AllFilesSkippedByQBit; return result; } - + // remove if all files are unwanted result.DeleteReason = DeleteReason.AllFilesSkipped; return result; @@ -140,19 +152,19 @@ public class QBitService : DownloadService, IQBitService _logger.LogDebug("failed to find torrent {hash} in the download client", hash); return result; } - + // Mark as processed since we found the download result.Found = true; - + IReadOnlyList trackers = await GetTrackersAsync(hash); - + if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true)) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); return result; } - + TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(hash); if (torrentProperties is null) @@ -167,13 +179,13 @@ public class QBitService : DownloadService, IQBitService result.IsPrivate = isPrivate; - if (_contentBlockerConfig.IgnorePrivate && isPrivate) + if (_configManager.GetConfiguration("contentblocker.json").IgnorePrivate && isPrivate) { // ignore private trackers _logger.LogDebug("skip files check | download is private | {name}", download.Name); return result; } - + IReadOnlyList? files = await _client.GetTorrentContentsAsync(hash); if (files is null) @@ -184,7 +196,7 @@ public class QBitService : DownloadService, IQBitService List unwantedFiles = []; long totalFiles = 0; long totalUnwantedFiles = 0; - + foreach (TorrentContent file in files) { if (!file.Index.HasValue) @@ -204,7 +216,7 @@ public class QBitService : DownloadService, IQBitService { continue; } - + _logger.LogInformation("unwanted file found | {file}", file.Name); unwantedFiles.Add(file.Index.Value); totalUnwantedFiles++; @@ -214,12 +226,12 @@ public class QBitService : DownloadService, IQBitService { return result; } - + if (totalUnwantedFiles == totalFiles) { // Skip marking files as unwanted. The download will be removed completely. result.ShouldRemove = true; - + return result; } @@ -227,10 +239,10 @@ public class QBitService : DownloadService, IQBitService { await _dryRunInterceptor.InterceptAsync(SkipFile, hash, fileIndex); } - + return result; } - + /// public override async Task?> GetSeedingDownloads() => (await _client.GetTorrentListAsync(new() @@ -258,9 +270,9 @@ public class QBitService : DownloadService, IQBitService .Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) .Where(x => { - if (_downloadCleanerConfig.UnlinkedUseTag) + if (_configManager.GetConfiguration("downloadcleaner.json").UnlinkedUseTag) { - return !x.Tags.Any(tag => tag.Equals(_downloadCleanerConfig.UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase)); + return !x.Tags.Any(tag => tag.Equals(_configManager.GetConfiguration("downloadcleaner.json").UnlinkedTargetCategory, StringComparison.InvariantCultureIgnoreCase)); } return true; @@ -276,20 +288,20 @@ public class QBitService : DownloadService, IQBitService { return; } - + foreach (TorrentInfo download in downloads) { if (string.IsNullOrEmpty(download.Hash)) { continue; } - + if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) { _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); continue; } - + IReadOnlyList trackers = await GetTrackersAsync(download.Hash); if (ignoredDownloads.Count > 0 && @@ -298,16 +310,16 @@ public class QBitService : DownloadService, IQBitService _logger.LogInformation("skip | download is ignored | {name}", download.Name); continue; } - + CleanCategory? category = categoriesToClean .FirstOrDefault(x => download.Category.Equals(x.Name, StringComparison.InvariantCultureIgnoreCase)); - + if (category is null) { continue; } - - if (!_downloadCleanerConfig.DeletePrivate) + + if (!_configManager.GetConfiguration("downloadcleaner.json").DeletePrivate) { TorrentProperties? torrentProperties = await _client.GetTorrentPropertiesAsync(download.Hash); @@ -316,7 +328,7 @@ public class QBitService : DownloadService, IQBitService _logger.LogDebug("failed to find torrent properties in the download client | {name}", download.Name); return; } - + bool isPrivate = torrentProperties.AdditionalData.TryGetValue("is_private", out var dictValue) && bool.TryParse(dictValue?.ToString(), out bool boolValue) && boolValue; @@ -327,7 +339,7 @@ public class QBitService : DownloadService, IQBitService continue; } } - + ContextProvider.Set("downloadName", download.Name); ContextProvider.Set("hash", download.Hash); @@ -347,7 +359,7 @@ public class QBitService : DownloadService, IQBitService : "MAX_SEED_TIME", download.Name ); - + await _notifier.NotifyDownloadCleaned(download.Ratio, download.SeedingTime ?? TimeSpan.Zero, category.Name, result.Reason); } } @@ -355,12 +367,12 @@ public class QBitService : DownloadService, IQBitService public override async Task CreateCategoryAsync(string name) { IReadOnlyDictionary? existingCategories = await _client.GetCategoriesAsync(); - + if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) { return; } - + await _dryRunInterceptor.InterceptAsync(CreateCategory, name); } @@ -370,34 +382,34 @@ public class QBitService : DownloadService, IQBitService { return; } - - if (!string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)) + + if (!string.IsNullOrEmpty(_configManager.GetConfiguration("downloadcleaner.json").UnlinkedIgnoredRootDir)) { - _hardLinkFileService.PopulateFileCounts(_downloadCleanerConfig.UnlinkedIgnoredRootDir); + _hardLinkFileService.PopulateFileCounts(_configManager.GetConfiguration("downloadcleaner.json").UnlinkedIgnoredRootDir); } - + foreach (TorrentInfo download in downloads) { if (string.IsNullOrEmpty(download.Hash)) { continue; } - + if (excludedHashes.Any(x => x.Equals(download.Hash, StringComparison.InvariantCultureIgnoreCase))) { _logger.LogDebug("skip | download is used by an arr | {name}", download.Name); continue; } - + IReadOnlyList trackers = await GetTrackersAsync(download.Hash); - + if (ignoredDownloads.Count > 0 && (download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)))) { _logger.LogInformation("skip | download is ignored | {name}", download.Name); continue; } - + IReadOnlyList? files = await _client.GetTorrentContentsAsync(download.Hash); if (files is null) @@ -409,7 +421,7 @@ public class QBitService : DownloadService, IQBitService ContextProvider.Set("downloadName", download.Name); ContextProvider.Set("hash", download.Hash); bool hasHardlinks = false; - + foreach (TorrentContent file in files) { if (!file.Index.HasValue) @@ -426,8 +438,8 @@ public class QBitService : DownloadService, IQBitService _logger.LogDebug("skip | file is not downloaded | {file}", filePath); continue; } - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_downloadCleanerConfig.UnlinkedIgnoredRootDir)); + + long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, !string.IsNullOrEmpty(_configManager.GetConfiguration("downloadcleaner.json").UnlinkedIgnoredRootDir)); if (hardlinkCount < 0) { @@ -442,29 +454,29 @@ public class QBitService : DownloadService, IQBitService break; } } - + if (hasHardlinks) { _logger.LogDebug("skip | download has hardlinks | {name}", download.Name); continue; } - - await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _downloadCleanerConfig.UnlinkedTargetCategory); - if (_downloadCleanerConfig.UnlinkedUseTag) + await _dryRunInterceptor.InterceptAsync(ChangeCategory, download.Hash, _configManager.GetConfiguration("downloadcleaner.json").UnlinkedTargetCategory); + + if (_configManager.GetConfiguration("downloadcleaner.json").UnlinkedUseTag) { _logger.LogInformation("tag added for {name}", download.Name); } else { _logger.LogInformation("category changed for {name}", download.Name); - download.Category = _downloadCleanerConfig.UnlinkedTargetCategory; + download.Category = _configManager.GetConfiguration("downloadcleaner.json").UnlinkedTargetCategory; } - - await _notifier.NotifyCategoryChanged(download.Category, _downloadCleanerConfig.UnlinkedTargetCategory, _downloadCleanerConfig.UnlinkedUseTag); + + await _notifier.NotifyCategoryChanged(download.Category, _configManager.GetConfiguration("downloadcleaner.json").UnlinkedTargetCategory, _configManager.GetConfiguration("downloadcleaner.json").UnlinkedUseTag); } } - + /// [DryRunSafeguard] public override async Task DeleteDownload(string hash) @@ -477,7 +489,7 @@ public class QBitService : DownloadService, IQBitService { await _client.AddCategoryAsync(name); } - + [DryRunSafeguard] protected virtual async Task SkipFile(string hash, int fileIndex) { @@ -487,12 +499,12 @@ public class QBitService : DownloadService, IQBitService [DryRunSafeguard] protected virtual async Task ChangeCategory(string hash, string newCategory) { - if (_downloadCleanerConfig.UnlinkedUseTag) + if (_configManager.GetConfiguration("downloadcleaner.json").UnlinkedUseTag) { await _client.AddTorrentTagAsync([hash], newCategory); return; } - + await _client.SetTorrentCategoryAsync([hash], newCategory); } @@ -515,37 +527,37 @@ public class QBitService : DownloadService, IQBitService private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfSlow(TorrentInfo download, bool isPrivate) { - if (_queueCleanerConfig.SlowMaxStrikes is 0) + if (_configManager.GetConfiguration("queuecleaner.json").SlowMaxStrikes is 0) { return (false, DeleteReason.None); } - + if (download.State is not (TorrentState.Downloading or TorrentState.ForcedDownload)) { return (false, DeleteReason.None); } - + if (download.DownloadSpeed <= 0) { return (false, DeleteReason.None); } - - if (_queueCleanerConfig.SlowIgnorePrivate && isPrivate) + + if (_configManager.GetConfiguration("queuecleaner.json").SlowIgnorePrivate && isPrivate) { // ignore private trackers _logger.LogDebug("skip slow check | download is private | {name}", download.Name); return (false, DeleteReason.None); } - if (download.Size > (_queueCleanerConfig.SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue)) + if (download.Size > (_configManager.GetConfiguration("queuecleaner.json").SlowIgnoreAboveSizeByteSize?.Bytes ?? long.MaxValue)) { _logger.LogDebug("skip slow check | download is too large | {name}", download.Name); return (false, DeleteReason.None); } - - ByteSize minSpeed = _queueCleanerConfig.SlowMinSpeedByteSize; + + ByteSize minSpeed = _configManager.GetConfiguration("queuecleaner.json").SlowMinSpeedByteSize; ByteSize currentSpeed = new ByteSize(download.DownloadSpeed); - SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_queueCleanerConfig.SlowMaxTime); + SmartTimeSpan maxTime = SmartTimeSpan.FromHours(_configManager.GetConfiguration("queuecleaner.json").SlowMaxTime); SmartTimeSpan currentTime = new SmartTimeSpan(download.EstimatedTime ?? TimeSpan.Zero); return await CheckIfSlow( @@ -560,11 +572,11 @@ public class QBitService : DownloadService, IQBitService private async Task<(bool ShouldRemove, DeleteReason Reason)> CheckIfStuck(TorrentInfo torrent, bool isPrivate) { - if (_queueCleanerConfig.StalledMaxStrikes is 0 && _queueCleanerConfig.DownloadingMetadataMaxStrikes is 0) + if (_configManager.GetConfiguration("queuecleaner.json").StalledMaxStrikes is 0 && _configManager.GetConfiguration("queuecleaner.json").DownloadingMetadataMaxStrikes is 0) { return (false, DeleteReason.None); } - + if (torrent.State is not TorrentState.StalledDownload and not TorrentState.FetchingMetadata and not TorrentState.ForcedFetchingMetadata) { @@ -572,9 +584,9 @@ public class QBitService : DownloadService, IQBitService return (false, DeleteReason.None); } - if (_queueCleanerConfig.StalledMaxStrikes > 0 && torrent.State is TorrentState.StalledDownload) + if (_configManager.GetConfiguration("queuecleaner.json").StalledMaxStrikes > 0 && torrent.State is TorrentState.StalledDownload) { - if (_queueCleanerConfig.StalledIgnorePrivate && isPrivate) + if (_configManager.GetConfiguration("queuecleaner.json").StalledIgnorePrivate && isPrivate) { // ignore private trackers _logger.LogDebug("skip stalled check | download is private | {name}", torrent.Name); diff --git a/code/Infrastructure/Verticals/Notifications/Apprise/AppriseProvider.cs b/code/Infrastructure/Verticals/Notifications/Apprise/AppriseProvider.cs index 43807ccd..8bebbc61 100644 --- a/code/Infrastructure/Verticals/Notifications/Apprise/AppriseProvider.cs +++ b/code/Infrastructure/Verticals/Notifications/Apprise/AppriseProvider.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Text; +using Infrastructure.Configuration; using Infrastructure.Verticals.Notifications.Models; using Microsoft.Extensions.Options; @@ -9,10 +10,10 @@ public sealed class AppriseProvider : NotificationProvider private readonly AppriseConfig _config; private readonly IAppriseProxy _proxy; - public AppriseProvider(IOptions config, IAppriseProxy proxy) - : base(config) + public AppriseProvider(IConfigManager configManager, IAppriseProxy proxy) + : base(configManager) { - _config = config.Value; + _config = configManager.GetConfiguration("apprise.json") ?? new AppriseConfig(); _proxy = proxy; } diff --git a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs index 12dd9b41..d03ff2b2 100644 --- a/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs +++ b/code/Infrastructure/Verticals/Notifications/Notifiarr/NotifiarrProvider.cs @@ -1,3 +1,4 @@ +using Infrastructure.Configuration; using Infrastructure.Verticals.Notifications.Models; using Mapster; using Microsoft.Extensions.Options; @@ -15,10 +16,10 @@ public class NotifiarrProvider : NotificationProvider public override string Name => "Notifiarr"; - public NotifiarrProvider(IOptions config, INotifiarrProxy proxy) - : base(config) + public NotifiarrProvider(IConfigManager configManager, INotifiarrProxy proxy) + : base(configManager) { - _config = config.Value; + _config = configManager.GetConfiguration("notifiarr.json") ?? new NotifiarrConfig(); _proxy = proxy; } diff --git a/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs b/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs index 169c5eeb..65347fff 100644 --- a/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs +++ b/code/Infrastructure/Verticals/Notifications/NotificationProvider.cs @@ -1,4 +1,5 @@ using Common.Configuration.Notification; +using Infrastructure.Configuration; using Infrastructure.Verticals.Notifications.Models; using Microsoft.Extensions.Options; @@ -6,13 +7,16 @@ namespace Infrastructure.Verticals.Notifications; public abstract class NotificationProvider : INotificationProvider { - protected NotificationProvider(IOptions config) + protected NotificationProvider(IConfigManager configManager) { - Config = config.Value; + ConfigManager = configManager; + Config = configManager.GetConfiguration("notification.json") ?? new NotificationConfig(); } public abstract string Name { get; } + public IConfigManager ConfigManager { get; } + public NotificationConfig Config { get; } public abstract Task OnFailedImportStrike(FailedImportStrikeNotification notification);