using System.Collections.Concurrent; using System.Net; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.General; using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; /// /// Implementation of the dynamic HttpClient factory /// public class DynamicHttpClientFactory : IDynamicHttpClientFactory { private const char VersionSeparator = '#'; private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientConfigStore _configStore; private readonly IOptionsMonitorCache _optionsCache; private readonly ILogger _logger; private readonly ConcurrentDictionary _generations = new(); public DynamicHttpClientFactory( IHttpClientFactory httpClientFactory, IHttpClientConfigStore configStore, IOptionsMonitorCache optionsCache, ILogger logger) { _httpClientFactory = httpClientFactory; _configStore = configStore; _optionsCache = optionsCache; _logger = logger; } /// /// Appends the generation so IHttpClientFactory builds a fresh handler. Generation 0 keeps the plain name. /// internal static string ComposeVersionedName(string clientName, int generation) => generation == 0 ? clientName : $"{clientName}{VersionSeparator}{generation}"; /// /// Recovers the configuration store key from a name built by . /// internal static string StripVersion(string versionedName) { int index = versionedName.IndexOf(VersionSeparator); return index < 0 ? versionedName : versionedName[..index]; } private int GetGeneration(string clientName) => _generations.GetOrAdd(clientName, 0); private void BumpGeneration(string clientName) => _generations.AddOrUpdate(clientName, 1, (_, generation) => generation + 1); public HttpClient CreateClient(string clientName, HttpClientConfig config) { RegisterConfiguration(clientName, config); return CreateClient(clientName); } public HttpClient CreateClient(string clientName) { if (!_configStore.TryGetConfiguration(clientName, out _)) { throw new InvalidOperationException($"No configuration found for client '{clientName}'. Register configuration first."); } return _httpClientFactory.CreateClient(ComposeVersionedName(clientName, GetGeneration(clientName))); } public void RegisterConfiguration(string clientName, HttpClientConfig config) { // No bump: this runs on every download-service build, not only on changes. _configStore.AddConfiguration(clientName, config); } public void RegisterRetryClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent) { HttpClientConfig config = new() { Name = clientName, Timeout = timeout, Type = HttpClientType.WithRetry, RetryConfig = retryConfig, CertificateValidationType = certificateType, SendUserAgent = sendUserAgent }; RegisterConfiguration(clientName, config); } public void RegisterDelugeClient(string clientName, int timeout, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent) { HttpClientConfig config = new() { Name = clientName, Timeout = timeout, Type = HttpClientType.Deluge, RetryConfig = retryConfig, AllowAutoRedirect = true, CertificateValidationType = certificateType, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, SendUserAgent = sendUserAgent }; RegisterConfiguration(clientName, config); } public void RegisterDownloadClient( string clientName, int timeout, HttpClientType clientType, RetryConfig retryConfig, CertificateValidationType certificateType, bool sendUserAgent, HttpAuthType httpAuthType = HttpAuthType.Basic, NetworkCredential? httpAuthCredential = null, Uri? baseUri = null) { HttpClientConfig config = new() { Name = clientName, Timeout = timeout, Type = clientType, RetryConfig = retryConfig, CertificateValidationType = certificateType, SendUserAgent = sendUserAgent, HttpAuthType = httpAuthType, HttpAuthCredential = httpAuthCredential, BaseUri = baseUri, }; // Configure Deluge-specific settings if needed if (clientType == HttpClientType.Deluge) { config.AllowAutoRedirect = true; config.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; } RegisterConfiguration(clientName, config); } public void RegisterPlainClient(string clientName, bool sendUserAgent) { HttpClientConfig config = new() { Name = clientName, Type = HttpClientType.Plain, SendUserAgent = sendUserAgent }; RegisterConfiguration(clientName, config); } public void UnregisterConfiguration(string clientName) { _configStore.RemoveConfiguration(clientName); // The cached handler outlives the options, so only a new name drops stale credentials. _optionsCache.TryRemove(ComposeVersionedName(clientName, GetGeneration(clientName))); BumpGeneration(clientName); _logger.LogDebug("Unregistered and invalidated HTTP client configuration: {ClientName}", clientName); } public void UpdateAllClientsFromGeneralConfig(GeneralConfig generalConfig) { var allConfigurations = _configStore.GetAllConfigurations().ToList(); if (!allConfigurations.Any()) { _logger.LogDebug("No HTTP client configurations to update"); return; } var updatedConfigurations = allConfigurations.Select(kvp => { var config = kvp.Value; // Update timeout and certificate validation for all clients config.Timeout = generalConfig.HttpTimeout; config.CertificateValidationType = generalConfig.HttpCertificateValidation; config.SendUserAgent = generalConfig.HttpSendUserAgent; // Update retry configuration if it exists if (config.RetryConfig != null) { config.RetryConfig.MaxRetries = generalConfig.HttpMaxRetries; } return new KeyValuePair(kvp.Key, config); }).ToList(); // Apply all updates to our configuration store _configStore.UpdateConfigurations(updatedConfigurations); // CRITICAL: Invalidate IHttpClientFactory's cached configurations // This forces the factory to call our Configure() method again with updated settings var clientNames = updatedConfigurations.Select(kvp => kvp.Key).ToList(); foreach (string clientName in clientNames) { // Direct IHttpClientFactory consumers (ArrClient, notification proxies) request the bare name. _optionsCache.TryRemove(clientName); _optionsCache.TryRemove(ComposeVersionedName(clientName, GetGeneration(clientName))); BumpGeneration(clientName); } _logger.LogInformation("Updated and invalidated {Count} HTTP client configurations with new general settings: " + "Timeout={Timeout}s, MaxRetries={MaxRetries}, CertificateValidation={CertValidation}", updatedConfigurations.Count, generalConfig.HttpTimeout, generalConfig.HttpMaxRetries, generalConfig.HttpCertificateValidation); } }