try fix config update

This commit is contained in:
Flaminel
2025-06-13 15:46:00 +03:00
parent 26bfa5adb2
commit 6a0641ef63
11 changed files with 172 additions and 133 deletions

View File

@@ -8,7 +8,7 @@ public class ArrInstanceDto
/// <summary>
/// Unique identifier for this instance
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Friendly name for this instance

View File

@@ -5,7 +5,7 @@ namespace Common.Configuration.DTOs.DownloadClient;
/// <summary>
/// DTO for retrieving DownloadClient configuration (excludes sensitive data)
/// </summary>
public class DownloadClientConfigDto
public class DownloadClientConfigDto
{
/// <summary>
/// Collection of download clients configured for the application
@@ -26,7 +26,7 @@ public class ClientConfigDto
/// <summary>
/// Unique identifier for this client
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Friendly name for this client
@@ -36,12 +36,12 @@ public class ClientConfigDto
/// <summary>
/// Type of download client
/// </summary>
public DownloadClientType Type { get; set; } = DownloadClientType.None;
public required DownloadClientType Type { get; set; }
/// <summary>
/// Host address for the download client
/// </summary>
public string Host { get; set; } = string.Empty;
public Uri? Host { get; set; }
/// <summary>
/// Username for authentication (included without password)

View File

@@ -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
/// <summary>
/// Type of download client
/// </summary>
public DownloadClientType Type { get; init; } = DownloadClientType.None;
public required DownloadClientType Type { get; init; }
/// <summary>
/// Host address for the download client
/// </summary>
public string Host { get; init; } = string.Empty;
public Uri? Host { get; init; }
/// <summary>
/// Username for authentication
@@ -57,7 +56,7 @@ public sealed record ClientConfig
/// <summary>
/// The computed full URL for the client
/// </summary>
public Uri Url => new($"{Host.TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}");
public Uri Url => new($"{Host?.ToString().TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}");
/// <summary>
/// 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}");
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -5,6 +5,5 @@ public enum DownloadClientType
QBittorrent,
Deluge,
Transmission,
None,
Disabled
Usenet,
}

View File

