diff --git a/code/Executable/Controllers/DownloadClientsController.cs b/code/Executable/Controllers/DownloadClientsController.cs
new file mode 100644
index 00000000..fe98abca
--- /dev/null
+++ b/code/Executable/Controllers/DownloadClientsController.cs
@@ -0,0 +1,304 @@
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Configuration;
+using Infrastructure.Verticals.DownloadClient;
+using Infrastructure.Verticals.DownloadClient.Factory;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Executable.Controllers;
+
+///
+/// Controller for managing individual download clients
+///
+[ApiController]
+[Route("api/clients")]
+public class DownloadClientsController : ControllerBase
+{
+ private readonly ILogger _logger;
+ private readonly IConfigManager _configManager;
+ private readonly IDownloadClientFactory _clientFactory;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ public DownloadClientsController(
+ ILogger logger,
+ IConfigManager configManager,
+ IDownloadClientFactory clientFactory)
+ {
+ _logger = logger;
+ _configManager = configManager;
+ _clientFactory = clientFactory;
+ }
+
+ ///
+ /// Gets all download clients
+ ///
+ [HttpGet]
+ public async Task GetAllClients()
+ {
+ try
+ {
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ return Ok(config.Clients);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error retrieving download clients");
+ return StatusCode(500, new { Error = "An error occurred while retrieving download clients" });
+ }
+ }
+
+ ///
+ /// Gets a specific download client by ID
+ ///
+ [HttpGet("{id}")]
+ public async Task GetClient(string id)
+ {
+ try
+ {
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ var client = config.GetClientConfig(id);
+ if (client == null)
+ {
+ return NotFound(new { Message = $"Client with ID '{id}' not found" });
+ }
+
+ return Ok(client);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error retrieving download client {id}", id);
+ return StatusCode(500, new { Error = "An error occurred while retrieving the download client" });
+ }
+ }
+
+ ///
+ /// Adds a new download client
+ ///
+ [HttpPost]
+ public async Task AddClient([FromBody] ClientConfig clientConfig)
+ {
+ try
+ {
+ // Validate the new client configuration
+ clientConfig.Validate();
+
+ // Get the current configuration
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ config = new DownloadClientConfig
+ {
+ Clients = new List()
+ };
+ }
+
+ // Check if a client with the same ID already exists
+ if (config.GetClientConfig(clientConfig.Id) != null)
+ {
+ return BadRequest(new { Error = $"A client with ID '{clientConfig.Id}' already exists" });
+ }
+
+ // Add the new client
+ config.Clients.Add(clientConfig);
+
+ // Persist the updated configuration
+ var result = await _configManager.UpdateDownloadClientConfigAsync(config);
+ if (!result)
+ {
+ return StatusCode(500, new { Error = "Failed to save download client configuration" });
+ }
+
+ // Refresh the client factory to recognize the new client
+ await _clientFactory.RefreshClients();
+
+ _logger.LogInformation("Added new download client: {name} ({id})", clientConfig.Name, clientConfig.Id);
+ return CreatedAtAction(nameof(GetClient), new { id = clientConfig.Id }, clientConfig);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error adding download client");
+ return BadRequest(new { Error = ex.Message });
+ }
+ }
+
+ ///
+ /// Updates an existing download client
+ ///
+ [HttpPut("{id}")]
+ public async Task UpdateClient(string id, [FromBody] ClientConfig clientConfig)
+ {
+ try
+ {
+ // Ensure the ID in the route matches the ID in the body
+ if (id != clientConfig.Id)
+ {
+ return BadRequest(new { Error = "Client ID in the URL does not match the ID in the request body" });
+ }
+
+ // Validate the updated client configuration
+ clientConfig.Validate();
+
+ // Get the current configuration
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ // Find the client to update
+ var existingClientIndex = config.Clients.FindIndex(c => c.Id == id);
+ if (existingClientIndex == -1)
+ {
+ return NotFound(new { Message = $"Client with ID '{id}' not found" });
+ }
+
+ // Update the client
+ config.Clients[existingClientIndex] = clientConfig;
+
+ // Persist the updated configuration
+ var result = await _configManager.UpdateDownloadClientConfigAsync(config);
+ if (!result)
+ {
+ return StatusCode(500, new { Error = "Failed to save download client configuration" });
+ }
+
+ // Refresh the client factory to recognize the updated client
+ await _clientFactory.RefreshClients();
+
+ _logger.LogInformation("Updated download client: {name} ({id})", clientConfig.Name, clientConfig.Id);
+ return Ok(clientConfig);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating download client {id}", id);
+ return BadRequest(new { Error = ex.Message });
+ }
+ }
+
+ ///
+ /// Deletes a download client
+ ///
+ [HttpDelete("{id}")]
+ public async Task DeleteClient(string id)
+ {
+ try
+ {
+ // Get the current configuration
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ // Find the client to delete
+ var existingClientIndex = config.Clients.FindIndex(c => c.Id == id);
+ if (existingClientIndex == -1)
+ {
+ return NotFound(new { Message = $"Client with ID '{id}' not found" });
+ }
+
+ // Remove the client
+ config.Clients.RemoveAt(existingClientIndex);
+
+ // Persist the updated configuration
+ var result = await _configManager.UpdateDownloadClientConfigAsync(config);
+ if (!result)
+ {
+ return StatusCode(500, new { Error = "Failed to save download client configuration" });
+ }
+
+ // Refresh the client factory to recognize the deleted client
+ await _clientFactory.RefreshClients();
+
+ _logger.LogInformation("Deleted download client with ID: {id}", id);
+ return NoContent();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error deleting download client {id}", id);
+ return StatusCode(500, new { Error = "An error occurred while deleting the download client" });
+ }
+ }
+
+ ///
+ /// Tests connection to a download client
+ ///
+ [HttpPost("{id}/test")]
+ public async Task TestConnection(string id)
+ {
+ try
+ {
+ // Get the client configuration
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ var clientConfig = config.GetClientConfig(id);
+ if (clientConfig == null)
+ {
+ return NotFound(new { Message = $"Client with ID '{id}' not found" });
+ }
+
+ // Ensure the client is initialized
+ try
+ {
+ // Get the client instance
+ var client = _clientFactory.GetClient(id);
+
+ // Try to login
+ await client.LoginAsync();
+
+ _logger.LogInformation("Successfully connected to download client: {name} ({id})", clientConfig.Name, id);
+ return Ok(new { Success = true, Message = "Connection successful" });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to connect to download client: {name} ({id})", clientConfig.Name, id);
+ return Ok(new { Success = false, Message = $"Connection failed: {ex.Message}" });
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error testing connection to download client {id}", id);
+ return StatusCode(500, new { Error = "An error occurred while testing the connection" });
+ }
+ }
+
+ ///
+ /// Gets all clients of a specific type
+ ///
+ [HttpGet("type/{type}")]
+ public async Task GetClientsByType(DownloadClient type)
+ {
+ try
+ {
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ return NotFound(new { Message = "No download client configuration found" });
+ }
+
+ var clients = config.Clients.Where(c => c.Type == type).ToList();
+ return Ok(clients);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error retrieving download clients of type {type}", type);
+ return StatusCode(500, new { Error = "An error occurred while retrieving download clients" });
+ }
+ }
+}
diff --git a/code/Executable/Controllers/HealthCheckController.cs b/code/Executable/Controllers/HealthCheckController.cs
new file mode 100644
index 00000000..e883f6e2
--- /dev/null
+++ b/code/Executable/Controllers/HealthCheckController.cs
@@ -0,0 +1,103 @@
+using Infrastructure.Health;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Executable.Controllers;
+
+///
+/// Controller for checking the health of download clients
+///
+[ApiController]
+[Route("api/health")]
+public class HealthCheckController : ControllerBase
+{
+ private readonly ILogger _logger;
+ private readonly IHealthCheckService _healthCheckService;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ public HealthCheckController(
+ ILogger logger,
+ IHealthCheckService healthCheckService)
+ {
+ _logger = logger;
+ _healthCheckService = healthCheckService;
+ }
+
+ ///
+ /// Gets the health status of all download clients
+ ///
+ [HttpGet]
+ public IActionResult GetAllHealth()
+ {
+ try
+ {
+ var healthStatuses = _healthCheckService.GetAllClientHealth();
+ return Ok(healthStatuses);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error retrieving client health statuses");
+ return StatusCode(500, new { Error = "An error occurred while retrieving client health statuses" });
+ }
+ }
+
+ ///
+ /// Gets the health status of a specific download client
+ ///
+ [HttpGet("{id}")]
+ public IActionResult GetClientHealth(string id)
+ {
+ try
+ {
+ var healthStatus = _healthCheckService.GetClientHealth(id);
+ if (healthStatus == null)
+ {
+ return NotFound(new { Message = $"Health status for client with ID '{id}' not found" });
+ }
+
+ return Ok(healthStatus);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error retrieving health status for client {id}", id);
+ return StatusCode(500, new { Error = "An error occurred while retrieving the client health status" });
+ }
+ }
+
+ ///
+ /// Triggers a health check for all download clients
+ ///
+ [HttpPost("check")]
+ public async Task CheckAllHealth()
+ {
+ try
+ {
+ var results = await _healthCheckService.CheckAllClientsHealthAsync();
+ return Ok(results);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking health for all clients");
+ return StatusCode(500, new { Error = "An error occurred while checking client health" });
+ }
+ }
+
+ ///
+ /// Triggers a health check for a specific download client
+ ///
+ [HttpPost("check/{id}")]
+ public async Task CheckClientHealth(string id)
+ {
+ try
+ {
+ var result = await _healthCheckService.CheckClientHealthAsync(id);
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking health for client {id}", id);
+ return StatusCode(500, new { Error = "An error occurred while checking client health" });
+ }
+ }
+}
diff --git a/code/Executable/DependencyInjection/ApiDI.cs b/code/Executable/DependencyInjection/ApiDI.cs
index 821c3b1b..ab447fe0 100644
--- a/code/Executable/DependencyInjection/ApiDI.cs
+++ b/code/Executable/DependencyInjection/ApiDI.cs
@@ -1,3 +1,4 @@
+using Infrastructure.Health;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi.Models;
@@ -11,6 +12,13 @@ public static class ApiDI
// Add API-specific services
services.AddControllers();
services.AddEndpointsApiExplorer();
+
+ // Add SignalR for real-time updates
+ services.AddSignalR();
+
+ // Add health status broadcaster
+ services.AddHostedService();
+
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
@@ -45,6 +53,9 @@ public static class ApiDI
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
+
+ // Map SignalR hubs
+ app.MapHub("/hubs/health");
return app;
}
diff --git a/code/Executable/DependencyInjection/MainDI.cs b/code/Executable/DependencyInjection/MainDI.cs
index ef005a96..1f688429 100644
--- a/code/Executable/DependencyInjection/MainDI.cs
+++ b/code/Executable/DependencyInjection/MainDI.cs
@@ -3,6 +3,7 @@ using Common.Configuration.General;
using Common.Helpers;
using Domain.Models.Arr;
using Infrastructure.Configuration;
+using Infrastructure.Health;
using Infrastructure.Http;
using Infrastructure.Services;
using Infrastructure.Verticals.DownloadClient.Factory;
@@ -29,6 +30,7 @@ public static class MainDI
})
.AddServices()
.AddDownloadClientServices()
+ .AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications(configuration)
.AddMassTransit(config =>
@@ -121,4 +123,15 @@ public static class MainDI
.AddTransient()
.AddTransient()
.AddTransient();
+
+ ///
+ /// Adds health check services to the service collection
+ ///
+ private static IServiceCollection AddHealthServices(this IServiceCollection services) =>
+ services
+ // Register the health check service
+ .AddSingleton()
+
+ // Register the background service for periodic health checks
+ .AddHostedService();
}
\ No newline at end of file
diff --git a/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs b/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs
new file mode 100644
index 00000000..ca6abf59
--- /dev/null
+++ b/code/Infrastructure.Tests/Health/HealthCheckServiceFixture.cs
@@ -0,0 +1,93 @@
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Configuration;
+using Infrastructure.Health;
+using Infrastructure.Verticals.DownloadClient;
+using Infrastructure.Verticals.DownloadClient.Factory;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+
+namespace Infrastructure.Tests.Health;
+
+public class HealthCheckServiceFixture : IDisposable
+{
+ public ILogger Logger { get; }
+ public IConfigManager ConfigManager { get; }
+ public IDownloadClientFactory ClientFactory { get; }
+ public IDownloadService MockClient { get; }
+ public DownloadClientConfig DownloadClientConfig { get; }
+
+ public HealthCheckServiceFixture()
+ {
+ Logger = Substitute.For>();
+ ConfigManager = Substitute.For();
+ ClientFactory = Substitute.For();
+ MockClient = Substitute.For();
+
+ // Set up test download client config
+ DownloadClientConfig = new DownloadClientConfig
+ {
+ Clients = new List
+ {
+ new()
+ {
+ Id = "qbit1",
+ Name = "Test QBittorrent",
+ Type = DownloadClient.QBittorrent,
+ Enabled = true,
+ Url = "http://localhost:8080",
+ Username = "admin",
+ Password = "adminadmin"
+ },
+ new()
+ {
+ Id = "transmission1",
+ Name = "Test Transmission",
+ Type = DownloadClient.Transmission,
+ Enabled = true,
+ Url = "http://localhost:9091",
+ Username = "admin",
+ Password = "adminadmin"
+ },
+ new()
+ {
+ Id = "disabled1",
+ Name = "Disabled Client",
+ Type = DownloadClient.QBittorrent,
+ Enabled = false,
+ Url = "http://localhost:5555"
+ }
+ }
+ };
+
+ // Set up the mock client factory
+ ClientFactory.GetClient(Arg.Any()).Returns(MockClient);
+ MockClient.GetClientId().Returns("qbit1");
+
+ // Set up mock config manager
+ ConfigManager.GetDownloadClientConfigAsync().Returns(DownloadClientConfig);
+ }
+
+ public HealthCheckService CreateSut()
+ {
+ return new HealthCheckService(Logger, ConfigManager, ClientFactory);
+ }
+
+ public void SetupHealthyClient(string clientId)
+ {
+ // Setup a client that will successfully login
+ MockClient.LoginAsync().Returns(Task.CompletedTask);
+ }
+
+ public void SetupUnhealthyClient(string clientId, string errorMessage = "Failed to connect")
+ {
+ // Setup a client that will fail to login
+ MockClient.LoginAsync().Throws(new Exception(errorMessage));
+ }
+
+ public void Dispose()
+ {
+ // Cleanup if needed
+ }
+}
diff --git a/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs b/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs
new file mode 100644
index 00000000..afab762f
--- /dev/null
+++ b/code/Infrastructure.Tests/Health/HealthCheckServiceTests.cs
@@ -0,0 +1,177 @@
+using Infrastructure.Health;
+using NSubstitute;
+using Shouldly;
+
+namespace Infrastructure.Tests.Health;
+
+public class HealthCheckServiceTests : IClassFixture
+{
+ private readonly HealthCheckServiceFixture _fixture;
+
+ public HealthCheckServiceTests(HealthCheckServiceFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task CheckClientHealthAsync_WithHealthyClient_ShouldReturnHealthyStatus()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupHealthyClient("qbit1");
+
+ // Act
+ var result = await sut.CheckClientHealthAsync("qbit1");
+
+ // Assert
+ result.ShouldSatisfyAllConditions(
+ () => result.IsHealthy.ShouldBeTrue(),
+ () => result.ClientId.ShouldBe("qbit1"),
+ () => result.ErrorMessage.ShouldBeNull(),
+ () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
+ );
+ }
+
+ [Fact]
+ public async Task CheckClientHealthAsync_WithUnhealthyClient_ShouldReturnUnhealthyStatus()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupUnhealthyClient("qbit1", "Connection refused");
+
+ // Act
+ var result = await sut.CheckClientHealthAsync("qbit1");
+
+ // Assert
+ result.ShouldSatisfyAllConditions(
+ () => result.IsHealthy.ShouldBeFalse(),
+ () => result.ClientId.ShouldBe("qbit1"),
+ () => result.ErrorMessage.ShouldContain("Connection refused"),
+ () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
+ );
+ }
+
+ [Fact]
+ public async Task CheckClientHealthAsync_WithNonExistentClient_ShouldReturnErrorStatus()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+
+ // Configure the ConfigManager to return null for the client config
+ _fixture.ConfigManager.GetDownloadClientConfigAsync().Returns(
+ Task.FromResult(null)
+ );
+
+ // Act
+ var result = await sut.CheckClientHealthAsync("non-existent");
+
+ // Assert
+ result.ShouldSatisfyAllConditions(
+ () => result.IsHealthy.ShouldBeFalse(),
+ () => result.ClientId.ShouldBe("non-existent"),
+ () => result.ErrorMessage.ShouldContain("not found"),
+ () => result.LastChecked.ShouldBeInRange(DateTime.UtcNow.AddSeconds(-10), DateTime.UtcNow)
+ );
+ }
+
+ [Fact]
+ public async Task CheckAllClientsHealthAsync_ShouldReturnAllEnabledClients()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupHealthyClient("qbit1");
+ _fixture.SetupUnhealthyClient("transmission1");
+
+ // Act
+ var results = await sut.CheckAllClientsHealthAsync();
+
+ // Assert
+ results.Count.ShouldBe(2); // Only enabled clients
+ results.Keys.ShouldContain("qbit1");
+ results.Keys.ShouldContain("transmission1");
+ results["qbit1"].IsHealthy.ShouldBeTrue();
+ results["transmission1"].IsHealthy.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task ClientHealthChanged_ShouldRaiseEventOnHealthStateChange()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupHealthyClient("qbit1");
+
+ ClientHealthChangedEventArgs? capturedArgs = null;
+ sut.ClientHealthChanged += (_, args) => capturedArgs = args;
+
+ // Act - first check establishes initial state
+ var firstResult = await sut.CheckClientHealthAsync("qbit1");
+
+ // Setup client to be unhealthy for second check
+ _fixture.SetupUnhealthyClient("qbit1");
+
+ // Act - second check changes state
+ var secondResult = await sut.CheckClientHealthAsync("qbit1");
+
+ // Assert
+ capturedArgs.ShouldNotBeNull();
+ capturedArgs.ClientId.ShouldBe("qbit1");
+ capturedArgs.Status.IsHealthy.ShouldBeFalse();
+ capturedArgs.IsDegraded.ShouldBeTrue();
+ capturedArgs.IsRecovered.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task GetClientHealth_ShouldReturnCachedStatus()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupHealthyClient("qbit1");
+
+ // Perform a check to cache the status
+ await sut.CheckClientHealthAsync("qbit1");
+
+ // Act
+ var result = sut.GetClientHealth("qbit1");
+
+ // Assert
+ result.ShouldNotBeNull();
+ result.IsHealthy.ShouldBeTrue();
+ result.ClientId.ShouldBe("qbit1");
+ }
+
+ [Fact]
+ public void GetClientHealth_WithNoCheck_ShouldReturnNull()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+
+ // Act
+ var result = sut.GetClientHealth("qbit1");
+
+ // Assert
+ result.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task GetAllClientHealth_ShouldReturnAllCheckedClients()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ _fixture.SetupHealthyClient("qbit1");
+ _fixture.SetupUnhealthyClient("transmission1");
+
+ // Perform checks to cache statuses
+ await sut.CheckClientHealthAsync("qbit1");
+ await sut.CheckClientHealthAsync("transmission1");
+
+ // Act
+ var results = sut.GetAllClientHealth();
+
+ // Assert
+ results.Count.ShouldBe(2);
+ results.Keys.ShouldContain("qbit1");
+ results.Keys.ShouldContain("transmission1");
+ results["qbit1"].IsHealthy.ShouldBeTrue();
+ results["transmission1"].IsHealthy.ShouldBeFalse();
+ }
+}
diff --git a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs
new file mode 100644
index 00000000..c818276a
--- /dev/null
+++ b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs
@@ -0,0 +1,70 @@
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Http;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Infrastructure.Tests.Http;
+
+public class DynamicHttpClientProviderFixture : IDisposable
+{
+ public ILogger Logger { get; }
+
+ public DynamicHttpClientProviderFixture()
+ {
+ Logger = Substitute.For>();
+ }
+
+ public DynamicHttpClientProvider CreateSut()
+ {
+ return new DynamicHttpClientProvider(Logger);
+ }
+
+ public ClientConfig CreateQBitClientConfig()
+ {
+ return new ClientConfig
+ {
+ Id = "qbit-test",
+ Name = "QBit Test",
+ Type = DownloadClient.QBittorrent,
+ Enabled = true,
+ Url = "http://localhost:8080",
+ Username = "admin",
+ Password = "adminadmin"
+ };
+ }
+
+ public ClientConfig CreateTransmissionClientConfig()
+ {
+ return new ClientConfig
+ {
+ Id = "transmission-test",
+ Name = "Transmission Test",
+ Type = DownloadClient.Transmission,
+ Enabled = true,
+ Url = "http://localhost:9091",
+ Username = "admin",
+ Password = "adminadmin",
+ UrlBase = "transmission"
+ };
+ }
+
+ public ClientConfig CreateDelugeClientConfig()
+ {
+ return new ClientConfig
+ {
+ Id = "deluge-test",
+ Name = "Deluge Test",
+ Type = DownloadClient.Deluge,
+ Enabled = true,
+ Url = "http://localhost:8112",
+ Username = "admin",
+ Password = "deluge"
+ };
+ }
+
+ public void Dispose()
+ {
+ // Cleanup if needed
+ }
+}
diff --git a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs
new file mode 100644
index 00000000..e30e28d2
--- /dev/null
+++ b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderTests.cs
@@ -0,0 +1,135 @@
+using System.Net;
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Http;
+using Shouldly;
+
+namespace Infrastructure.Tests.Http;
+
+public class DynamicHttpClientProviderTests : IClassFixture
+{
+ private readonly DynamicHttpClientProviderFixture _fixture;
+
+ public DynamicHttpClientProviderTests(DynamicHttpClientProviderFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public void CreateClient_WithQBitConfig_ShouldReturnConfiguredClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateQBitClientConfig();
+
+ // Act
+ var httpClient = sut.CreateClient(config);
+
+ // Assert
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldBe(new Uri(config.Url));
+ VerifyDefaultHttpClientProperties(httpClient);
+ }
+
+ [Fact]
+ public void CreateClient_WithTransmissionConfig_ShouldReturnConfiguredClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateTransmissionClientConfig();
+
+ // Act
+ var httpClient = sut.CreateClient(config);
+
+ // Assert
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldBe(new Uri(config.Url));
+ VerifyDefaultHttpClientProperties(httpClient);
+ }
+
+ [Fact]
+ public void CreateClient_WithDelugeConfig_ShouldReturnConfiguredClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateDelugeClientConfig();
+
+ // Act
+ var httpClient = sut.CreateClient(config);
+
+ // Assert
+ httpClient.ShouldNotBeNull();
+ httpClient.BaseAddress.ShouldBe(new Uri(config.Url));
+
+ // Deluge client should have additional properties configured
+ VerifyDelugeHttpClientProperties(httpClient);
+ }
+
+ [Fact]
+ public void CreateClient_WithSameConfig_ShouldReturnUniqueInstances()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateQBitClientConfig();
+
+ // Act
+ var firstClient = sut.CreateClient(config);
+ var secondClient = sut.CreateClient(config);
+
+ // Assert
+ firstClient.ShouldNotBeNull();
+ secondClient.ShouldNotBeNull();
+ firstClient.ShouldNotBeSameAs(secondClient); // Should be different instances
+ }
+
+ [Fact]
+ public void CreateClient_WithCustomCertificateValidation_ShouldConfigureHandler()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateQBitClientConfig();
+ config.IgnoreSslErrors = true;
+
+ // Act
+ var httpClient = sut.CreateClient(config);
+
+ // Assert
+ httpClient.ShouldNotBeNull();
+
+ // Since we can't directly access the handler settings after creation,
+ // we verify the behavior is working by checking if the client can be created properly
+ httpClient.BaseAddress.ShouldBe(new Uri(config.Url));
+ }
+
+ [Fact]
+ public void CreateClient_WithTimeout_ShouldConfigureTimeout()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.CreateQBitClientConfig();
+ TimeSpan expectedTimeout = TimeSpan.FromSeconds(30);
+
+ // Act
+ var httpClient = sut.CreateClient(config);
+
+ // Assert
+ httpClient.Timeout.ShouldBe(expectedTimeout);
+ }
+
+ private void VerifyDefaultHttpClientProperties(HttpClient httpClient)
+ {
+ // Check common properties that should be set for all clients
+ httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(30));
+ httpClient.DefaultRequestHeaders.ShouldNotBeNull();
+ }
+
+ private void VerifyDelugeHttpClientProperties(HttpClient httpClient)
+ {
+ // Verify Deluge-specific HTTP client configurations
+ VerifyDefaultHttpClientProperties(httpClient);
+
+ // Using reflection to access the handler is tricky and potentially brittle
+ // Instead, we focus on verifying the client itself is properly configured
+ httpClient.BaseAddress.ShouldNotBeNull();
+ }
+}
diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs
new file mode 100644
index 00000000..dcab94a0
--- /dev/null
+++ b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryFixture.cs
@@ -0,0 +1,109 @@
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Configuration;
+using Infrastructure.Http;
+using Infrastructure.Verticals.DownloadClient;
+using Infrastructure.Verticals.DownloadClient.Deluge;
+using Infrastructure.Verticals.DownloadClient.Factory;
+using Infrastructure.Verticals.DownloadClient.QBittorrent;
+using Infrastructure.Verticals.DownloadClient.Transmission;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Infrastructure.Tests.Verticals.DownloadClient.Factory;
+
+public class DownloadClientFactoryFixture : IDisposable
+{
+ public ILogger Logger { get; }
+ public IConfigManager ConfigManager { get; }
+ public IServiceProvider ServiceProvider { get; }
+ public DownloadClientConfig DownloadClientConfig { get; }
+
+ public DownloadClientFactoryFixture()
+ {
+ Logger = Substitute.For>();
+ ConfigManager = Substitute.For();
+
+ // Set up test download client config
+ DownloadClientConfig = new DownloadClientConfig
+ {
+ Clients = new List
+ {
+ new()
+ {
+ Id = "qbit1",
+ Name = "Test QBittorrent",
+ Type = DownloadClient.QBittorrent,
+ Enabled = true,
+ Url = "http://localhost:8080",
+ Username = "admin",
+ Password = "adminadmin"
+ },
+ new()
+ {
+ Id = "transmission1",
+ Name = "Test Transmission",
+ Type = DownloadClient.Transmission,
+ Enabled = true,
+ Url = "http://localhost:9091",
+ Username = "admin",
+ Password = "adminadmin"
+ },
+ new()
+ {
+ Id = "deluge1",
+ Name = "Test Deluge",
+ Type = DownloadClient.Deluge,
+ Enabled = true,
+ Url = "http://localhost:8112",
+ Username = "admin",
+ Password = "adminadmin"
+ },
+ new()
+ {
+ Id = "disabled1",
+ Name = "Disabled Client",
+ Type = DownloadClient.QBittorrent,
+ Enabled = false,
+ Url = "http://localhost:5555"
+ }
+ }
+ };
+
+ // Configure the ConfigManager to return our test config
+ ConfigManager.GetDownloadClientConfigAsync().Returns(Task.FromResult(DownloadClientConfig));
+
+ // Set up mock services
+ var serviceCollection = new ServiceCollection();
+
+ // Mock the services that will be resolved
+ var qbitService = Substitute.For();
+ var transmissionService = Substitute.For();
+ var delugeService = Substitute.For();
+ var httpClientProvider = Substitute.For();
+
+ // Register our mock services in the service collection
+ serviceCollection.AddSingleton(qbitService);
+ serviceCollection.AddSingleton(transmissionService);
+ serviceCollection.AddSingleton(delugeService);
+ serviceCollection.AddSingleton(httpClientProvider);
+
+ // Build the service provider
+ ServiceProvider = serviceCollection.BuildServiceProvider();
+ }
+
+ public DownloadClientFactory CreateSut()
+ {
+ return new DownloadClientFactory(
+ Logger,
+ ConfigManager,
+ ServiceProvider
+ );
+ }
+
+ public void Dispose()
+ {
+ // Clean up if needed
+ }
+}
diff --git a/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs
new file mode 100644
index 00000000..714a1b88
--- /dev/null
+++ b/code/Infrastructure.Tests/Verticals/DownloadClient/Factory/DownloadClientFactoryTests.cs
@@ -0,0 +1,201 @@
+using System.Collections.Concurrent;
+using Common.Configuration.DownloadClient;
+using Common.Enums;
+using Infrastructure.Verticals.DownloadClient;
+using Infrastructure.Verticals.DownloadClient.Deluge;
+using Infrastructure.Verticals.DownloadClient.Factory;
+using Infrastructure.Verticals.DownloadClient.QBittorrent;
+using Infrastructure.Verticals.DownloadClient.Transmission;
+using NSubstitute;
+using Shouldly;
+
+namespace Infrastructure.Tests.Verticals.DownloadClient.Factory;
+
+public class DownloadClientFactoryTests : IClassFixture
+{
+ private readonly DownloadClientFactoryFixture _fixture;
+
+ public DownloadClientFactoryTests(DownloadClientFactoryFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task Initialize_ShouldCreateClientsForEnabledConfigurations()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+
+ // Act
+ await sut.Initialize();
+
+ // Assert
+ var clients = GetPrivateClientsCollection(sut);
+ clients.Count.ShouldBe(3); // Only enabled clients should be initialized
+ clients.Keys.ShouldContain("qbit1");
+ clients.Keys.ShouldContain("transmission1");
+ clients.Keys.ShouldContain("deluge1");
+ clients.Keys.ShouldNotContain("disabled1");
+ }
+
+ [Fact]
+ public async Task GetClient_WithExistingId_ShouldReturnExistingClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Get an initial reference to the client
+ var firstClient = sut.GetClient("qbit1");
+ firstClient.ShouldNotBeNull();
+
+ // Act
+ var secondClient = sut.GetClient("qbit1");
+
+ // Assert
+ secondClient.ShouldBeSameAs(firstClient); // Should return the same instance
+ }
+
+ [Fact]
+ public async Task GetClient_WithNonExistingId_ShouldCreateNewClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Clear the internal clients collection to simulate a client that hasn't been created yet
+ var clients = GetPrivateClientsCollection(sut);
+ clients.Clear();
+
+ // Act
+ var client = sut.GetClient("qbit1");
+
+ // Assert
+ client.ShouldNotBeNull();
+ client.ShouldBeOfType();
+ clients.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public void GetClient_WithEmptyId_ShouldThrowArgumentException()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+
+ // Act & Assert
+ Should.Throw(() => sut.GetClient(string.Empty));
+ }
+
+ [Fact]
+ public async Task GetClient_WithInvalidId_ShouldThrowKeyNotFoundException()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Act & Assert
+ Should.Throw(() => sut.GetClient("invalid-client-id"));
+ }
+
+ [Fact]
+ public async Task GetAllClients_ShouldReturnAllEnabledClients()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Act
+ var clients = sut.GetAllClients();
+
+ // Assert
+ clients.Count().ShouldBe(3);
+ clients.Select(c => c.GetClientId()).ShouldContain("qbit1");
+ clients.Select(c => c.GetClientId()).ShouldContain("transmission1");
+ clients.Select(c => c.GetClientId()).ShouldContain("deluge1");
+ }
+
+ [Fact]
+ public async Task GetClientByType_ShouldReturnCorrectClientType()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Act
+ var qbitClients = sut.GetClientsByType(DownloadClient.QBittorrent);
+ var transmissionClients = sut.GetClientsByType(DownloadClient.Transmission);
+ var delugeClients = sut.GetClientsByType(DownloadClient.Deluge);
+
+ // Assert
+ qbitClients.Count().ShouldBe(1);
+ qbitClients.First().ShouldBeOfType();
+
+ transmissionClients.Count().ShouldBe(1);
+ transmissionClients.First().ShouldBeOfType();
+
+ delugeClients.Count().ShouldBe(1);
+ delugeClients.First().ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task CreateClient_WithValidConfig_ShouldReturnInitializedClient()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ var config = _fixture.DownloadClientConfig.Clients.First(c => c.Type == DownloadClient.QBittorrent);
+
+ // Act
+ var client = await sut.CreateClient(config.Id);
+
+ // Assert
+ client.ShouldNotBeNull();
+ client.GetClientId().ShouldBe(config.Id);
+ }
+
+ [Fact]
+ public async Task RefreshClients_ShouldReinitializeAllClients()
+ {
+ // Arrange
+ var sut = _fixture.CreateSut();
+ await sut.Initialize();
+
+ // Get initial collection of clients
+ var initialClients = sut.GetAllClients().ToList();
+
+ // Now modify the config to add a new client
+ var updatedConfig = new DownloadClientConfig
+ {
+ Clients = new List(_fixture.DownloadClientConfig.Clients)
+ };
+
+ // Add a new client
+ updatedConfig.Clients.Add(new ClientConfig
+ {
+ Id = "new-client",
+ Name = "New QBittorrent",
+ Type = DownloadClient.QBittorrent,
+ Enabled = true,
+ Url = "http://localhost:9999"
+ });
+
+ // Update the mock ConfigManager to return the updated config
+ _fixture.ConfigManager.GetDownloadClientConfigAsync().Returns(Task.FromResult(updatedConfig));
+
+ // Act
+ await sut.RefreshClients();
+ var refreshedClients = sut.GetAllClients().ToList();
+
+ // Assert
+ refreshedClients.Count.ShouldBe(4); // Should have one more client now
+ refreshedClients.Select(c => c.GetClientId()).ShouldContain("new-client");
+ }
+
+ // Helper method to access the private _clients field using reflection
+ private ConcurrentDictionary GetPrivateClientsCollection(DownloadClientFactory factory)
+ {
+ var field = typeof(DownloadClientFactory).GetField("_clients",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
+
+ return (ConcurrentDictionary)field!.GetValue(factory)!;
+ }
+}
diff --git a/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs b/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs
new file mode 100644
index 00000000..1f944a7b
--- /dev/null
+++ b/code/Infrastructure/Health/ClientHealthChangedEventArgs.cs
@@ -0,0 +1,51 @@
+namespace Infrastructure.Health;
+
+///
+/// Event arguments for client health changes
+///
+public class ClientHealthChangedEventArgs : EventArgs
+{
+ ///
+ /// Gets the client ID
+ ///
+ public string ClientId { get; }
+
+ ///
+ /// Gets the health status
+ ///
+ public HealthStatus Status { get; }
+
+ ///
+ /// Gets a value indicating whether this is a transition to a healthy state
+ ///
+ public bool IsRecovered { get; }
+
+ ///
+ /// Gets a value indicating whether this is a transition to an unhealthy state
+ ///
+ public bool IsDegraded { get; }
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The client ID
+ /// The current health status
+ /// The previous health status, if any
+ public ClientHealthChangedEventArgs(string clientId, HealthStatus status, HealthStatus? previousStatus)
+ {
+ ClientId = clientId;
+ Status = status;
+
+ // Determine if this is a state transition
+ if (previousStatus != null)
+ {
+ IsRecovered = !previousStatus.IsHealthy && status.IsHealthy;
+ IsDegraded = previousStatus.IsHealthy && !status.IsHealthy;
+ }
+ else
+ {
+ IsRecovered = false;
+ IsDegraded = !status.IsHealthy;
+ }
+ }
+}
diff --git a/code/Infrastructure/Health/HealthCheckBackgroundService.cs b/code/Infrastructure/Health/HealthCheckBackgroundService.cs
new file mode 100644
index 00000000..57fd69fc
--- /dev/null
+++ b/code/Infrastructure/Health/HealthCheckBackgroundService.cs
@@ -0,0 +1,89 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Health;
+
+///
+/// Background service that periodically checks the health of all download clients
+///
+public class HealthCheckBackgroundService : BackgroundService
+{
+ private readonly ILogger _logger;
+ private readonly IHealthCheckService _healthCheckService;
+ private readonly TimeSpan _checkInterval;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The logger
+ /// The health check service
+ public HealthCheckBackgroundService(
+ ILogger logger,
+ IHealthCheckService healthCheckService)
+ {
+ _logger = logger;
+ _healthCheckService = healthCheckService;
+
+ // Check health every 1 minute by default
+ _checkInterval = TimeSpan.FromMinutes(1);
+ }
+
+ ///
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ _logger.LogInformation("Health check background service started");
+
+ try
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ _logger.LogDebug("Performing periodic health check for all clients");
+
+ try
+ {
+ // Check health of all clients
+ var results = await _healthCheckService.CheckAllClientsHealthAsync();
+
+ // Log summary
+ var healthyCount = results.Count(r => r.Value.IsHealthy);
+ var unhealthyCount = results.Count - healthyCount;
+
+ _logger.LogInformation(
+ "Health check completed. {healthyCount} healthy, {unhealthyCount} unhealthy clients",
+ healthyCount,
+ unhealthyCount);
+
+ // Log detailed information for unhealthy clients
+ foreach (var result in results.Where(r => !r.Value.IsHealthy))
+ {
+ _logger.LogWarning(
+ "Client {clientId} ({clientName}) is unhealthy: {errorMessage}",
+ result.Key,
+ result.Value.ClientName,
+ result.Value.ErrorMessage);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error performing periodic health check");
+ }
+
+ // Wait for the next check interval
+ await Task.Delay(_checkInterval, stoppingToken);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Normal shutdown, no need to log error
+ _logger.LogInformation("Health check background service stopping");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error in health check background service");
+ }
+ finally
+ {
+ _logger.LogInformation("Health check background service stopped");
+ }
+ }
+}
diff --git a/code/Infrastructure/Health/HealthCheckService.cs b/code/Infrastructure/Health/HealthCheckService.cs
new file mode 100644
index 00000000..18ca7d5e
--- /dev/null
+++ b/code/Infrastructure/Health/HealthCheckService.cs
@@ -0,0 +1,224 @@
+using Common.Configuration.DownloadClient;
+using Infrastructure.Configuration;
+using Infrastructure.Verticals.DownloadClient;
+using Infrastructure.Verticals.DownloadClient.Factory;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Health;
+
+///
+/// Service for checking the health of download clients
+///
+public class HealthCheckService : IHealthCheckService
+{
+ private readonly ILogger _logger;
+ private readonly IConfigManager _configManager;
+ private readonly IDownloadClientFactory _clientFactory;
+ private readonly Dictionary _healthStatuses = new();
+ private readonly object _lockObject = new();
+
+ ///
+ /// Occurs when a client's health status changes
+ ///
+ public event EventHandler? ClientHealthChanged;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The logger
+ /// The configuration manager
+ /// The download client factory
+ public HealthCheckService(
+ ILogger logger,
+ IConfigManager configManager,
+ IDownloadClientFactory clientFactory)
+ {
+ _logger = logger;
+ _configManager = configManager;
+ _clientFactory = clientFactory;
+ }
+
+ ///
+ public async Task CheckClientHealthAsync(string clientId)
+ {
+ _logger.LogDebug("Checking health for client {clientId}", clientId);
+
+ try
+ {
+ // Get the client configuration
+ var config = await GetClientConfigAsync(clientId);
+ if (config == null)
+ {
+ _logger.LogWarning("Client {clientId} not found in configuration", clientId);
+ var notFoundStatus = new HealthStatus
+ {
+ ClientId = clientId,
+ IsHealthy = false,
+ LastChecked = DateTime.UtcNow,
+ ErrorMessage = "Client not found in configuration"
+ };
+
+ UpdateHealthStatus(notFoundStatus);
+ return notFoundStatus;
+ }
+
+ // Get the client instance
+ var client = _clientFactory.GetClient(clientId);
+
+ // Measure response time
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+
+ try
+ {
+ // Execute the login to check connectivity
+ await client.LoginAsync();
+
+ stopwatch.Stop();
+
+ // Create health status object
+ var status = new HealthStatus
+ {
+ ClientId = clientId,
+ ClientName = config.Name,
+ ClientType = config.Type,
+ IsHealthy = true,
+ LastChecked = DateTime.UtcNow,
+ ResponseTime = stopwatch.Elapsed
+ };
+
+ UpdateHealthStatus(status);
+ return status;
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+
+ _logger.LogWarning(ex, "Health check failed for client {clientId}", clientId);
+
+ var status = new HealthStatus
+ {
+ ClientId = clientId,
+ ClientName = config.Name,
+ ClientType = config.Type,
+ IsHealthy = false,
+ LastChecked = DateTime.UtcNow,
+ ErrorMessage = $"Connection failed: {ex.Message}",
+ ResponseTime = stopwatch.Elapsed
+ };
+
+ UpdateHealthStatus(status);
+ return status;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error performing health check for client {clientId}", clientId);
+
+ var status = new HealthStatus
+ {
+ ClientId = clientId,
+ IsHealthy = false,
+ LastChecked = DateTime.UtcNow,
+ ErrorMessage = $"Error: {ex.Message}"
+ };
+
+ UpdateHealthStatus(status);
+ return status;
+ }
+ }
+
+ ///
+ public async Task> CheckAllClientsHealthAsync()
+ {
+ _logger.LogDebug("Checking health for all enabled clients");
+
+ try
+ {
+ // Get all enabled client configurations
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ if (config == null)
+ {
+ _logger.LogWarning("Download client configuration not found");
+ return new Dictionary();
+ }
+
+ var enabledClients = config.GetEnabledClients();
+ var results = new Dictionary();
+
+ // Check health of each enabled client
+ foreach (var clientConfig in enabledClients)
+ {
+ var status = await CheckClientHealthAsync(clientConfig.Id);
+ results[clientConfig.Id] = status;
+ }
+
+ return results;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error checking health for all clients");
+ return new Dictionary();
+ }
+ }
+
+ ///
+ public HealthStatus? GetClientHealth(string clientId)
+ {
+ lock (_lockObject)
+ {
+ return _healthStatuses.TryGetValue(clientId, out var status) ? status : null;
+ }
+ }
+
+ ///
+ public IDictionary GetAllClientHealth()
+ {
+ lock (_lockObject)
+ {
+ return new Dictionary(_healthStatuses);
+ }
+ }
+
+ private async Task GetClientConfigAsync(string clientId)
+ {
+ var config = await _configManager.GetDownloadClientConfigAsync();
+ return config?.GetClientConfig(clientId);
+ }
+
+ private void UpdateHealthStatus(HealthStatus newStatus)
+ {
+ HealthStatus? previousStatus = null;
+
+ lock (_lockObject)
+ {
+ // Get previous status for comparison
+ _healthStatuses.TryGetValue(newStatus.ClientId, out previousStatus);
+
+ // Update status
+ _healthStatuses[newStatus.ClientId] = newStatus;
+ }
+
+ // Determine if there's a significant change
+ bool isStateChange = previousStatus == null ||
+ previousStatus.IsHealthy != newStatus.IsHealthy;
+
+ // Raise event if there's a significant change
+ if (isStateChange)
+ {
+ _logger.LogInformation(
+ "Client {clientId} health changed: {status}",
+ newStatus.ClientId,
+ newStatus.IsHealthy ? "Healthy" : "Unhealthy");
+
+ OnClientHealthChanged(new ClientHealthChangedEventArgs(
+ newStatus.ClientId,
+ newStatus,
+ previousStatus));
+ }
+ }
+
+ private void OnClientHealthChanged(ClientHealthChangedEventArgs e)
+ {
+ ClientHealthChanged?.Invoke(this, e);
+ }
+}
diff --git a/code/Infrastructure/Health/HealthStatus.cs b/code/Infrastructure/Health/HealthStatus.cs
new file mode 100644
index 00000000..ae8f8857
--- /dev/null
+++ b/code/Infrastructure/Health/HealthStatus.cs
@@ -0,0 +1,42 @@
+namespace Infrastructure.Health;
+
+///
+/// Represents the health status of a client
+///
+public class HealthStatus
+{
+ ///
+ /// Gets or sets whether the client is healthy
+ ///
+ public bool IsHealthy { get; set; }
+
+ ///
+ /// Gets or sets the time when the client was last checked
+ ///
+ public DateTime LastChecked { get; set; }
+
+ ///
+ /// Gets or sets the error message if the client is not healthy
+ ///
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// Gets or sets the response time of the last health check
+ ///
+ public TimeSpan ResponseTime { get; set; }
+
+ ///
+ /// Gets or sets the client ID
+ ///
+ public string ClientId { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the client name
+ ///
+ public string ClientName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the client type
+ ///
+ public Common.Enums.DownloadClient ClientType { get; set; }
+}
diff --git a/code/Infrastructure/Health/HealthStatusBroadcaster.cs b/code/Infrastructure/Health/HealthStatusBroadcaster.cs
new file mode 100644
index 00000000..cb5a310e
--- /dev/null
+++ b/code/Infrastructure/Health/HealthStatusBroadcaster.cs
@@ -0,0 +1,82 @@
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Infrastructure.Health;
+
+///
+/// Service that broadcasts health status changes via SignalR
+///
+public class HealthStatusBroadcaster : IHostedService
+{
+ private readonly ILogger _logger;
+ private readonly IHealthCheckService _healthCheckService;
+ private readonly IHubContext _hubContext;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The logger
+ /// The health check service
+ /// The SignalR hub context
+ public HealthStatusBroadcaster(
+ ILogger logger,
+ IHealthCheckService healthCheckService,
+ IHubContext hubContext)
+ {
+ _logger = logger;
+ _healthCheckService = healthCheckService;
+ _hubContext = hubContext;
+ }
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Health status broadcaster starting");
+
+ // Subscribe to health status change events
+ _healthCheckService.ClientHealthChanged += OnClientHealthChanged;
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Health status broadcaster stopping");
+
+ // Unsubscribe from health status change events
+ _healthCheckService.ClientHealthChanged -= OnClientHealthChanged;
+
+ return Task.CompletedTask;
+ }
+
+ private async void OnClientHealthChanged(object? sender, ClientHealthChangedEventArgs e)
+ {
+ try
+ {
+ _logger.LogDebug("Broadcasting health status change for client {clientId}", e.ClientId);
+
+ // Broadcast to all clients
+ await _hubContext.Clients.All.SendAsync("HealthStatusChanged", e.Status);
+
+ // Send degradation messages
+ if (e.IsDegraded)
+ {
+ _logger.LogWarning("Client {clientId} health degraded", e.ClientId);
+ await _hubContext.Clients.All.SendAsync("ClientDegraded", e.Status);
+ }
+
+ // Send recovery messages
+ if (e.IsRecovered)
+ {
+ _logger.LogInformation("Client {clientId} health recovered", e.ClientId);
+ await _hubContext.Clients.All.SendAsync("ClientRecovered", e.Status);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error broadcasting health status change for client {clientId}", e.ClientId);
+ }
+ }
+}
diff --git a/code/Infrastructure/Health/HealthStatusHub.cs b/code/Infrastructure/Health/HealthStatusHub.cs
new file mode 100644
index 00000000..752e5be0
--- /dev/null
+++ b/code/Infrastructure/Health/HealthStatusHub.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace Infrastructure.Health;
+
+///
+/// SignalR hub for broadcasting health status updates
+///
+public class HealthStatusHub : Hub
+{
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class
+ ///
+ /// The logger
+ public HealthStatusHub(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ /// Called when a client connects to the hub
+ ///
+ public override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("Client connected: {connectionId}", Context.ConnectionId);
+ await base.OnConnectedAsync();
+ }
+
+ ///
+ /// Called when a client disconnects from the hub
+ ///
+ public override async Task OnDisconnectedAsync(Exception? exception)
+ {
+ _logger.LogInformation("Client disconnected: {connectionId}", Context.ConnectionId);
+ await base.OnDisconnectedAsync(exception);
+ }
+}
diff --git a/code/Infrastructure/Health/IHealthCheckService.cs b/code/Infrastructure/Health/IHealthCheckService.cs
new file mode 100644
index 00000000..1b334385
--- /dev/null
+++ b/code/Infrastructure/Health/IHealthCheckService.cs
@@ -0,0 +1,38 @@
+namespace Infrastructure.Health;
+
+///
+/// Service for checking the health of download clients
+///
+public interface IHealthCheckService
+{
+ ///
+ /// Occurs when a client's health status changes
+ ///
+ event EventHandler ClientHealthChanged;
+
+ ///
+ /// Checks the health of a specific client
+ ///
+ /// The client ID to check
+ /// The health status of the client
+ Task CheckClientHealthAsync(string clientId);
+
+ ///
+ /// Checks the health of all enabled clients
+ ///
+ /// A dictionary of client IDs to health statuses
+ Task> CheckAllClientsHealthAsync();
+
+ ///
+ /// Gets the current health status of a client
+ ///
+ /// The client ID
+ /// The current health status, or null if the client hasn't been checked
+ HealthStatus? GetClientHealth(string clientId);
+
+ ///
+ /// Gets the current health status of all clients that have been checked
+ ///
+ /// A dictionary of client IDs to health statuses
+ IDictionary GetAllClientHealth();
+}