From 3c2bb7a2891a0db1f5acada17306f0a41e16fb94 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Fri, 16 May 2025 18:53:01 +0300 Subject: [PATCH] #11 --- .../DownloadClient/ClientConfig.cs | 49 +++++ code/Executable/DependencyInjection/MainDI.cs | 39 ++-- .../Http/DynamicHttpClientProvider.cs | 120 ++++++++++++ .../Http/IDynamicHttpClientProvider.cs | 16 ++ .../DownloadClient/Deluge/DelugeClient.cs | 7 +- .../DownloadClient/Deluge/DelugeService.cs | 71 +++++-- .../DownloadClient/DownloadService.cs | 21 ++- .../Factory/DownloadClientFactory.cs | 174 ++++++++++++++++++ .../Factory/IDownloadClientFactory.cs | 40 ++++ .../DownloadClient/IDownloadService.cs | 6 + .../DownloadClient/QBittorrent/QBitService.cs | 148 +++++++++++---- .../Transmission/TransmissionService.cs | 96 +++++++--- .../Verticals/QueueCleaner/QueueCleaner.cs | 40 ++-- 13 files changed, 694 insertions(+), 133 deletions(-) create mode 100644 code/Infrastructure/Http/DynamicHttpClientProvider.cs create mode 100644 code/Infrastructure/Http/IDynamicHttpClientProvider.cs create mode 100644 code/Infrastructure/Verticals/DownloadClient/Factory/DownloadClientFactory.cs create mode 100644 code/Infrastructure/Verticals/DownloadClient/Factory/IDownloadClientFactory.cs diff --git a/code/Common/Configuration/DownloadClient/ClientConfig.cs b/code/Common/Configuration/DownloadClient/ClientConfig.cs index 13f5b214..947ccf3b 100644 --- a/code/Common/Configuration/DownloadClient/ClientConfig.cs +++ b/code/Common/Configuration/DownloadClient/ClientConfig.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; + namespace Common.Configuration.DownloadClient; /// @@ -54,4 +56,51 @@ public sealed record ClientConfig /// Whether this client is enabled /// public bool Enabled { get; init; } = true; + + /// + /// The base URL path component, used by clients like Transmission and Deluge + /// + [ConfigurationKeyName("URL_BASE")] + public string UrlBase { get; init; } = string.Empty; + + /// + /// Use HTTPS protocol + /// + public bool UseHttps { get; init; } = false; + + /// + /// The computed full URL for the client + /// + public Uri Url => new Uri($"{(UseHttps ? "https" : "http")}://{Host}:{Port}/{UrlBase.TrimStart('/').TrimEnd('/')}"); + + /// + /// Validates the configuration + /// + public void Validate() + { + if (string.IsNullOrWhiteSpace(Id)) + { + throw new InvalidOperationException("Client ID cannot be empty"); + } + + if (string.IsNullOrWhiteSpace(Name)) + { + throw new InvalidOperationException($"Client name cannot be empty for client ID: {Id}"); + } + + if (string.IsNullOrWhiteSpace(Host)) + { + throw new InvalidOperationException($"Host cannot be empty for client ID: {Id}"); + } + + if (Port <= 0) + { + throw new InvalidOperationException($"Port must be greater than 0 for client ID: {Id}"); + } + + if (Type == Common.Enums.DownloadClient.None) + { + throw new InvalidOperationException($"Client type must be specified for client ID: {Id}"); + } + } } diff --git a/code/Executable/DependencyInjection/MainDI.cs b/code/Executable/DependencyInjection/MainDI.cs index 8c2a3cab..ef005a96 100644 --- a/code/Executable/DependencyInjection/MainDI.cs +++ b/code/Executable/DependencyInjection/MainDI.cs @@ -2,14 +2,16 @@ using System.Net; using Common.Configuration.General; using Common.Helpers; using Domain.Models.Arr; +using Infrastructure.Configuration; +using Infrastructure.Http; using Infrastructure.Services; +using Infrastructure.Verticals.DownloadClient.Factory; using Infrastructure.Verticals.DownloadClient.Deluge; using Infrastructure.Verticals.DownloadRemover.Consumers; using Infrastructure.Verticals.Notifications.Consumers; using Infrastructure.Verticals.Notifications.Models; using MassTransit; using MassTransit.Configuration; -using Infrastructure.Configuration; using Polly; using Polly.Extensions.Http; @@ -26,6 +28,7 @@ public static class MainDI options.ExpirationScanFrequency = TimeSpan.FromMinutes(1); }) .AddServices() + .AddDownloadClientServices() .AddQuartzServices(configuration) .AddNotifications(configuration) .AddMassTransit(config => @@ -69,6 +72,9 @@ public static class MainDI // add default HttpClient services.AddHttpClient(); + // add dynamic HTTP client provider + services.AddSingleton(); + var configManager = services.BuildServiceProvider().GetRequiredService(); HttpConfig config = configManager.GetConfiguration("http.json") ?? new(); config.Validate(); @@ -90,24 +96,8 @@ public static class MainDI }) .AddRetryPolicyHandler(config); - // add Deluge HttpClient - services - .AddHttpClient(nameof(DelugeService), x => - { - x.Timeout = TimeSpan.FromSeconds(config.Timeout); - }) - .ConfigurePrimaryHttpMessageHandler(_ => - { - return new HttpClientHandler - { - AllowAutoRedirect = true, - UseCookies = true, - CookieContainer = new CookieContainer(), - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, - ServerCertificateCustomValidationCallback = (_, _, _, _) => true - }; - }) - .AddRetryPolicyHandler(config); + // Note: We're no longer configuring specific named HttpClients for each download service + // Instead, we use the DynamicHttpClientProvider to create HttpClients as needed based on client configurations return services; } @@ -120,4 +110,15 @@ public static class MainDI .OrResult(response => !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized) .WaitAndRetryAsync(config.MaxRetries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))) ); + + private static IServiceCollection AddDownloadClientServices(this IServiceCollection services) => + services + // Register the factory that creates download clients + .AddSingleton() + + // Register all download client service types + // The factory will create instances as needed based on the client configuration + .AddTransient() + .AddTransient() + .AddTransient(); } \ No newline at end of file diff --git a/code/Infrastructure/Http/DynamicHttpClientProvider.cs b/code/Infrastructure/Http/DynamicHttpClientProvider.cs new file mode 100644 index 00000000..088338d7 --- /dev/null +++ b/code/Infrastructure/Http/DynamicHttpClientProvider.cs @@ -0,0 +1,120 @@ +using System.Net; +using Common.Configuration.DownloadClient; +using Common.Configuration.General; +using Infrastructure.Configuration; +using Infrastructure.Services; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Extensions.Http; + +namespace Infrastructure.Http; + +/// +/// Provides dynamically configured HTTP clients for download services +/// +public class DynamicHttpClientProvider : IDynamicHttpClientProvider +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfigManager _configManager; + private readonly CertificateValidationService _certificateValidationService; + + public DynamicHttpClientProvider( + ILogger logger, + IHttpClientFactory httpClientFactory, + IConfigManager configManager, + CertificateValidationService certificateValidationService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _configManager = configManager; + _certificateValidationService = certificateValidationService; + } + + /// + public HttpClient CreateClient(ClientConfig clientConfig) + { + if (clientConfig == null) + { + throw new ArgumentNullException(nameof(clientConfig)); + } + + // Try to use named client if it exists + try + { + string clientName = GetClientName(clientConfig); + return _httpClientFactory.CreateClient(clientName); + } + catch (InvalidOperationException) + { + _logger.LogWarning("Named HTTP client for {clientId} not found, creating generic client", clientConfig.Id); + return CreateGenericClient(clientConfig); + } + } + + /// + /// Gets the client name for a specific client configuration + /// + /// The client configuration + /// The client name for use with IHttpClientFactory + private string GetClientName(ClientConfig clientConfig) + { + return $"DownloadClient_{clientConfig.Id}"; + } + + /// + /// Creates a generic HTTP client with appropriate configuration + /// + /// The client configuration + /// A configured HttpClient instance + private HttpClient CreateGenericClient(ClientConfig clientConfig) + { + var httpConfig = _configManager.GetConfiguration("http.json") ?? new HttpConfig(); + + // Create handler with certificate validation + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = _certificateValidationService.ShouldByPassValidationError, + UseDefaultCredentials = false + }; + + if (clientConfig.Type == Common.Enums.DownloadClient.Deluge) + { + handler.AllowAutoRedirect = true; + handler.UseCookies = true; + handler.CookieContainer = new CookieContainer(); + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; + } + + // Create client with policy + var client = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(httpConfig.Timeout) + }; + + // Set base address if needed + if (clientConfig.Url != null) + { + client.BaseAddress = clientConfig.Url; + } + + _logger.LogDebug("Created generic HTTP client for client {clientId} with base address {baseAddress}", + clientConfig.Id, client.BaseAddress); + + return client; + } + + /// + /// Creates a retry policy for the HTTP client + /// + /// The HTTP configuration + /// A configured policy + private static IAsyncPolicy CreateRetryPolicy(HttpConfig httpConfig) + { + return HttpPolicyExtensions + .HandleTransientHttpError() + // Do not retry on Unauthorized + .OrResult(response => !response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Unauthorized) + .WaitAndRetryAsync(httpConfig.MaxRetries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } +} diff --git a/code/Infrastructure/Http/IDynamicHttpClientProvider.cs b/code/Infrastructure/Http/IDynamicHttpClientProvider.cs new file mode 100644 index 00000000..ec410a38 --- /dev/null +++ b/code/Infrastructure/Http/IDynamicHttpClientProvider.cs @@ -0,0 +1,16 @@ +using Common.Configuration.DownloadClient; + +namespace Infrastructure.Http; + +/// +/// Interface for a provider that creates HTTP clients dynamically based on client configuration +/// +public interface IDynamicHttpClientProvider +{ + /// + /// Creates an HTTP client configured for the specified download client + /// + /// The client configuration + /// A configured HttpClient instance + HttpClient CreateClient(ClientConfig clientConfig); +} diff --git a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeClient.cs b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeClient.cs index 8c2a203e..10e075d4 100644 --- a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeClient.cs +++ b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeClient.cs @@ -14,7 +14,7 @@ namespace Infrastructure.Verticals.DownloadClient.Deluge; public sealed class DelugeClient { - private readonly DelugeConfig _config; + private readonly ClientConfig _config; private readonly HttpClient _httpClient; private static readonly IReadOnlyList Fields = @@ -34,11 +34,10 @@ public sealed class DelugeClient "download_location" ]; - public DelugeClient(DelugeConfig config, IHttpClientFactory httpClientFactory) + public DelugeClient(ClientConfig config, HttpClient httpClient) { _config = config; - _config.Validate(); - _httpClient = httpClientFactory.CreateClient(nameof(DelugeService)); + _httpClient = httpClient; } public async Task LoginAsync() diff --git a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs index 8bf5fba1..c3e63763 100644 --- a/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/Deluge/DelugeService.cs @@ -20,57 +20,90 @@ using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Infrastructure.Configuration; +using Infrastructure.Http; namespace Infrastructure.Verticals.DownloadClient.Deluge; public class DelugeService : DownloadService, IDelugeService { - private readonly DelugeClient _client; + private DelugeClient? _client; public DelugeService( ILogger logger, IConfigManager configManager, - IHttpClientFactory httpClientFactory, IMemoryCache cache, IFilenameEvaluator filenameEvaluator, IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider ) : base( logger, configManager, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService + filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService, + httpClientProvider ) { - var config = configManager.GetConfiguration("deluge.json"); - if (config != null) + // Client will be initialized when Initialize() is called with a specific client configuration + } + + /// + public override void Initialize(ClientConfig clientConfig) + { + // Initialize base service first + base.Initialize(clientConfig); + + // Ensure client type is correct + if (clientConfig.Type != Common.Enums.DownloadClient.Deluge) { - config.Validate(); - _client = new DelugeClient(config, httpClientFactory); + throw new InvalidOperationException($"Cannot initialize DelugeService with client type {clientConfig.Type}"); } - else + + if (_httpClient == null) { - _logger.LogWarning("Deluge configuration not found. Using default values."); - _client = new DelugeClient(new DelugeConfig - { - Url = "http://localhost:8112" - }, httpClientFactory); + throw new InvalidOperationException("HTTP client is not initialized"); } + + // Create Deluge client + _client = new DelugeClient(clientConfig, _httpClient); + + _logger.LogInformation("Initialized Deluge service for client {clientName} ({clientId})", + clientConfig.Name, clientConfig.Id); } public override async Task LoginAsync() { - await _client.LoginAsync(); - - if (!await _client.IsConnected() && !await _client.Connect()) + if (_client == null) { - throw new FatalException("Deluge WebUI is not connected to the daemon"); + throw new InvalidOperationException("Deluge client is not initialized"); + } + + try + { + await _client.LoginAsync(); + + if (!await _client.IsConnected() && !await _client.Connect()) + { + throw new FatalException("Deluge WebUI is not connected to the daemon"); + } + + _logger.LogDebug("Successfully logged in to Deluge client {clientId}", _clientConfig.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to login to Deluge client {clientId}", _clientConfig.Id); + throw; } } /// public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) { + if (_client == null) + { + throw new InvalidOperationException("Deluge client is not initialized"); + } + hash = hash.ToLowerInvariant(); DelugeContents? contents = null; @@ -563,5 +596,7 @@ public class DelugeService : DownloadService, IDelugeService public override void Dispose() { + _client = null; + _httpClient?.Dispose(); } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs index 06952025..924f4f47 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs @@ -8,7 +8,9 @@ using Common.CustomDataTypes; using Common.Helpers; using Domain.Enums; using Domain.Models.Cache; +using Infrastructure.Configuration; using Infrastructure.Helpers; +using Infrastructure.Http; using Infrastructure.Interceptors; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.Context; @@ -17,7 +19,6 @@ using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Infrastructure.Configuration; namespace Infrastructure.Verticals.DownloadClient; @@ -32,9 +33,13 @@ public abstract class DownloadService : IDownloadService protected readonly INotificationPublisher _notifier; protected readonly IDryRunInterceptor _dryRunInterceptor; protected readonly IHardLinkFileService _hardLinkFileService; + protected readonly IDynamicHttpClientProvider _httpClientProvider; // Client-specific configuration protected ClientConfig _clientConfig; + + // HTTP client for this service + protected HttpClient? _httpClient; protected DownloadService( ILogger logger, @@ -44,7 +49,8 @@ public abstract class DownloadService : IDownloadService IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider ) { _logger = logger; @@ -55,6 +61,7 @@ public abstract class DownloadService : IDownloadService _notifier = notifier; _dryRunInterceptor = dryRunInterceptor; _hardLinkFileService = hardLinkFileService; + _httpClientProvider = httpClientProvider; _cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); @@ -62,10 +69,20 @@ public abstract class DownloadService : IDownloadService _clientConfig = new ClientConfig(); } + /// + public string GetClientId() + { + return _clientConfig.Id; + } + /// public virtual void Initialize(ClientConfig clientConfig) { _clientConfig = clientConfig; + + // Create HTTP client for this service + _httpClient = _httpClientProvider.CreateClient(clientConfig); + _logger.LogDebug("Initialized download service for client {clientId} ({type})", clientConfig.Id, clientConfig.Type); } diff --git a/code/Infrastructure/Verticals/DownloadClient/Factory/DownloadClientFactory.cs b/code/Infrastructure/Verticals/DownloadClient/Factory/DownloadClientFactory.cs new file mode 100644 index 00000000..becd0f44 --- /dev/null +++ b/code/Infrastructure/Verticals/DownloadClient/Factory/DownloadClientFactory.cs @@ -0,0 +1,174 @@ +using System.Collections.Concurrent; +using Common.Configuration.DownloadClient; +using Common.Enums; +using Domain.Exceptions; +using Infrastructure.Configuration; +using Infrastructure.Interceptors; +using Infrastructure.Verticals.ContentBlocker; +using Infrastructure.Verticals.DownloadClient.Deluge; +using Infrastructure.Verticals.DownloadClient.QBittorrent; +using Infrastructure.Verticals.DownloadClient.Transmission; +using Infrastructure.Verticals.Files; +using Infrastructure.Verticals.ItemStriker; +using Infrastructure.Verticals.Notifications; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Verticals.DownloadClient.Factory; + +/// +/// Factory for creating and managing download client service instances +/// +public class DownloadClientFactory : IDownloadClientFactory +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly IConfigManager _configManager; + private readonly ConcurrentDictionary _clients = new(); + + public DownloadClientFactory( + ILogger logger, + IServiceProvider serviceProvider, + IConfigManager configManager) + { + _logger = logger; + _serviceProvider = serviceProvider; + _configManager = configManager; + } + + /// + public IDownloadService GetClient(string clientId) + { + if (string.IsNullOrWhiteSpace(clientId)) + { + throw new ArgumentException("Client ID cannot be empty", nameof(clientId)); + } + + return _clients.GetOrAdd(clientId, CreateClient); + } + + /// + public IEnumerable GetAllEnabledClients() + { + var downloadClientConfig = _configManager.GetConfiguration("downloadclients.json") + ?? new DownloadClientConfig(); + + foreach (var client in downloadClientConfig.GetEnabledClients()) + { + yield return GetClient(client.Id); + } + } + + /// + public IEnumerable GetClientsByType(DownloadClient clientType) + { + var downloadClientConfig = _configManager.GetConfiguration("downloadclients.json") + ?? new DownloadClientConfig(); + + foreach (var client in downloadClientConfig.GetEnabledClients().Where(c => c.Type == clientType)) + { + yield return GetClient(client.Id); + } + } + + /// + public void RefreshClient(string clientId) + { + if (_clients.TryRemove(clientId, out var service)) + { + service.Dispose(); + _logger.LogDebug("Removed client {clientId} from cache", clientId); + } + + // Re-create and add the client + _clients[clientId] = CreateClient(clientId); + _logger.LogDebug("Re-created client {clientId}", clientId); + } + + /// + public void RefreshAllClients() + { + _logger.LogInformation("Refreshing all download clients"); + + // Get list of client IDs to avoid modifying collection during iteration + var clientIds = _clients.Keys.ToList(); + + foreach (var clientId in clientIds) + { + RefreshClient(clientId); + } + } + + private IDownloadService CreateClient(string clientId) + { + var downloadClientConfig = _configManager.GetConfiguration("downloadclients.json") + ?? new DownloadClientConfig(); + + var clientConfig = downloadClientConfig.GetClientConfig(clientId); + + if (clientConfig == null) + { + throw new NotFoundException($"No configuration found for client with ID {clientId}"); + } + + IDownloadService service = clientConfig.Type switch + { + DownloadClient.QBittorrent => CreateQBitService(clientConfig), + DownloadClient.Transmission => CreateTransmissionService(clientConfig), + DownloadClient.Deluge => CreateDelugeService(clientConfig), + _ => throw new NotSupportedException($"Download client type {clientConfig.Type} is not supported") + }; + + // Initialize the service with its configuration + service.Initialize(clientConfig); + + _logger.LogInformation("Created client {clientName} ({clientId}) of type {clientType}", + clientConfig.Name, clientId, clientConfig.Type); + + return service; + } + + private QBitService CreateQBitService(ClientConfig clientConfig) + { + return new QBitService( + _serviceProvider.GetRequiredService>(), + _serviceProvider.GetRequiredService(), + _configManager, + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService() + ); + } + + private TransmissionService CreateTransmissionService(ClientConfig clientConfig) + { + return new TransmissionService( + _serviceProvider.GetRequiredService>(), + _configManager, + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService() + ); + } + + private DelugeService CreateDelugeService(ClientConfig clientConfig) + { + return new DelugeService( + _serviceProvider.GetRequiredService>(), + _configManager, + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService() + ); + } +} diff --git a/code/Infrastructure/Verticals/DownloadClient/Factory/IDownloadClientFactory.cs b/code/Infrastructure/Verticals/DownloadClient/Factory/IDownloadClientFactory.cs new file mode 100644 index 00000000..b8886f64 --- /dev/null +++ b/code/Infrastructure/Verticals/DownloadClient/Factory/IDownloadClientFactory.cs @@ -0,0 +1,40 @@ +using Common.Enums; + +namespace Infrastructure.Verticals.DownloadClient.Factory; + +/// +/// Factory for creating and managing download client service instances +/// +public interface IDownloadClientFactory +{ + /// + /// Gets a download client by its ID + /// + /// The client ID + /// The download service for the specified client + IDownloadService GetClient(string clientId); + + /// + /// Gets all enabled download clients + /// + /// Collection of enabled download client services + IEnumerable GetAllEnabledClients(); + + /// + /// Gets all enabled download clients of a specific type + /// + /// The client type + /// Collection of enabled download client services of the specified type + IEnumerable GetClientsByType(DownloadClient clientType); + + /// + /// Refreshes a specific client instance (disposes and recreates) + /// + /// The client ID to refresh + void RefreshClient(string clientId); + + /// + /// Refreshes all client instances (disposes and recreates) + /// + void RefreshAllClients(); +} diff --git a/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs index f88c1cdc..721eb37c 100644 --- a/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs @@ -9,6 +9,12 @@ namespace Infrastructure.Verticals.DownloadClient; public interface IDownloadService : IDisposable { + /// + /// Gets the unique identifier for this download client + /// + /// The client ID + string GetClientId(); + /// /// Initializes the download service with client-specific configuration /// diff --git a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs index 182fa9fa..de0f22f6 100644 --- a/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/QBittorrent/QBitService.cs @@ -8,7 +8,9 @@ using Common.Configuration.QueueCleaner; using Common.CustomDataTypes; using Common.Helpers; using Domain.Enums; +using Infrastructure.Configuration; using Infrastructure.Extensions; +using Infrastructure.Http; using Infrastructure.Interceptors; using Infrastructure.Verticals.ContentBlocker; using Infrastructure.Verticals.Context; @@ -17,17 +19,13 @@ using Infrastructure.Verticals.ItemStriker; using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Infrastructure.Configuration; using QBittorrent.Client; namespace Infrastructure.Verticals.DownloadClient.QBittorrent; public class QBitService : DownloadService, IQBitService { - protected readonly IHttpClientFactory _httpClientFactory; - protected readonly IConfigManager _configManager; - protected readonly QBittorrentClient _client; - protected readonly ILogger _logger; + protected QBittorrentClient? _client; public QBitService( ILogger logger, @@ -38,47 +36,68 @@ public class QBitService : DownloadService, IQBitService IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider ) : base( - logger, configManager, cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService + logger, configManager, cache, filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService, + httpClientProvider ) { - _logger = logger; - _httpClientFactory = httpClientFactory; - _configManager = configManager; - - // Get configuration for initialization - var config = _configManager.GetConfiguration("qbit.json"); - if (config != null) + // Client will be initialized when Initialize() is called with a specific client configuration + } + + /// + public override void Initialize(ClientConfig clientConfig) + { + // Initialize base service first + base.Initialize(clientConfig); + + // Ensure client type is correct + if (clientConfig.Type != Common.Enums.DownloadClient.QBittorrent) { - 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"); + throw new InvalidOperationException($"Cannot initialize QBitService with client type {clientConfig.Type}"); } + + // Create QBittorrent client + _client = new QBittorrentClient(_httpClient, clientConfig.Url); + + _logger.LogInformation("Initialized QBittorrent service for client {clientName} ({clientId})", + clientConfig.Name, clientConfig.Id); } public override async Task LoginAsync() { - var config = _configManager.GetConfiguration("qbit.json"); - if (config == null || (string.IsNullOrEmpty(config.Username) && string.IsNullOrEmpty(config.Password))) + if (_client == null) { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + + if (string.IsNullOrEmpty(_clientConfig.Username) && string.IsNullOrEmpty(_clientConfig.Password)) + { + _logger.LogDebug("No credentials configured for client {clientId}, skipping login", _clientConfig.Id); return; } - await _client.LoginAsync(config.Username, config.Password); + try + { + await _client.LoginAsync(_clientConfig.Username, _clientConfig.Password); + _logger.LogDebug("Successfully logged in to QBittorrent client {clientId}", _clientConfig.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to login to QBittorrent client {clientId}", _clientConfig.Id); + throw; + } } /// public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + DownloadCheckResult result = new(); TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) .FirstOrDefault(); @@ -136,16 +155,22 @@ public class QBitService : DownloadService, IQBitService } /// - public override async Task BlockUnwantedFilesAsync(string hash, + public override async Task BlockUnwantedFilesAsync( + string hash, BlocklistType blocklistType, ConcurrentBag patterns, ConcurrentBag regexes, IReadOnlyList ignoredDownloads ) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + + BlockFilesResult result = new(hash); TorrentInfo? download = (await _client.GetTorrentListAsync(new TorrentListQuery { Hashes = [hash] })) .FirstOrDefault(); - BlockFilesResult result = new(); if (download is null) { @@ -244,14 +269,18 @@ public class QBitService : DownloadService, IQBitService } /// - public override async Task?> GetSeedingDownloads() => - (await _client.GetTorrentListAsync(new() + public override async Task?> GetSeedingDownloads() + { + if (_client == null) { - Filter = TorrentListFilter.Seeding - })) - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Cast() - .ToList(); + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + + var torrentList = await _client.GetTorrentListAsync(new TorrentListQuery { Filter = TorrentListFilter.Seeding }); + return torrentList?.Where(x => !string.IsNullOrEmpty(x.Hash)) + .Cast() + .ToList(); + } /// public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) => @@ -284,6 +313,11 @@ public class QBitService : DownloadService, IQBitService public override async Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + if (downloads?.Count is null or 0) { return; @@ -366,6 +400,11 @@ public class QBitService : DownloadService, IQBitService public override async Task CreateCategoryAsync(string name) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + IReadOnlyDictionary? existingCategories = await _client.GetCategoriesAsync(); if (existingCategories.Any(x => x.Value.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase))) @@ -378,6 +417,11 @@ public class QBitService : DownloadService, IQBitService public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + if (downloads?.Count is null or 0) { return; @@ -481,24 +525,44 @@ public class QBitService : DownloadService, IQBitService [DryRunSafeguard] public override async Task DeleteDownload(string hash) { - await _client.DeleteAsync(hash, deleteDownloadedData: true); + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + + await _client.DeleteAsync([hash], deleteFiles: true); } [DryRunSafeguard] protected async Task CreateCategory(string name) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + await _client.AddCategoryAsync(name); } [DryRunSafeguard] protected virtual async Task SkipFile(string hash, int fileIndex) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); } [DryRunSafeguard] protected virtual async Task ChangeCategory(string hash, string newCategory) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + if (_configManager.GetConfiguration("downloadcleaner.json").UnlinkedUseTag) { await _client.AddTorrentTagAsync([hash], newCategory); @@ -510,7 +574,8 @@ public class QBitService : DownloadService, IQBitService public override void Dispose() { - _client.Dispose(); + _client?.Dispose(); + _httpClient?.Dispose(); } private async Task<(bool, DeleteReason)> EvaluateDownloadRemoval(TorrentInfo torrent, bool isPrivate) @@ -609,6 +674,11 @@ public class QBitService : DownloadService, IQBitService private async Task> GetTrackersAsync(string hash) { + if (_client == null) + { + throw new InvalidOperationException("QBittorrent client is not initialized"); + } + return (await _client.GetTorrentTrackersAsync(hash)) .Where(x => x.Url.Contains("**")) .ToList(); diff --git a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs b/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs index 68188b83..0e5a8268 100644 --- a/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/Transmission/TransmissionService.cs @@ -18,6 +18,7 @@ using Infrastructure.Verticals.Notifications; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Infrastructure.Configuration; +using Infrastructure.Http; using Transmission.API.RPC; using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; @@ -26,7 +27,7 @@ namespace Infrastructure.Verticals.DownloadClient.Transmission; public class TransmissionService : DownloadService, ITransmissionService { - private readonly Client _client; + private Client? _client; private static readonly string[] Fields = [ @@ -48,7 +49,6 @@ public class TransmissionService : DownloadService, ITransmissionService ]; public TransmissionService( - IHttpClientFactory httpClientFactory, ILogger logger, IConfigManager configManager, IMemoryCache cache, @@ -56,45 +56,76 @@ public class TransmissionService : DownloadService, ITransmissionService IStriker striker, INotificationPublisher notifier, IDryRunInterceptor dryRunInterceptor, - IHardLinkFileService hardLinkFileService + IHardLinkFileService hardLinkFileService, + IDynamicHttpClientProvider httpClientProvider ) : base( logger, configManager, cache, - filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService + filenameEvaluator, striker, notifier, dryRunInterceptor, hardLinkFileService, + httpClientProvider ) { - var config = configManager.GetConfiguration("transmission.json"); - if (config != null) + // Client will be initialized when Initialize() is called with a specific client configuration + } + + /// + public override void Initialize(ClientConfig clientConfig) + { + // Initialize base service first + base.Initialize(clientConfig); + + // Ensure client type is correct + if (clientConfig.Type != Common.Enums.DownloadClient.Transmission) { - config.Validate(); - UriBuilder uriBuilder = new(config.Url); - uriBuilder.Path = string.IsNullOrEmpty(config.UrlBase) - ? $"{uriBuilder.Path.TrimEnd('/')}/rpc" - : $"{uriBuilder.Path.TrimEnd('/')}/{config.UrlBase.TrimStart('/').TrimEnd('/')}/rpc"; - _client = new( - httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), - uriBuilder.Uri.ToString(), - login: config.Username, - password: config.Password - ); + throw new InvalidOperationException($"Cannot initialize TransmissionService with client type {clientConfig.Type}"); } - else + + if (_httpClient == null) { - _logger.LogWarning("Transmission configuration not found. Using default values."); - _client = new( - httpClientFactory.CreateClient(Constants.HttpClientWithRetryName), - "http://localhost:9091/transmission/rpc" - ); + throw new InvalidOperationException("HTTP client is not initialized"); } + + // Create the RPC path + string rpcPath = string.IsNullOrEmpty(clientConfig.UrlBase) + ? "/rpc" + : $"/{clientConfig.UrlBase.TrimStart('/').TrimEnd('/')}/rpc"; + + // Create full RPC URL + string rpcUrl = new UriBuilder(clientConfig.Url) { Path = rpcPath }.Uri.ToString(); + + // Create Transmission client + _client = new Client(_httpClient, rpcUrl, login: clientConfig.Username, password: clientConfig.Password); + + _logger.LogInformation("Initialized Transmission service for client {clientName} ({clientId})", + clientConfig.Name, clientConfig.Id); } public override async Task LoginAsync() { - await _client.GetSessionInformationAsync(); + if (_client == null) + { + throw new InvalidOperationException("Transmission client is not initialized"); + } + + try + { + await _client.GetSessionInformationAsync(); + _logger.LogDebug("Successfully logged in to Transmission client {clientId}", _clientConfig.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to login to Transmission client {clientId}", _clientConfig.Id); + throw; + } } /// public override async Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) { + if (_client == null) + { + throw new InvalidOperationException("Transmission client is not initialized"); + } + DownloadCheckResult result = new(); TorrentInfo? download = await GetTorrentAsync(hash); @@ -447,6 +478,8 @@ public class TransmissionService : DownloadService, ITransmissionService public override void Dispose() { + _client = null; + _httpClient?.Dispose(); } [DryRunSafeguard] @@ -556,8 +589,15 @@ public class TransmissionService : DownloadService, ITransmissionService return (await _striker.StrikeAndCheckLimit(download.HashString!, download.Name!, maxStrikes, StrikeType.Stalled), DeleteReason.Stalled); } - private async Task GetTorrentAsync(string hash) => - (await _client.TorrentGetAsync(Fields, hash)) - ?.Torrents - ?.FirstOrDefault(); + private async Task GetTorrentAsync(string hash) + { + if (_client == null) + { + throw new InvalidOperationException("Transmission client is not initialized"); + } + + return (await _client.TorrentGetAsync(Fields, hash)) + ?.Torrents + ?.FirstOrDefault(); + } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs index 983fc2aa..80aece6b 100644 --- a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs +++ b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs @@ -11,6 +11,7 @@ using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.Arr.Interfaces; using Infrastructure.Verticals.Context; using Infrastructure.Verticals.DownloadClient; +using Infrastructure.Verticals.DownloadClient.Factory; using Infrastructure.Verticals.DownloadRemover.Models; using Infrastructure.Verticals.Jobs; using Infrastructure.Verticals.Notifications; @@ -28,7 +29,7 @@ public sealed class QueueCleaner : GenericHandler private readonly IMemoryCache _cache; private readonly IConfigManager _configManager; private readonly IIgnoredDownloadsService _ignoredDownloadsService; - private readonly List _downloadServices; + private readonly IDownloadClientFactory _downloadClientFactory; public QueueCleaner( ILogger logger, @@ -39,7 +40,8 @@ public sealed class QueueCleaner : GenericHandler ArrQueueIterator arrArrQueueIterator, DownloadServiceFactory downloadServiceFactory, INotificationPublisher notifier, - IIgnoredDownloadsService ignoredDownloadsService + IIgnoredDownloadsService ignoredDownloadsService, + IDownloadClientFactory downloadClientFactory ) : base( logger, cache, messageBus, arrClientFactory, arrArrQueueIterator, downloadServiceFactory, @@ -49,7 +51,7 @@ public sealed class QueueCleaner : GenericHandler _configManager = configManager; _cache = cache; _ignoredDownloadsService = ignoredDownloadsService; - _downloadServices = new List(); + _downloadClientFactory = downloadClientFactory; // Initialize the configuration var configTask = _configManager.GetQueueCleanerConfigAsync(); @@ -72,30 +74,19 @@ public sealed class QueueCleaner : GenericHandler _radarrConfig = await _configManager.GetRadarrConfigAsync() ?? new RadarrConfig(); _lidarrConfig = await _configManager.GetLidarrConfigAsync() ?? new LidarrConfig(); - // Initialize download services + // Log information about configured download clients if (_downloadClientConfig.Clients.Count > 0) { foreach (var client in _downloadClientConfig.GetEnabledClients()) { - try - { - var service = _downloadServiceFactory.GetDownloadService(client.Id); - if (service != null) - { - _downloadServices.Add(service); - _logger.LogDebug("Added download client: {name} ({id})", client.Name, client.Id); - } - else - { - _logger.LogWarning("Download client service not available for: {id}", client.Id); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error initializing download client {id}: {message}", client.Id, ex.Message); - } + _logger.LogDebug("Found configured download client: {name} ({id}) of type {type}", + client.Name, client.Id, client.Type); } } + else + { + _logger.LogWarning("No download clients configured in downloadclients.json"); + } } protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config) @@ -160,7 +151,7 @@ public sealed class QueueCleaner : GenericHandler } // Check each download client for the download item - foreach (var downloadService in _downloadServices) + foreach (var downloadService in _downloadClientFactory.GetAllEnabledClients()) { try { @@ -169,12 +160,15 @@ public sealed class QueueCleaner : GenericHandler if (result.Found) { downloadCheckResult = result; + // Add client ID to context for tracking + ContextProvider.Set("ClientId", downloadService.GetClientId()); break; } } catch (Exception ex) { - _logger.LogError(ex, "Error checking download {id} with download client", record.DownloadId); + _logger.LogError(ex, "Error checking download {id} with download client {clientId}", + record.DownloadId, downloadService.GetClientId()); } }