using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Health; /// /// Service for checking the health of download clients and arr instances /// public class HealthCheckService : IHealthCheckService { private readonly ILogger _logger; private readonly Dictionary _healthStatuses = new(); private readonly Dictionary _arrHealthStatuses = new(); private readonly IServiceScopeFactory _scopeFactory; private readonly object _lockObject = new(); /// /// Occurs when a client's health status changes /// public event EventHandler? ClientHealthChanged; public HealthCheckService( ILogger logger, IServiceScopeFactory scopeFactory ) { _logger = logger; _scopeFactory = scopeFactory; } /// public async Task CheckClientHealthAsync(Guid clientId) { _logger.LogDebug("Checking health for client {clientId}", clientId); try { await using var scope = _scopeFactory.CreateAsyncScope(); await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get the client configuration var downloadClientConfig = await dataContext.DownloadClients .Where(x => x.Id == clientId) .FirstOrDefaultAsync(); if (downloadClientConfig is 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 downloadServiceFactory = scope.ServiceProvider.GetRequiredService(); var client = downloadServiceFactory.GetDownloadService(downloadClientConfig); // Execute the health check var healthResult = await client.HealthCheckAsync(); // Create health status object var status = new HealthStatus { ClientId = clientId, ClientName = downloadClientConfig.Name, ClientTypeName = downloadClientConfig.TypeName, IsHealthy = healthResult.IsHealthy, LastChecked = DateTime.UtcNow, ErrorMessage = healthResult.ErrorMessage, ResponseTime = healthResult.ResponseTime }; 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 { await using var scope = _scopeFactory.CreateAsyncScope(); await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get all enabled client configurations var enabledClients = await dataContext.DownloadClients .Where(x => x.Enabled) .ToListAsync(); 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(Guid clientId) { lock (_lockObject) { return _healthStatuses.TryGetValue(clientId, out var status) ? status : null; } } /// public IDictionary GetAllClientHealth() { lock (_lockObject) { return new Dictionary(_healthStatuses); } } /// public async Task CheckArrInstanceHealthAsync(Guid instanceId) { _logger.LogDebug("Checking health for arr instance {instanceId}", instanceId); try { await using var scope = _scopeFactory.CreateAsyncScope(); await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get the arr instance with its config (needed for InstanceType) // Load config with instances first, then find in memory (SQLite doesn't support APPLY) var config = await dataContext.ArrConfigs .Include(x => x.Instances) .FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId)); var arrInstance = config is null ? null : new { Instance = config.Instances.First(i => i.Id == instanceId), Config = config }; if (arrInstance is null) { _logger.LogWarning("Arr instance {instanceId} not found in configuration", instanceId); var notFoundStatus = new ArrHealthStatus { InstanceId = instanceId, IsHealthy = false, LastChecked = DateTime.UtcNow, ErrorMessage = "Arr instance not found in configuration" }; UpdateArrHealthStatus(notFoundStatus); return notFoundStatus; } // Get the arr client and execute health check var arrClientFactory = scope.ServiceProvider.GetRequiredService(); var client = arrClientFactory.GetClient(arrInstance.Config.Type, arrInstance.Instance.Version); await client.HealthCheckAsync(arrInstance.Instance); var status = new ArrHealthStatus { InstanceId = instanceId, InstanceName = arrInstance.Instance.Name, InstanceType = arrInstance.Config.Type, IsHealthy = true, LastChecked = DateTime.UtcNow }; UpdateArrHealthStatus(status); return status; } catch (Exception ex) { _logger.LogError(ex, "Error performing health check for arr instance {instanceId}", instanceId); var status = new ArrHealthStatus { InstanceId = instanceId, IsHealthy = false, LastChecked = DateTime.UtcNow, ErrorMessage = $"Error: {ex.Message}" }; UpdateArrHealthStatus(status); return status; } } /// public async Task> CheckAllArrInstancesHealthAsync() { _logger.LogDebug("Checking health for all enabled arr instances"); try { await using var scope = _scopeFactory.CreateAsyncScope(); await using var dataContext = scope.ServiceProvider.GetRequiredService(); // Get all enabled arr instances across all configs // Load configs with instances first, then flatten in memory (SQLite doesn't support APPLY) var configs = await dataContext.ArrConfigs .Include(x => x.Instances) .ToListAsync(); var enabledInstances = configs .SelectMany(c => c.Instances .Where(i => i.Enabled) .Select(i => new { Instance = i, Config = c })) .ToList(); var results = new Dictionary(); var arrClientFactory = scope.ServiceProvider.GetRequiredService(); foreach (var entry in enabledInstances) { try { var client = arrClientFactory.GetClient(entry.Config.Type, entry.Instance.Version); await client.HealthCheckAsync(entry.Instance); var status = new ArrHealthStatus { InstanceId = entry.Instance.Id, InstanceName = entry.Instance.Name, InstanceType = entry.Config.Type, IsHealthy = true, LastChecked = DateTime.UtcNow }; UpdateArrHealthStatus(status); results[entry.Instance.Id] = status; } catch (Exception ex) { _logger.LogError(ex, "Error performing health check for arr instance {instanceId} ({instanceName})", entry.Instance.Id, entry.Instance.Name); var status = new ArrHealthStatus { InstanceId = entry.Instance.Id, InstanceName = entry.Instance.Name, InstanceType = entry.Config.Type, IsHealthy = false, LastChecked = DateTime.UtcNow, ErrorMessage = $"Error: {ex.Message}" }; UpdateArrHealthStatus(status); results[entry.Instance.Id] = status; } } return results; } catch (Exception ex) { _logger.LogError(ex, "Error checking health for all arr instances"); return new Dictionary(); } } /// public ArrHealthStatus? GetArrInstanceHealth(Guid instanceId) { lock (_lockObject) { return _arrHealthStatuses.TryGetValue(instanceId, out var status) ? status : null; } } /// public IDictionary GetAllArrInstanceHealth() { lock (_lockObject) { return new Dictionary(_arrHealthStatuses); } } private void UpdateArrHealthStatus(ArrHealthStatus newStatus) { ArrHealthStatus? previousStatus; lock (_lockObject) { _arrHealthStatuses.TryGetValue(newStatus.InstanceId, out previousStatus); _arrHealthStatuses[newStatus.InstanceId] = newStatus; } bool isStateChange = previousStatus == null || previousStatus.IsHealthy != newStatus.IsHealthy; if (isStateChange) { _logger.LogInformation( "Arr instance {instanceId} ({instanceName}) health changed: {status}", newStatus.InstanceId, newStatus.InstanceName, newStatus.IsHealthy ? "Healthy" : "Unhealthy"); } } private void UpdateHealthStatus(HealthStatus newStatus) { HealthStatus? previousStatus; 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); } }