diff --git a/code/Common/Configuration/DTOs/Arr/ArrInstanceDto.cs b/code/Common/Configuration/DTOs/Arr/ArrInstanceDto.cs index 89657a84..8b482ac8 100644 --- a/code/Common/Configuration/DTOs/Arr/ArrInstanceDto.cs +++ b/code/Common/Configuration/DTOs/Arr/ArrInstanceDto.cs @@ -8,7 +8,7 @@ public class ArrInstanceDto /// /// Unique identifier for this instance /// - public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? Id { get; set; } = Guid.NewGuid(); /// /// Friendly name for this instance diff --git a/code/Common/Configuration/DTOs/DownloadClient/DownloadClientConfigDto.cs b/code/Common/Configuration/DTOs/DownloadClient/DownloadClientConfigDto.cs index 854c7967..55caca4a 100644 --- a/code/Common/Configuration/DTOs/DownloadClient/DownloadClientConfigDto.cs +++ b/code/Common/Configuration/DTOs/DownloadClient/DownloadClientConfigDto.cs @@ -5,7 +5,7 @@ namespace Common.Configuration.DTOs.DownloadClient; /// /// DTO for retrieving DownloadClient configuration (excludes sensitive data) /// -public class DownloadClientConfigDto +public class DownloadClientConfigDto { /// /// Collection of download clients configured for the application @@ -26,7 +26,7 @@ public class ClientConfigDto /// /// Unique identifier for this client /// - public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? Id { get; set; } = Guid.NewGuid(); /// /// Friendly name for this client @@ -36,12 +36,12 @@ public class ClientConfigDto /// /// Type of download client /// - public DownloadClientType Type { get; set; } = DownloadClientType.None; + public required DownloadClientType Type { get; set; } /// /// Host address for the download client /// - public string Host { get; set; } = string.Empty; + public Uri? Host { get; set; } /// /// Username for authentication (included without password) diff --git a/code/Common/Configuration/DownloadClient/ClientConfig.cs b/code/Common/Configuration/DownloadClient/ClientConfig.cs index 1c7de1c8..c2db665d 100644 --- a/code/Common/Configuration/DownloadClient/ClientConfig.cs +++ b/code/Common/Configuration/DownloadClient/ClientConfig.cs @@ -1,7 +1,6 @@ using Common.Attributes; -using Common.Configuration; using Common.Enums; -using Microsoft.Extensions.Configuration; +using Common.Exceptions; using Newtonsoft.Json; namespace Common.Configuration.DownloadClient; @@ -29,12 +28,12 @@ public sealed record ClientConfig /// /// Type of download client /// - public DownloadClientType Type { get; init; } = DownloadClientType.None; + public required DownloadClientType Type { get; init; } /// /// Host address for the download client /// - public string Host { get; init; } = string.Empty; + public Uri? Host { get; init; } /// /// Username for authentication @@ -57,7 +56,7 @@ public sealed record ClientConfig /// /// The computed full URL for the client /// - public Uri Url => new($"{Host.TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}"); + public Uri Url => new($"{Host?.ToString().TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}"); /// /// Validates the configuration @@ -66,22 +65,17 @@ public sealed record ClientConfig { if (Id == Guid.Empty) { - throw new InvalidOperationException("Client ID cannot be empty"); + throw new ValidationException("Client ID cannot be empty"); } if (string.IsNullOrWhiteSpace(Name)) { - throw new InvalidOperationException($"Client name cannot be empty for client ID: {Id}"); + throw new ValidationException($"Client name cannot be empty for client ID: {Id}"); } - if (string.IsNullOrWhiteSpace(Host)) + if (Host is null && Type is not DownloadClientType.Usenet) { - throw new InvalidOperationException($"Host cannot be empty for client ID: {Id}"); - } - - if (Type == DownloadClientType.None) - { - throw new InvalidOperationException($"Client type must be specified for client ID: {Id}"); + throw new ValidationException($"Host cannot be empty for client ID: {Id}"); } } } diff --git a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs b/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs index 48e84145..cdcc898e 100644 --- a/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs +++ b/code/Common/Configuration/DownloadClient/DownloadClientConfig.cs @@ -1,4 +1,6 @@ -namespace Common.Configuration.DownloadClient; +using Common.Exceptions; + +namespace Common.Configuration.DownloadClient; public sealed record DownloadClientConfig : IConfig { @@ -32,34 +34,21 @@ public sealed record DownloadClientConfig : IConfig public void Validate() { // Validate clients have unique IDs - var duplicateIds = Clients - .GroupBy(c => c.Id) + var duplicateNames = Clients + .GroupBy(c => c.Name) .Where(g => g.Count() > 1) .Select(g => g.Key) .ToList(); - if (duplicateIds.Any()) + if (duplicateNames.Any()) { - throw new InvalidOperationException($"Duplicate client IDs found: {string.Join(", ", duplicateIds)}"); + throw new ValidationException($"Duplicate client names found: {string.Join(", ", duplicateNames)}"); } // Validate each client configuration foreach (var client in Clients) { - if (client.Id == Guid.Empty) - { - 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}"); - } + client.Validate(); } } } \ No newline at end of file diff --git a/code/Common/Enums/DownloadClientType.cs b/code/Common/Enums/DownloadClientType.cs index 7911ff23..fccdc6bf 100644 --- a/code/Common/Enums/DownloadClientType.cs +++ b/code/Common/Enums/DownloadClientType.cs @@ -5,6 +5,5 @@ public enum DownloadClientType QBittorrent, Deluge, Transmission, - None, - Disabled + Usenet, } \ No newline at end of file diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index 6d9248f4..94a6df41 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -107,23 +107,24 @@ public class ConfigurationController : ControllerBase public async Task UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig dto) { // Get existing config - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); - // Apply updates from DTO - dto.Adapt(config); + // Apply updates from DTO, preserving sensitive data if not provided + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save QueueCleaner configuration"); } // Update the scheduler based on configuration changes - await UpdateJobSchedule(config, JobType.QueueCleaner); + await UpdateJobSchedule(oldConfig, JobType.QueueCleaner); return Ok(new { Message = "QueueCleaner configuration updated successfully" }); } @@ -166,16 +167,17 @@ public class ConfigurationController : ControllerBase public async Task UpdateContentBlockerConfig([FromBody] ContentBlockerConfigUpdateDto dto) { // Get existing config - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); - // Apply updates from DTO - dto.Adapt(config); + // Apply updates from DTO, preserving sensitive data if not provided + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save ContentBlocker configuration"); @@ -188,41 +190,43 @@ public class ConfigurationController : ControllerBase public async Task UpdateDownloadCleanerConfig([FromBody] DownloadCleanerConfig dto) { // Get existing config - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); - // Apply updates from DTO - dto.Adapt(config); + // Apply updates from DTO, preserving sensitive data if not provided + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save DownloadCleaner configuration"); } // Update the scheduler based on configuration changes - await UpdateJobSchedule(config, JobType.DownloadCleaner); + await UpdateJobSchedule(oldConfig, JobType.DownloadCleaner); return Ok(new { Message = "DownloadCleaner configuration updated successfully" }); } [HttpPut("download_client")] - public async Task UpdateDownloadClientConfig([FromBody] DownloadClientConfigUpdateDto dto) + public async Task UpdateDownloadClientConfig(DownloadClientConfigUpdateDto dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); - + var oldConfig = await _configManager.GetConfigurationAsync(); + // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save DownloadClient configuration"); @@ -235,24 +239,24 @@ public class ConfigurationController : ControllerBase public async Task UpdateGeneralConfig([FromBody] GeneralConfig dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); - - _loggingConfigManager.SetLogLevel(config.LogLevel); - + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save General configuration"); } + _loggingConfigManager.SetLogLevel(oldConfig.LogLevel); + return Ok(new { Message = "General configuration updated successfully" }); } @@ -260,16 +264,17 @@ public class ConfigurationController : ControllerBase public async Task UpdateSonarrConfig([FromBody] SonarrConfigUpdateDto dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save Sonarr configuration"); @@ -282,16 +287,17 @@ public class ConfigurationController : ControllerBase public async Task UpdateRadarrConfig([FromBody] RadarrConfigUpdateDto dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save Radarr configuration"); @@ -304,16 +310,17 @@ public class ConfigurationController : ControllerBase public async Task UpdateLidarrConfig([FromBody] LidarrConfigUpdateDto dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); // Validate the configuration - config.Validate(); + newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save Lidarr configuration"); @@ -326,13 +333,17 @@ public class ConfigurationController : ControllerBase public async Task UpdateNotificationsConfig([FromBody] NotificationsConfigUpdateDto dto) { // Get existing config to preserve sensitive data - var config = await _configManager.GetConfigurationAsync(); + var oldConfig = await _configManager.GetConfigurationAsync(); // Apply updates from DTO, preserving sensitive data if not provided - dto.Adapt(config); + var newConfig = oldConfig.Adapt(); + newConfig = dto.Adapt(newConfig); + + // Validate the configuration + // newConfig.Validate(); // Persist the configuration - var result = await _configManager.SaveConfigurationAsync(config); + var result = await _configManager.SaveConfigurationAsync(newConfig); if (!result) { return StatusCode(500, "Failed to save Notifications configuration"); diff --git a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs index 99dc7037..43ffbc1f 100644 --- a/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs +++ b/code/Infrastructure.Tests/Http/DynamicHttpClientProviderFixture.cs @@ -38,7 +38,7 @@ public class DynamicHttpClientProviderFixture : IDisposable Name = "QBit Test", Type = DownloadClientType.QBittorrent, Enabled = true, - Host = "http://localhost:8080", + Host = new("http://localhost:8080"), Username = "admin", Password = "adminadmin" }; @@ -52,7 +52,7 @@ public class DynamicHttpClientProviderFixture : IDisposable Name = "Transmission Test", Type = DownloadClientType.Transmission, Enabled = true, - Host = "http://localhost:9091", + Host = new("http://localhost:9091"), Username = "admin", Password = "adminadmin", UrlBase = "transmission" @@ -67,7 +67,7 @@ public class DynamicHttpClientProviderFixture : IDisposable Name = "Deluge Test", Type = DownloadClientType.Deluge, Enabled = true, - Host = "http://localhost:8112", + Host = new("http://localhost:8112"), Username = "admin", Password = "deluge" }; diff --git a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs index 9335ae90..2e9633e3 100644 --- a/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs +++ b/code/Infrastructure/Verticals/DownloadClient/DownloadService.cs @@ -71,9 +71,6 @@ public abstract class DownloadService : IDownloadService _cacheOptions = new MemoryCacheEntryOptions() .SetSlidingExpiration(StaticConfiguration.TriggerValue + Constants.CacheLimitBuffer); - // Initialize with default empty configuration - _clientConfig = new ClientConfig(); - _queueCleanerConfig = _configManager.GetConfiguration(); _downloadCleanerConfig = _configManager.GetConfiguration(); } diff --git a/code/UI/src/app/settings/download-client/download-client-settings.component.html b/code/UI/src/app/settings/download-client/download-client-settings.component.html index d5327108..efa1ba96 100644 --- a/code/UI/src/app/settings/download-client/download-client-settings.component.html +++ b/code/UI/src/app/settings/download-client/download-client-settings.component.html @@ -69,35 +69,43 @@ -
- -
- - Host is required - Host must be a valid URL - Host must use http or https protocol + + +
+ +
+ + Host is required + Host must be a valid URL + Host must use http or https protocol +
-
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
+ +
+ Usenet client type is for categorization only. No connection details needed.
diff --git a/code/UI/src/app/settings/download-client/download-client-settings.component.ts b/code/UI/src/app/settings/download-client/download-client-settings.component.ts index 2069bcee..17081733 100644 --- a/code/UI/src/app/settings/download-client/download-client-settings.component.ts +++ b/code/UI/src/app/settings/download-client/download-client-settings.component.ts @@ -54,7 +54,8 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD clientTypeOptions = [ { label: "QBittorrent", value: DownloadClientType.QBittorrent }, { label: "Deluge", value: DownloadClientType.Deluge }, - { label: "Transmission", value: DownloadClientType.Transmission } + { label: "Transmission", value: DownloadClientType.Transmission }, + { label: "Usenet", value: DownloadClientType.Usenet } ]; // Clean up subscriptions @@ -255,20 +256,35 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD */ addClient(client: ClientConfig | null = null): void { const clientsArray = this.downloadClientForm.get('clients') as FormArray; + const clientType = client?.type ?? DownloadClientType.QBittorrent; + const isUsenet = clientType === DownloadClientType.Usenet; - clientsArray.push( - this.formBuilder.group({ - enabled: [client?.enabled ?? true], - id: [client?.id ?? ''], - name: [client?.name ?? '', Validators.required], - type: [client?.type ?? DownloadClientType.QBittorrent, Validators.required], - host: [client?.host ?? '', [Validators.required, this.uriValidator]], - username: [client?.username ?? ''], - password: [client?.password ?? ''], - urlBase: [client?.urlBase ?? ''] - }) - ); + // Create the client form group with conditional validators based on client type + const clientFormGroup = this.formBuilder.group({ + enabled: [client?.enabled ?? true], + id: [client?.id ?? ''], + name: [client?.name ?? '', Validators.required], + type: [clientType, Validators.required], + host: [client?.host ?? '', isUsenet ? [] : [Validators.required, this.uriValidator]], + username: [client?.username ?? ''], + password: [client?.password ?? ''], + urlBase: [client?.urlBase ?? ''] + }); + // Set up subscription to type changes to update validators + const typeControl = clientFormGroup.get('type'); + if (typeControl) { + typeControl.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(newType => { + // Only update validators if newType is not null + if (newType !== null) { + this.updateValidatorsForClientType(clientFormGroup, newType); + } + }); + } + + clientsArray.push(clientFormGroup); this.downloadClientForm.markAsDirty(); // Recalculate if actual changes exist by comparing with original values this.hasActualChanges = this.formValuesChanged(); @@ -361,4 +377,30 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD return { invalidUri: true }; // Invalid URI } } + + /** + * Checks if a client type is Usenet + */ + isUsenetClient(clientType: DownloadClientType | null | undefined): boolean { + return clientType === DownloadClientType.Usenet; + } + + /** + * Update validators for a client form group based on the client type + */ + private updateValidatorsForClientType(clientFormGroup: FormGroup, clientType: DownloadClientType): void { + const hostControl = clientFormGroup.get('host'); + if (!hostControl) return; + + if (clientType === DownloadClientType.Usenet) { + // For Usenet, remove all validators + hostControl.clearValidators(); + } else { + // For other client types, add required and URI validators + hostControl.setValidators([Validators.required, this.uriValidator]); + } + + // Update validation state + hostControl.updateValueAndValidity(); + } } diff --git a/code/UI/src/app/shared/models/enums.ts b/code/UI/src/app/shared/models/enums.ts index 79296e7b..c347ecbe 100644 --- a/code/UI/src/app/shared/models/enums.ts +++ b/code/UI/src/app/shared/models/enums.ts @@ -5,6 +5,5 @@ export enum DownloadClientType { QBittorrent = 0, Deluge = 1, Transmission = 2, - None = 3, - Disabled = 4 + Usenet = 3, }