@@ -107,23 +107,24 @@ public class ConfigurationController : ControllerBase
public async Task<IActionResult> UpdateQueueCleanerConfig([FromBody] QueueCleanerConfig dto)
{
// Get existing config
var config = await _configManager.GetConfigurationAsync<QueueCleanerConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<QueueCleanerConfig>();
// Apply updates from DTO
dto.Adapt(config);
// Apply updates from DTO, preserving sensitive data if not provided
var newConfig = oldConfig.Adapt<QueueCleanerConfig>();
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<IActionResult> UpdateContentBlockerConfig([FromBody] ContentBlockerConfigUpdateDto dto)
{
// Get existing config
var config = await _configManager.GetConfigurationAsync<ContentBlockerConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<ContentBlockerConfig>();
// Apply updates from DTO
dto.Adapt(config);
// Apply updates from DTO, preserving sensitive data if not provided
var newConfig = oldConfig.Adapt<ContentBlockerConfig>();
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<IActionResult> UpdateDownloadCleanerConfig([FromBody] DownloadCleanerConfig dto)
{
// Get existing config
var config = await _configManager.GetConfigurationAsync<DownloadCleanerConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<DownloadCleanerConfig>();
// Apply updates from DTO
dto.Adapt(config);
// Apply updates from DTO, preserving sensitive data if not provided
var newConfig = oldConfig.Adapt<DownloadCleanerConfig>();
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<IActionResult> UpdateDownloadClientConfig([FromBody] DownloadClientConfigUpdateDto dto)
public async Task<IActionResult> UpdateDownloadClientConfig(DownloadClientConfigUpdateDto dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<DownloadClientConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<DownloadClientConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<DownloadClientConfig>();
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<IActionResult> UpdateGeneralConfig([FromBody] GeneralConfig dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<GeneralConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<GeneralConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<GeneralConfig>();
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<IActionResult> UpdateSonarrConfig([FromBody] SonarrConfigUpdateDto dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<SonarrConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<SonarrConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<SonarrConfig>();
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<IActionResult> UpdateRadarrConfig([FromBody] RadarrConfigUpdateDto dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<RadarrConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<RadarrConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<RadarrConfig>();
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<IActionResult> UpdateLidarrConfig([FromBody] LidarrConfigUpdateDto dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<LidarrConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<LidarrConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<LidarrConfig>();
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<IActionResult> UpdateNotificationsConfig([FromBody] NotificationsConfigUpdateDto dto)
{
// Get existing config to preserve sensitive data
var config = await _configManager.GetConfigurationAsync<NotificationsConfig>();
var oldConfig = await _configManager.GetConfigurationAsync<NotificationsConfig>();
// Apply updates from DTO, preserving sensitive data if not provided
dto.Adapt(config);
var newConfig = oldConfig.Adapt<NotificationsConfig>();
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");

View File

@@ -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"
};

View File

@@ -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<QueueCleanerConfig>();
_downloadCleanerConfig = _configManager.GetConfiguration<DownloadCleanerConfig>();
}

View File

@@ -69,35 +69,43 @@
</div>
</div>
<div class="instance-field">
<label>Host</label>
<div class="field-input">
<input type="text" pInputText formControlName="host" placeholder="http://localhost:8080" required />
<small *ngIf="hasClientFieldError(i, 'host', 'required')" class="p-error block">Host is required</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidUri')" class="p-error block">Host must be a valid URL</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidProtocol')" class="p-error block">Host must use http or https protocol</small>
<!-- Connection fields - only shown when client type is not Usenet -->
<ng-container *ngIf="!isUsenetClient(client.get('type')?.value)">
<div class="instance-field">
<label>Host</label>
<div class="field-input">
<input type="text" pInputText formControlName="host" placeholder="http://localhost:8080" required />
<small *ngIf="hasClientFieldError(i, 'host', 'required')" class="p-error block">Host is required</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidUri')" class="p-error block">Host must be a valid URL</small>
<small *ngIf="hasClientFieldError(i, 'host', 'invalidProtocol')" class="p-error block">Host must use http or https protocol</small>
</div>
</div>
</div>
<div class="instance-field">
<label>URL Base</label>
<div class="field-input">
<input type="text" pInputText formControlName="urlBase" placeholder="(Optional) Path prefix" />
</div>
</div>
<div class="instance-field">
<label>Username</label>
<div class="field-input">
<input type="text" pInputText formControlName="username" placeholder="Username" />
</div>
</div>
<div class="instance-field">
<label>Password</label>
<div class="field-input">
<input type="password" pInputText formControlName="password" placeholder="Password" />
</div>
</div>
</ng-container>
<div class="instance-field">
<label>URL Base</label>
<div class="field-input">
<input type="text" pInputText formControlName="urlBase" placeholder="(Optional) Path prefix" />
</div>
</div>
<div class="instance-field">
<label>Username</label>
<div class="field-input">
<input type="text" pInputText formControlName="username" placeholder="Username" />
</div>
</div>
<div class="instance-field">
<label>Password</label>
<div class="field-input">
<input type="password" pInputText formControlName="password" placeholder="Password" />
</div>
<!-- Message shown when Usenet is selected -->
<div *ngIf="isUsenetClient(client.get('type')?.value)" class="p-3 mt-2 surface-overlay border-1 border-round-sm">
<small class="text-secondary">Usenet client type is for categorization only. No connection details needed.</small>
</div>
</div>
</div>

View File

@@ -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();
}
}

View File

@@ -5,6 +5,5 @@ export enum DownloadClientType {
QBittorrent = 0,
Deluge = 1,
Transmission = 2,
None = 3,
Disabled = 4
Usenet = 3,
}