From b6950f545f30e28d73ad75e3cd019cf78492b8f0 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Thu, 15 May 2025 19:34:12 +0300 Subject: [PATCH] #6 --- .../DownloadClient/ClientConfig.cs | 69 ++++++++ .../DownloadClient/DownloadClientConfig.cs | 118 ++++++++++++- .../Controllers/ConfigurationController.cs | 7 +- .../Controllers/StatusController.cs | 158 +++++++++++------ .../DependencyInjection/ServicesDI.cs | 3 +- .../Infrastructure/Verticals/Arr/ArrClient.cs | 20 +++ .../Verticals/Arr/Interfaces/IArrClient.cs | 7 + .../ContentBlocker/ContentBlocker.cs | 113 +++++++----- .../DownloadCleaner/DownloadCleaner.cs | 164 +++++++++++++++--- .../DownloadClient/DownloadService.cs | 15 ++ .../DownloadClient/DownloadServiceFactory.cs | 95 ++++++++-- .../DownloadClient/EmptyDownloadService.cs | 85 +++++++++ .../DownloadClient/IDownloadService.cs | 7 + .../Verticals/Jobs/GenericHandler.cs | 73 +++++++- .../Verticals/QueueCleaner/QueueCleaner.cs | 46 ++++- 15 files changed, 827 insertions(+), 153 deletions(-) create mode 100644 code/Common/Configuration/DownloadClient/ClientConfig.cs create mode 100644 code/Infrastructure/Verticals/DownloadClient/EmptyDownloadService.cs diff --git a/code/Common/Configuration/DownloadClient/ClientConfig.cs b/code/Common/Configuration/DownloadClient/ClientConfig.cs new file mode 100644 index 00000000..b045a35e --- /dev/null +++ b/code/Common/Configuration/DownloadClient/ClientConfig.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Configuration; + +namespace Common.Configuration.DownloadClient; + +/// +/// Configuration for a specific download client +/// +public sealed record ClientConfig +{ + /// + /// Unique identifier for this client + /// + [ConfigurationKeyName("ID")] + public string Id { get; init; } = Guid.NewGuid().ToString("N"); + + /// + /// Friendly name for this client + /// + [ConfigurationKeyName("NAME")] + public string Name { get; init; } = string.Empty; + + /// + /// Type of download client + /// + [ConfigurationKeyName("TYPE")] + public Common.Enums.DownloadClient Type { get; init; } = Common.Enums.DownloadClient.None; + + /// + /// Host address for the download client + /// + [ConfigurationKeyName("HOST")] + public string Host { get; init; } = string.Empty; + + /// + /// Port for the download client + /// + [ConfigurationKeyName("PORT")] + public int Port { get; init; } + + /// + /// Username for authentication + /// + [ConfigurationKeyName("USERNAME")] + public string Username { get; init; } = string.Empty; + + /// + /// Password for authentication + /// + [ConfigurationKeyName("PASSWORD")] + public string Password { get; init; } = string.Empty; + + /// + /// Default category to use + /// + [ConfigurationKeyName("CATEGORY")] + public string Category { get; init; } = string.Empty; + + /// + /// Path to download directory + /// + [ConfigurationKeyName("PATH")] + public string Path { get; init; } = string.Empty; + + /// + /// Whether this client is enabled + /// + [ConfigurationKeyName("ENABLED")] + public bool Enabled { get; init; } = true; +} diff --git a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs b/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs index dd7da2e3..c29d1f90 100644 --- a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs +++ b/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs @@ -1,14 +1,124 @@ -using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using Common.Enums; +using Microsoft.Extensions.Configuration; namespace Common.Configuration.DownloadClient; public sealed record DownloadClientConfig : IConfig { - [ConfigurationKeyName("DOWNLOAD_CLIENT")] - public Enums.DownloadClient DownloadClient { get; init; } = Enums.DownloadClient.None; + public const string SectionName = "DownloadClient"; + /// + /// Collection of download clients configured for the application + /// + public List Clients { get; init; } = new(); + + /// + /// Gets a client configuration by id + /// + /// The client id + /// The client configuration or null if not found + public ClientConfig? GetClientConfig(string id) + { + return Clients.FirstOrDefault(c => c.Id == id); + } + + /// + /// Gets all enabled clients + /// + /// Collection of enabled client configurations + public IEnumerable GetEnabledClients() + { + return Clients.Where(c => c.Enabled); + } + + /// + /// Validates the configuration to ensure it meets requirements + /// public void Validate() { - throw new NotImplementedException(); + // Validate clients have unique IDs + var duplicateIds = Clients + .GroupBy(c => c.Id) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateIds.Any()) + { + throw new InvalidOperationException($"Duplicate client IDs found: {string.Join(", ", duplicateIds)}"); + } + + // Validate each client configuration + foreach (var client in Clients) + { + if (string.IsNullOrWhiteSpace(client.Id)) + { + throw new InvalidOperationException("Client ID cannot be empty"); + } + + if (string.IsNullOrWhiteSpace(client.Name)) + { + throw new InvalidOperationException($"Client name cannot be empty for client ID: {client.Id}"); + } + + if (string.IsNullOrWhiteSpace(client.Host)) + { + throw new InvalidOperationException($"Host cannot be empty for client ID: {client.Id}"); + } + + if (client.Port <= 0) + { + throw new InvalidOperationException($"Port must be greater than 0 for client ID: {client.Id}"); + } + } } +} + +/// +/// Represents a download client configuration +/// +public class ClientConfig +{ + /// + /// Unique identifier for the client + /// + public string Id { get; init; } = string.Empty; + + /// + /// Display name of the client + /// + public string Name { get; init; } = string.Empty; + + /// + /// Host address (IP or hostname) + /// + public string Host { get; init; } = string.Empty; + + /// + /// Port number + /// + public int Port { get; init; } + + /// + /// Username for authentication (if required) + /// + public string? Username { get; init; } + + /// + /// Password for authentication (if required) + /// + public string? Password { get; init; } + + /// + /// Type of download client + /// + public DownloadClientType Type { get; init; } = DownloadClientType.QBittorrent; + + /// + /// Whether the client is enabled + /// + public bool Enabled { get; init; } = true; } \ No newline at end of file diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index 03aef25d..57357737 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -178,6 +178,9 @@ public class ConfigurationController : ControllerBase { try { + // Validate the configuration + config.Validate(); + // Persist the configuration var result = await _configService.UpdateDownloadClientConfigAsync(config); if (!result) @@ -190,8 +193,8 @@ public class ConfigurationController : ControllerBase } catch (Exception ex) { - _logger.LogError(ex, "Error updating DownloadClient configuration"); - return StatusCode(500, "An error occurred while updating DownloadClient configuration"); + _logger.LogError(ex, "Error updating DownloadClient configuration: {Message}", ex.Message); + return BadRequest(new { Error = ex.Message }); } } diff --git a/code/Executable/Controllers/StatusController.cs b/code/Executable/Controllers/StatusController.cs index c9c3facb..2187fbdf 100644 --- a/code/Executable/Controllers/StatusController.cs +++ b/code/Executable/Controllers/StatusController.cs @@ -1,9 +1,9 @@ using Common.Configuration.Arr; using Common.Configuration.DownloadClient; +using Infrastructure.Configuration; using Infrastructure.Verticals.Arr; using Infrastructure.Verticals.DownloadClient; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using System.Diagnostics; namespace Executable.Controllers; @@ -13,38 +13,41 @@ namespace Executable.Controllers; public class StatusController : ControllerBase { private readonly ILogger _logger; - private readonly IOptionsMonitor _downloadClientConfig; - private readonly IOptionsMonitor _sonarrConfig; - private readonly IOptionsMonitor _radarrConfig; - private readonly IOptionsMonitor _lidarrConfig; + private readonly IConfigurationManager _configManager; private readonly DownloadServiceFactory _downloadServiceFactory; private readonly ArrClientFactory _arrClientFactory; public StatusController( ILogger logger, - IOptionsMonitor downloadClientConfig, - IOptionsMonitor sonarrConfig, - IOptionsMonitor radarrConfig, - IOptionsMonitor lidarrConfig, + IConfigurationManager configManager, DownloadServiceFactory downloadServiceFactory, ArrClientFactory arrClientFactory) { _logger = logger; - _downloadClientConfig = downloadClientConfig; - _sonarrConfig = sonarrConfig; - _radarrConfig = radarrConfig; - _lidarrConfig = lidarrConfig; + _configManager = configManager; _downloadServiceFactory = downloadServiceFactory; _arrClientFactory = arrClientFactory; } [HttpGet] - public IActionResult GetSystemStatus() + public async Task GetSystemStatus() { try { var process = Process.GetCurrentProcess(); + // Get configuration + var downloadClientConfig = await _configManager.GetDownloadClientConfigAsync(); + var sonarrConfig = await _configManager.GetSonarrConfigAsync(); + var radarrConfig = await _configManager.GetRadarrConfigAsync(); + var lidarrConfig = await _configManager.GetLidarrConfigAsync(); + + // Default if configs are null + downloadClientConfig ??= new DownloadClientConfig(); + sonarrConfig ??= new SonarrConfig(); + radarrConfig ??= new RadarrConfig(); + lidarrConfig ??= new LidarrConfig(); + var status = new { Application = new @@ -57,26 +60,29 @@ public class StatusController : ControllerBase }, DownloadClient = new { - Type = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), - IsConfigured = _downloadClientConfig.CurrentValue.DownloadClient != Common.Enums.DownloadClient.None && - _downloadClientConfig.CurrentValue.DownloadClient != Common.Enums.DownloadClient.Disabled + LegacyType = downloadClientConfig.DownloadClient.ToString(), + ConfiguredClientCount = downloadClientConfig.Clients.Count, + EnabledClientCount = downloadClientConfig.Clients.Count(c => c.Enabled), + IsConfigured = downloadClientConfig.DownloadClient != Common.Enums.DownloadClient.None && + downloadClientConfig.DownloadClient != Common.Enums.DownloadClient.Disabled || + downloadClientConfig.Clients.Any(c => c.Enabled) }, MediaManagers = new { Sonarr = new { - IsEnabled = _sonarrConfig.CurrentValue.Enabled, - InstanceCount = _sonarrConfig.CurrentValue.Instances?.Count ?? 0 + IsEnabled = sonarrConfig.Enabled, + InstanceCount = sonarrConfig.Instances?.Count ?? 0 }, Radarr = new { - IsEnabled = _radarrConfig.CurrentValue.Enabled, - InstanceCount = _radarrConfig.CurrentValue.Instances?.Count ?? 0 + IsEnabled = radarrConfig.Enabled, + InstanceCount = radarrConfig.Instances?.Count ?? 0 }, Lidarr = new { - IsEnabled = _lidarrConfig.CurrentValue.Enabled, - InstanceCount = _lidarrConfig.CurrentValue.Instances?.Count ?? 0 + IsEnabled = lidarrConfig.Enabled, + InstanceCount = lidarrConfig.Instances?.Count ?? 0 } } }; @@ -95,37 +101,70 @@ public class StatusController : ControllerBase { try { - if (_downloadClientConfig.CurrentValue.DownloadClient == Common.Enums.DownloadClient.None || - _downloadClientConfig.CurrentValue.DownloadClient == Common.Enums.DownloadClient.Disabled) + var downloadClientConfig = await _configManager.GetDownloadClientConfigAsync(); + if (downloadClientConfig == null) { - return NotFound("No download client is configured"); + return NotFound("Download client configuration not found"); + } + + var result = new Dictionary(); + + // Check for configured clients + if (downloadClientConfig.Clients.Count > 0) + { + var clientsStatus = new List(); + foreach (var client in downloadClientConfig.Clients) + { + try + { + clientsStatus.Add(new + { + Id = client.Id, + Name = client.Name, + Type = client.Type.ToString(), + Host = client.Host, + Port = client.Port, + Enabled = client.Enabled, + IsConnected = client.Enabled, // We can't check connection status without implementing test methods + Status = client.Enabled ? "Enabled" : "Disabled" + }); + } + catch (Exception ex) + { + clientsStatus.Add(new + { + Id = client.Id, + Name = client.Name, + Type = client.Type.ToString(), + Host = client.Host, + Port = client.Port, + Enabled = client.Enabled, + IsConnected = false, + Status = $"Error: {ex.Message}" + }); + } + } + + result["Clients"] = clientsStatus; + } + else if (downloadClientConfig.DownloadClient != Common.Enums.DownloadClient.None && + downloadClientConfig.DownloadClient != Common.Enums.DownloadClient.Disabled) + { + // Legacy configuration + result["LegacyClient"] = new + { + Type = downloadClientConfig.DownloadClient.ToString(), + Host = downloadClientConfig.Host, + Port = downloadClientConfig.Port, + IsConnected = true // We can't check without implementing test methods in clients + }; + } + else + { + result["Status"] = "No download clients configured"; } - var downloadService = _downloadServiceFactory.CreateDownloadClient(); - - try - { - await downloadService.LoginAsync(); - - // Basic status info that should be safe for any download client - var status = new - { - IsConnected = true, - ClientType = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), - Message = "Successfully connected to download client" - }; - - return Ok(status); - } - catch (Exception ex) - { - return StatusCode(503, new - { - IsConnected = false, - ClientType = _downloadClientConfig.CurrentValue.DownloadClient.ToString(), - Message = $"Failed to connect to download client: {ex.Message}" - }); - } + return Ok(result); } catch (Exception ex) { @@ -140,13 +179,18 @@ public class StatusController : ControllerBase try { var status = new Dictionary(); + + // Get configurations + var sonarrConfig = await _configManager.GetSonarrConfigAsync(); + var radarrConfig = await _configManager.GetRadarrConfigAsync(); + var lidarrConfig = await _configManager.GetLidarrConfigAsync(); // Check Sonarr instances - if (_sonarrConfig.CurrentValue.Enabled && _sonarrConfig.CurrentValue.Instances?.Count > 0) + if (sonarrConfig?.Enabled == true && sonarrConfig.Instances?.Count > 0) { var sonarrStatus = new List(); - foreach (var instance in _sonarrConfig.CurrentValue.Instances) + foreach (var instance in sonarrConfig.Instances) { try { @@ -177,11 +221,11 @@ public class StatusController : ControllerBase } // Check Radarr instances - if (_radarrConfig.CurrentValue.Enabled && _radarrConfig.CurrentValue.Instances?.Count > 0) + if (radarrConfig?.Enabled == true && radarrConfig.Instances?.Count > 0) { var radarrStatus = new List(); - foreach (var instance in _radarrConfig.CurrentValue.Instances) + foreach (var instance in radarrConfig.Instances) { try { @@ -212,11 +256,11 @@ public class StatusController : ControllerBase } // Check Lidarr instances - if (_lidarrConfig.CurrentValue.Enabled && _lidarrConfig.CurrentValue.Instances?.Count > 0) + if (lidarrConfig?.Enabled == true && lidarrConfig.Instances?.Count > 0) { var lidarrStatus = new List(); - foreach (var instance in _lidarrConfig.CurrentValue.Instances) + foreach (var instance in lidarrConfig.Instances) { try { diff --git a/code/Executable/DependencyInjection/ServicesDI.cs b/code/Executable/DependencyInjection/ServicesDI.cs index 48301b4e..30ac9704 100644 --- a/code/Executable/DependencyInjection/ServicesDI.cs +++ b/code/Executable/DependencyInjection/ServicesDI.cs @@ -37,7 +37,8 @@ public static class ServicesDI .AddTransient() .AddTransient() .AddTransient() - .AddTransient() + // Download client services + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/code/Infrastructure/Verticals/Arr/ArrClient.cs b/code/Infrastructure/Verticals/Arr/ArrClient.cs index 421aa90a..77f4cab3 100644 --- a/code/Infrastructure/Verticals/Arr/ArrClient.cs +++ b/code/Infrastructure/Verticals/Arr/ArrClient.cs @@ -170,6 +170,26 @@ public abstract class ArrClient : IArrClient return true; } + /// + /// Tests the connection to an Arr instance + /// + /// The instance to test connection to + /// Task that completes when the connection test is done + public virtual async Task TestConnectionAsync(ArrInstance arrInstance) + { + UriBuilder uriBuilder = new(arrInstance.Url); + uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/api/v3/system/status"; + + using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri); + SetApiKey(request, arrInstance.ApiKey); + + using HttpResponseMessage response = await _httpClient.SendAsync(request); + + response.EnsureSuccessStatusCode(); + + _logger.LogDebug("Connection test successful for {url}", arrInstance.Url); + } + protected abstract string GetQueueUrlPath(); protected abstract string GetQueueUrlQuery(int page); diff --git a/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs b/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs index e2cd654d..d445c54a 100644 --- a/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs +++ b/code/Infrastructure/Verticals/Arr/Interfaces/IArrClient.cs @@ -16,4 +16,11 @@ public interface IArrClient Task SearchItemsAsync(ArrInstance arrInstance, HashSet? items); bool IsRecordValid(QueueRecord record); + + /// + /// Tests the connection to an Arr instance + /// + /// The instance to test connection to + /// Task that completes when the connection test is done + Task TestConnectionAsync(ArrInstance arrInstance); } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs b/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs index df72d081..af07571f 100644 --- a/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs +++ b/code/Infrastructure/Verticals/ContentBlocker/ContentBlocker.cs @@ -29,6 +29,7 @@ public sealed class ContentBlocker : GenericHandler private readonly BlocklistProvider _blocklistProvider; private readonly IIgnoredDownloadsService _ignoredDownloadsService; private readonly IConfigurationManager _configManager; + private readonly IEnumerable _downloadServices; public ContentBlocker( ILogger logger, @@ -38,18 +39,19 @@ public sealed class ContentBlocker : GenericHandler ArrClientFactory arrClientFactory, ArrQueueIterator arrArrQueueIterator, BlocklistProvider blocklistProvider, - DownloadServiceFactory downloadServiceFactory, + IEnumerable downloadServices, INotificationPublisher notifier, IIgnoredDownloadsService ignoredDownloadsService ) : base( logger, cache, messageBus, arrClientFactory, arrArrQueueIterator, - downloadServiceFactory, notifier + downloadServices, notifier ) { _configManager = configManager; _blocklistProvider = blocklistProvider; _ignoredDownloadsService = ignoredDownloadsService; + _downloadServices = downloadServices; // Initialize the configuration var configTask = _configManager.GetContentBlockerConfigAsync(); @@ -115,64 +117,81 @@ public sealed class ContentBlocker : GenericHandler var groups = items .GroupBy(x => x.DownloadId) .ToList(); - + foreach (var group in groups) { + if (group.Any(x => !arrClient.IsRecordValid(x))) + { + continue; + } + QueueRecord record = group.First(); + string hash = record.DownloadId; - if (record.Protocol is not "torrent") + // Skip this record if it is in the ignored list + if (ignoredDownloads.Contains(hash, StringComparer.InvariantCultureIgnoreCase)) { + _logger.LogDebug("skipping {name} | download id is in ignore list", record.Title); continue; } - if (string.IsNullOrEmpty(record.DownloadId)) + _logger.LogTrace("processing | {name}", record.Title); + + // Process through all download clients + bool foundInAnyClient = false; + foreach (var downloadService in _downloadServices) { - _logger.LogDebug("skip | download id is null for {title}", record.Title); - continue; + try + { + var result = await downloadService.BlockUnwantedFilesAsync( + hash, + blocklistType, + patterns, + regexes, + ignoredDownloads); + + if (result.Processed) + { + foundInAnyClient = true; + + // Successfully processed by this client, log results + if (result.HasHardLinks) + { + _logger.LogInformation( + "skipping hard linked files | {title} | {paths}", + record.Title, + string.Join(", ", result.HardLinkedFiles)); + } + + if (result.BlockedFiles.Count > 0) + { + _notifier.BlockedFiles( + record.Title, + instanceType.ToString(), + result.BlockedFiles, + _config.BlockDeleteAfter); + } + + // Break after the first client successfully processes this download + break; + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error blocking unwanted files for {hash} with download client", + hash); + } } - if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase)) + if (!foundInAnyClient) { - _logger.LogInformation("skip | {title} | ignored", record.Title); - continue; + _logger.LogWarning( + "Download {hash} ({title}) not found in any download client", + hash, + record.Title); } - - string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url); - - if (_cache.TryGetValue(downloadRemovalKey, out bool _)) - { - _logger.LogDebug("skip | already marked for removal | {title}", record.Title); - continue; - } - - _logger.LogDebug("searching unwanted files for {title}", record.Title); - - BlockFilesResult result = await _downloadService - .BlockUnwantedFilesAsync(record.DownloadId, blocklistType, patterns, regexes, ignoredDownloads); - - if (!result.ShouldRemove) - { - continue; - } - - _logger.LogDebug("all files are marked as unwanted | {hash}", record.Title); - - bool removeFromClient = true; - - if (result.IsPrivate && !_config.DeletePrivate) - { - removeFromClient = false; - } - - await PublishQueueItemRemoveRequest( - downloadRemovalKey, - instanceType, - instance, - record, - group.Count() > 1, - removeFromClient, - DeleteReason.AllFilesBlocked - ); } }); } diff --git a/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs b/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs index 78aae559..5828f1ac 100644 --- a/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs +++ b/code/Infrastructure/Verticals/DownloadCleaner/DownloadCleaner.cs @@ -23,6 +23,7 @@ public sealed class DownloadCleaner : GenericHandler private readonly IIgnoredDownloadsService _ignoredDownloadsService; private readonly HashSet _excludedHashes = []; private readonly IConfigurationManager _configManager; + private readonly List _downloadServices = new List(); private static bool _hardLinkCategoryCreated; @@ -67,14 +68,52 @@ public sealed class DownloadCleaner : GenericHandler _lidarrConfig = await _configManager.GetLidarrConfigAsync() ?? new LidarrConfig(); } + private void InitializeDownloadServices() + { + // Clear existing services + _downloadServices.Clear(); + + if (_downloadClientConfig.Clients.Count == 0) + { + _logger.LogWarning("No download clients configured"); + return; + } + + foreach (var client in _downloadClientConfig.GetEnabledClients()) + { + try + { + var downloadService = _downloadServiceFactory.GetDownloadService(client.Id); + if (downloadService != null) + { + _downloadServices.Add(downloadService); + _logger.LogDebug("Added download client: {name} ({id})", client.Name, client.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error initializing download client {id}: {message}", client.Id, ex.Message); + } + } + } + public override async Task ExecuteAsync() { // Refresh configurations before executing await InitializeConfigs(); - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None or Common.Enums.DownloadClient.Disabled) + if (_downloadClientConfig.Clients.Count == 0) { - _logger.LogWarning("download client is not set"); + _logger.LogWarning("No download clients configured"); + return; + } + + // Initialize download services + InitializeDownloadServices(); + + if (_downloadServices.Count == 0) + { + _logger.LogWarning("No enabled download clients available"); return; } @@ -89,33 +128,71 @@ public sealed class DownloadCleaner : GenericHandler IReadOnlyList ignoredDownloads = await _ignoredDownloadsService.GetIgnoredDownloadsAsync(); - await _downloadService.LoginAsync(); - List? downloads = await _downloadService.GetSeedingDownloads(); + // Process each client separately + var allDownloads = new List(); + foreach (var downloadService in _downloadServices) + { + try + { + await downloadService.LoginAsync(); + var clientDownloads = await downloadService.GetSeedingDownloads(); + if (clientDownloads?.Count > 0) + { + allDownloads.AddRange(clientDownloads); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get seeding downloads from download client"); + } + } - if (downloads?.Count is null or 0) + if (allDownloads.Count == 0) { _logger.LogDebug("no seeding downloads found"); return; } - _logger.LogTrace("found {count} seeding downloads", downloads.Count); + _logger.LogTrace("found {count} seeding downloads", allDownloads.Count); List? downloadsToChangeCategory = null; if (isUnlinkedEnabled) { - if (!_hardLinkCategoryCreated) + // Create category for all clients + foreach (var downloadService in _downloadServices) { - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.QBittorrent && !_config.UnlinkedUseTag) + try { - _logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory); - await _downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory); + if (_downloadClientConfig.Clients.Any(x => x.Id == Common.Enums.DownloadClient.QBittorrent) && !_config.UnlinkedUseTag) + { + _logger.LogDebug("creating category {cat}", _config.UnlinkedTargetCategory); + await downloadService.CreateCategoryAsync(_config.UnlinkedTargetCategory); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create category for download client"); } - - _hardLinkCategoryCreated = true; } - downloadsToChangeCategory = _downloadService.FilterDownloadsToChangeCategoryAsync(downloads, _config.UnlinkedCategories); + // Get downloads to change category + downloadsToChangeCategory = new List(); + foreach (var downloadService in _downloadServices) + { + try + { + var clientDownloads = downloadService.FilterDownloadsToChangeCategoryAsync(allDownloads, _config.UnlinkedCategories); + if (clientDownloads?.Count > 0) + { + downloadsToChangeCategory.AddRange(clientDownloads); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to filter downloads for category change"); + } + } } // wait for the downloads to appear in the arr queue @@ -125,10 +202,23 @@ public sealed class DownloadCleaner : GenericHandler await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr, true); await ProcessArrConfigAsync(_lidarrConfig, InstanceType.Lidarr, true); - if (isUnlinkedEnabled) + if (isUnlinkedEnabled && downloadsToChangeCategory?.Count > 0) { - _logger.LogTrace("found {count} potential downloads to change category", downloadsToChangeCategory?.Count); - await _downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads); + _logger.LogTrace("found {count} potential downloads to change category", downloadsToChangeCategory.Count); + + // Process each client with its own filtered downloads + foreach (var downloadService in _downloadServices) + { + try + { + await downloadService.ChangeCategoryForNoHardLinksAsync(downloadsToChangeCategory, _excludedHashes, ignoredDownloads); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to change category for downloads"); + } + } + _logger.LogTrace("finished changing category"); } @@ -137,13 +227,42 @@ public sealed class DownloadCleaner : GenericHandler return; } - List? downloadsToClean = _downloadService.FilterDownloadsToBeCleanedAsync(downloads, _config.Categories); + // Get downloads to clean + List downloadsToClean = new List(); + foreach (var downloadService in _downloadServices) + { + try + { + var clientDownloads = downloadService.FilterDownloadsToBeCleanedAsync(allDownloads, _config.Categories); + if (clientDownloads?.Count > 0) + { + downloadsToClean.AddRange(clientDownloads); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to filter downloads for cleaning"); + } + } // release unused objects - downloads = null; + allDownloads = null; - _logger.LogTrace("found {count} potential downloads to clean", downloadsToClean?.Count); - await _downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads); + _logger.LogTrace("found {count} potential downloads to clean", downloadsToClean.Count); + + // Process cleaning for each client + foreach (var downloadService in _downloadServices) + { + try + { + await downloadService.CleanDownloadsAsync(downloadsToClean, _config.Categories, _excludedHashes, ignoredDownloads); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to clean downloads"); + } + } + _logger.LogTrace("finished cleaning downloads"); } @@ -169,6 +288,9 @@ public sealed class DownloadCleaner : GenericHandler public override void Dispose() { - _downloadService.Dispose(); + foreach (var downloadService in _downloadServices) + { + downloadService.Dispose(); + } } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs index a498c812..d3cccb81 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadCleaner; +using Common.Configuration.DownloadClient; using Common.Configuration.QueueCleaner; using Common.CustomDataTypes; using Common.Helpers; @@ -33,6 +34,9 @@ public abstract class DownloadService : IDownloadService protected readonly INotificationPublisher _notifier; protected readonly IDryRunInterceptor _dryRunInterceptor; protected readonly IHardLinkFileService _hardLinkFileService; + + // Client-specific configuration + protected ClientConfig _clientConfig; protected DownloadService( ILogger logger, @@ -59,6 +63,17 @@ public abstract class DownloadService : IDownloadService _hardLinkFileService = hardLinkFileService; _cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); + + // Initialize with default empty configuration + _clientConfig = new ClientConfig(); + } + + /// + public virtual void Initialize(ClientConfig clientConfig) + { + _clientConfig = clientConfig; + _logger.LogDebug("Initialized download service for client {clientId} ({type})", + clientConfig.Id, clientConfig.Type); } public abstract void Dispose(); diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs index 582b3441..93361dc1 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadServiceFactory.cs @@ -1,31 +1,102 @@ using Common.Configuration.DownloadClient; +using Common.Enums; +using Infrastructure.Configuration; using Infrastructure.Verticals.DownloadClient.Deluge; using Infrastructure.Verticals.DownloadClient.QBittorrent; using Infrastructure.Verticals.DownloadClient.Transmission; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; namespace Infrastructure.Verticals.DownloadClient; public sealed class DownloadServiceFactory { private readonly IServiceProvider _serviceProvider; - private readonly Common.Enums.DownloadClient _downloadClient; + private readonly IConfigurationManager _configManager; + private readonly ILogger _logger; - public DownloadServiceFactory(IServiceProvider serviceProvider, IOptions downloadClientConfig) + public DownloadServiceFactory( + IServiceProvider serviceProvider, + IConfigurationManager configManager, + ILogger logger) { _serviceProvider = serviceProvider; - _downloadClient = downloadClientConfig.Value.DownloadClient; + _configManager = configManager; + _logger = logger; } - public IDownloadService CreateDownloadClient() => - _downloadClient switch + /// + /// Creates a download service using the specified client ID + /// + /// The client ID to create a service for + /// An implementation of IDownloadService + public IDownloadService GetDownloadService(string clientId) + { + var config = _configManager.GetDownloadClientConfigAsync().GetAwaiter().GetResult(); + + if (config == null) { - Common.Enums.DownloadClient.QBittorrent => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Deluge => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Transmission => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.None => _serviceProvider.GetRequiredService(), - Common.Enums.DownloadClient.Disabled => _serviceProvider.GetRequiredService(), - _ => throw new ArgumentOutOfRangeException() + _logger.LogWarning("No download client configuration found, using empty service"); + return _serviceProvider.GetRequiredService(); + } + + var clientConfig = config.GetClientConfig(clientId); + + if (clientConfig == null) + { + _logger.LogWarning("No download client configuration found for ID {clientId}, using empty service", clientId); + return _serviceProvider.GetRequiredService(); + } + + if (!clientConfig.Enabled) + { + _logger.LogWarning("Download client {clientId} is disabled, using empty service", clientId); + return _serviceProvider.GetRequiredService(); + } + + return GetDownloadService(clientConfig); + } + + /// + /// Creates a download service using the specified client configuration + /// + /// The client configuration to use + /// An implementation of IDownloadService + public IDownloadService GetDownloadService(ClientConfig clientConfig) + { + if (clientConfig == null) + { + _logger.LogWarning("Client configuration is null, using empty service"); + return _serviceProvider.GetRequiredService(); + } + + if (!clientConfig.Enabled) + { + _logger.LogWarning("Download client {clientId} is disabled, using empty service", clientConfig.Id); + return _serviceProvider.GetRequiredService(); + } + + return clientConfig.Type switch + { + DownloadClientType.QBittorrent => CreateClientService(clientConfig), + DownloadClientType.Deluge => CreateClientService(clientConfig), + DownloadClientType.Transmission => CreateClientService(clientConfig), + DownloadClientType.Usenet => _serviceProvider.GetRequiredService(), + DownloadClientType.Disabled => _serviceProvider.GetRequiredService(), + _ => _serviceProvider.GetRequiredService() }; + } + + /// + /// Creates a download client service for a specific client type + /// + /// The type of download service to create + /// The client configuration + /// An implementation of IDownloadService + private T CreateClientService(ClientConfig clientConfig) where T : IDownloadService + { + var service = _serviceProvider.GetRequiredService(); + service.Initialize(clientConfig); + return service; + } } \ No newline at end of file diff --git a/code/Infrastructure/Verticals/DownloadClient/EmptyDownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/EmptyDownloadService.cs new file mode 100644 index 00000000..d6dee430 --- /dev/null +++ b/code/Infrastructure/Verticals/DownloadClient/EmptyDownloadService.cs @@ -0,0 +1,85 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Common.Configuration.DownloadCleaner; +using Domain.Enums; + +namespace Infrastructure.Verticals.DownloadClient; + +/// +/// Empty implementation of IDownloadService that performs no operations +/// This is used when no download client is configured or available +/// +public sealed class EmptyDownloadService : IDownloadService +{ + private readonly ILogger _logger; + + public EmptyDownloadService(ILogger logger) + { + _logger = logger; + } + + public void Dispose() + { + // Nothing to dispose + } + + public Task LoginAsync() + { + _logger.LogDebug("EmptyDownloadService: Login called (no-op)"); + return Task.CompletedTask; + } + + public Task ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList ignoredDownloads) + { + _logger.LogDebug("EmptyDownloadService: ShouldRemoveFromArrQueueAsync called (no-op)"); + return Task.FromResult(new DownloadCheckResult()); + } + + public Task BlockUnwantedFilesAsync(string hash, BlocklistType blocklistType, ConcurrentBag patterns, ConcurrentBag regexes, IReadOnlyList ignoredDownloads) + { + _logger.LogDebug("EmptyDownloadService: BlockUnwantedFilesAsync called (no-op)"); + return Task.FromResult(new BlockFilesResult()); + } + + public Task DeleteDownload(string hash) + { + _logger.LogDebug("EmptyDownloadService: DeleteDownload called (no-op)"); + return Task.CompletedTask; + } + + public Task?> GetSeedingDownloads() + { + _logger.LogDebug("EmptyDownloadService: GetSeedingDownloads called (no-op)"); + return Task.FromResult?>(new List()); + } + + public List? FilterDownloadsToBeCleanedAsync(List? downloads, List categories) + { + _logger.LogDebug("EmptyDownloadService: FilterDownloadsToBeCleanedAsync called (no-op)"); + return new List(); + } + + public List? FilterDownloadsToChangeCategoryAsync(List? downloads, List categories) + { + _logger.LogDebug("EmptyDownloadService: FilterDownloadsToChangeCategoryAsync called (no-op)"); + return new List(); + } + + public Task CleanDownloadsAsync(List? downloads, List categoriesToClean, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + { + _logger.LogDebug("EmptyDownloadService: CleanDownloadsAsync called (no-op)"); + return Task.CompletedTask; + } + + public Task ChangeCategoryForNoHardLinksAsync(List? downloads, HashSet excludedHashes, IReadOnlyList ignoredDownloads) + { + _logger.LogDebug("EmptyDownloadService: ChangeCategoryForNoHardLinksAsync called (no-op)"); + return Task.CompletedTask; + } + + public Task CreateCategoryAsync(string name) + { + _logger.LogDebug("EmptyDownloadService: CreateCategoryAsync called (no-op)"); + return Task.CompletedTask; + } +} diff --git a/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs index ebdb6a45..f88c1cdc 100644 --- a/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/IDownloadService.cs @@ -2,12 +2,19 @@ using System.Collections.Concurrent; using System.Text.RegularExpressions; using Common.Configuration.ContentBlocker; using Common.Configuration.DownloadCleaner; +using Common.Configuration.DownloadClient; using Infrastructure.Interceptors; namespace Infrastructure.Verticals.DownloadClient; public interface IDownloadService : IDisposable { + /// + /// Initializes the download service with client-specific configuration + /// + /// The client configuration + public void Initialize(ClientConfig clientConfig); + public Task LoginAsync(); /// diff --git a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs b/code/Infrastructure/Verticals/Jobs/GenericHandler.cs index 2c183ca6..145896cd 100644 --- a/code/Infrastructure/Verticals/Jobs/GenericHandler.cs +++ b/code/Infrastructure/Verticals/Jobs/GenericHandler.cs @@ -25,8 +25,11 @@ public abstract class GenericHandler : IHandler, IDisposable protected readonly IBus _messageBus; protected readonly ArrClientFactory _arrClientFactory; protected readonly ArrQueueIterator _arrArrQueueIterator; - protected readonly IDownloadService _downloadService; + protected readonly DownloadServiceFactory _downloadServiceFactory; protected readonly INotificationPublisher _notifier; + + // Collection of download services for use with multiple clients + protected readonly List _downloadServices = new(); protected GenericHandler( ILogger logger, @@ -43,13 +46,62 @@ public abstract class GenericHandler : IHandler, IDisposable _messageBus = messageBus; _arrClientFactory = arrClientFactory; _arrArrQueueIterator = arrArrQueueIterator; - _downloadService = downloadServiceFactory.CreateDownloadClient(); + _downloadServiceFactory = downloadServiceFactory; _notifier = notifier; } + + /// + /// Initialize download services based on configuration + /// + protected virtual void InitializeDownloadServices() + { + // Clear any existing services + DisposeDownloadServices(); + _downloadServices.Clear(); + + // Add all enabled clients + if (_downloadClientConfig.Clients.Count > 0) + { + foreach (var client in _downloadClientConfig.GetEnabledClients()) + { + try + { + _downloadServices.Add(_downloadServiceFactory.GetDownloadService(client.Id)); + _logger.LogDebug("Initialized download client: {name} ({id})", client.Name, client.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to initialize download client: {id}", client.Id); + } + } + } + + if (_downloadServices.Count == 0) + { + _logger.LogWarning("No enabled download clients found"); + } + else + { + _logger.LogInformation("Initialized {count} download clients", _downloadServices.Count); + } + } public virtual async Task ExecuteAsync() { - await _downloadService.LoginAsync(); + // Initialize download services + InitializeDownloadServices(); + + if (_downloadServices.Count == 0) + { + _logger.LogWarning("No download clients available, skipping execution"); + return; + } + + // Login to all download services + foreach (var downloadService in _downloadServices) + { + await downloadService.LoginAsync(); + } await ProcessArrConfigAsync(_sonarrConfig, InstanceType.Sonarr); await ProcessArrConfigAsync(_radarrConfig, InstanceType.Radarr); @@ -58,7 +110,18 @@ public abstract class GenericHandler : IHandler, IDisposable public virtual void Dispose() { - _downloadService.Dispose(); + DisposeDownloadServices(); + } + + /// + /// Dispose all download services + /// + protected void DisposeDownloadServices() + { + foreach (var service in _downloadServices) + { + service.Dispose(); + } } protected abstract Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config); @@ -78,7 +141,7 @@ public abstract class GenericHandler : IHandler, IDisposable } catch (Exception exception) { - _logger.LogError(exception, "failed to clean {type} instance | {url}", instanceType, arrInstance.Url); + _logger.LogError(exception, "failed to process {type} instance | {url}", instanceType, arrInstance.Url); if (throwOnFailure) { diff --git a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs index cbeb56f2..4d68a656 100644 --- a/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs +++ b/code/Infrastructure/Verticals/QueueCleaner/QueueCleaner.cs @@ -18,6 +18,7 @@ using MassTransit; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using LogContext = Serilog.Context.LogContext; +using System.Collections.Generic; namespace Infrastructure.Verticals.QueueCleaner; @@ -27,6 +28,7 @@ public sealed class QueueCleaner : GenericHandler private readonly IMemoryCache _cache; private readonly IConfigurationManager _configManager; private readonly IIgnoredDownloadsService _ignoredDownloadsService; + private readonly List _downloadServices; public QueueCleaner( ILogger logger, @@ -47,6 +49,7 @@ public sealed class QueueCleaner : GenericHandler _configManager = configManager; _cache = cache; _ignoredDownloadsService = ignoredDownloadsService; + _downloadServices = new List(); // Initialize the configuration var configTask = _configManager.GetQueueCleanerConfigAsync(); @@ -68,6 +71,16 @@ public sealed class QueueCleaner : GenericHandler _sonarrConfig = await _configManager.GetSonarrConfigAsync() ?? new SonarrConfig(); _radarrConfig = await _configManager.GetRadarrConfigAsync() ?? new RadarrConfig(); _lidarrConfig = await _configManager.GetLidarrConfigAsync() ?? new LidarrConfig(); + + // Initialize download services + if (_downloadClientConfig.Clients.Count > 0) + { + foreach (var clientConfig in _downloadClientConfig.Clients) + { + var downloadService = _downloadServiceFactory.GetDownloadService(clientConfig); + _downloadServices.Add(downloadService); + } + } } protected override async Task ProcessInstanceAsync(ArrInstance instance, InstanceType instanceType, ArrConfig config) @@ -123,16 +136,41 @@ public sealed class QueueCleaner : GenericHandler DownloadCheckResult downloadCheckResult = new(); - if (record.Protocol is "torrent" && _downloadClientConfig.DownloadClient is not Common.Enums.DownloadClient.Disabled) + if (record.Protocol is "torrent") { - if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None) + if (_downloadClientConfig.DownloadClient is Common.Enums.DownloadClient.None && + _downloadClientConfig.Clients.Count == 0) { _logger.LogWarning("skip | download client is not configured | {title}", record.Title); continue; } - // stalled download check - downloadCheckResult = await _downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads); + // Check each download client for the download item + bool processed = false; + foreach (var downloadService in _downloadServices) + { + try + { + // stalled download check + var result = await downloadService.ShouldRemoveFromArrQueueAsync(record.DownloadId, ignoredDownloads); + if (result.Processed) + { + downloadCheckResult = result; + processed = true; + break; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking download {id} with download client", record.DownloadId); + } + } + + if (!processed) + { + _logger.LogDebug("skip | no download client could process {title}", record.Title); + continue; + } } // failed import check