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