diff --git a/code/Common/Configuration/DownloadClientConfig.cs b/code/Common/Configuration/DownloadClientConfig.cs index 033f3b29..e49498b7 100644 --- a/code/Common/Configuration/DownloadClientConfig.cs +++ b/code/Common/Configuration/DownloadClientConfig.cs @@ -1,8 +1,8 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Common.Attributes; using Common.Enums; using Common.Exceptions; -using Newtonsoft.Json; namespace Common.Configuration; @@ -64,6 +64,7 @@ public sealed record DownloadClientConfig /// The computed full URL for the client /// [NotMapped] + [JsonIgnore] public Uri Url => new($"{Host?.ToString().TrimEnd('/')}/{UrlBase.TrimStart('/').TrimEnd('/')}"); /// diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index f0838e23..1f10b1c7 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -12,6 +12,7 @@ using Mapster; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using System; +using Executable.DTOs; namespace Executable.Controllers; @@ -92,24 +93,32 @@ public class ConfigurationController : ControllerBase } [HttpPost("download_client")] - public async Task CreateDownloadClientConfig([FromBody] DownloadClientConfig newClient) + public async Task CreateDownloadClientConfig([FromBody] CreateDownloadClientDto newClient) { - if (newClient == null) - { - return BadRequest("Invalid download client data"); - } - await DataContext.Lock.WaitAsync(); try { // Validate the configuration newClient.Validate(); + // Create the full config from the DTO + var clientConfig = new DownloadClientConfig + { + Enabled = newClient.Enabled, + Name = newClient.Name, + TypeName = newClient.TypeName, + Type = newClient.Type, + Host = newClient.Host, + Username = newClient.Username, + Password = newClient.Password, + UrlBase = newClient.UrlBase + }; + // Add the new client to the database - _dataContext.DownloadClients.Add(newClient); + _dataContext.DownloadClients.Add(clientConfig); await _dataContext.SaveChangesAsync(); - return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = newClient.Id }, newClient); + return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = clientConfig.Id }, clientConfig); } catch (Exception ex) { diff --git a/code/Executable/DTOs/CreateDownloadClientDto.cs b/code/Executable/DTOs/CreateDownloadClientDto.cs new file mode 100644 index 00000000..f5c97e73 --- /dev/null +++ b/code/Executable/DTOs/CreateDownloadClientDto.cs @@ -0,0 +1,66 @@ +using Common.Enums; +using Common.Exceptions; + +namespace Executable.DTOs; + +/// +/// DTO for creating a new download client (without ID) +/// +public sealed record CreateDownloadClientDto +{ + /// + /// Whether this client is enabled + /// + public bool Enabled { get; init; } = false; + + /// + /// Friendly name for this client + /// + public required string Name { get; init; } + + /// + /// Type name of download client + /// + public required DownloadClientTypeName TypeName { get; init; } + + /// + /// Type of download client + /// + public required DownloadClientType Type { get; init; } + + /// + /// Host address for the download client + /// + public Uri? Host { get; init; } + + /// + /// Username for authentication + /// + public string? Username { get; init; } + + /// + /// Password for authentication + /// + public string? Password { get; init; } + + /// + /// The base URL path component, used by clients like Transmission and Deluge + /// + public string? UrlBase { get; init; } + + /// + /// Validates the configuration + /// + public void Validate() + { + if (string.IsNullOrWhiteSpace(Name)) + { + throw new ValidationException("Client name cannot be empty"); + } + + if (Host is null && TypeName is not DownloadClientTypeName.Usenet) + { + throw new ValidationException("Host cannot be empty for non-Usenet clients"); + } + } +} \ No newline at end of file diff --git a/code/UI/src/app/core/services/configuration.service.ts b/code/UI/src/app/core/services/configuration.service.ts index fb09a3f7..175ac02b 100644 --- a/code/UI/src/app/core/services/configuration.service.ts +++ b/code/UI/src/app/core/services/configuration.service.ts @@ -6,7 +6,7 @@ import { JobSchedule, QueueCleanerConfig, ScheduleUnit } from "../../shared/mode import { SonarrConfig } from "../../shared/models/sonarr-config.model"; import { RadarrConfig } from "../../shared/models/radarr-config.model"; import { LidarrConfig } from "../../shared/models/lidarr-config.model"; -import { ClientConfig, DownloadClientConfig } from "../../shared/models/download-client-config.model"; +import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from "../../shared/models/download-client-config.model"; @Injectable({ providedIn: "root", @@ -216,7 +216,7 @@ export class ConfigurationService { /** * Create a new Download Client */ - createDownloadClient(client: ClientConfig): Observable { + createDownloadClient(client: CreateDownloadClientDto): Observable { return this.http.post(`${this.apiUrl}/api/configuration/download_client`, client).pipe( catchError((error) => { console.error("Error creating Download Client:", error); diff --git a/code/UI/src/app/settings/download-client/download-client-config.store.ts b/code/UI/src/app/settings/download-client/download-client-config.store.ts index 26193304..ec8734f7 100644 --- a/code/UI/src/app/settings/download-client/download-client-config.store.ts +++ b/code/UI/src/app/settings/download-client/download-client-config.store.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { ClientConfig, DownloadClientConfig } from '../../shared/models/download-client-config.model'; +import { ClientConfig, DownloadClientConfig, CreateDownloadClientDto } from '../../shared/models/download-client-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; @@ -93,8 +93,8 @@ export class DownloadClientConfigStore extends signalStore( /** * Create a new download client */ - createClient: rxMethod( - (client$: Observable) => client$.pipe( + createClient: rxMethod( + (client$: Observable) => client$.pipe( tap(() => patchState(store, { saving: true, error: null })), switchMap(client => configService.createDownloadClient(client).pipe( tap({ 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 a50becf2..3f64cee9 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 @@ -227,9 +227,10 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD operationsCount++; this.downloadClientStore.updateClient({ id: client.id, client: backendClient }); } else { - // This is a new client, create it + // This is a new client, create it (don't send ID) operationsCount++; - this.downloadClientStore.createClient(backendClient); + const { id, ...clientWithoutId } = backendClient; + this.downloadClientStore.createClient(clientWithoutId); } }); @@ -239,14 +240,22 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD return; } - // Setup effect to run once when saving is complete - const savingEffect = effect(() => { + // Monitor the saving state to show completion feedback + const savingSubscription = this.downloadClientSaving().valueOf() !== false ? + this.monitorSavingCompletion() : null; + } + + /** + * Monitor saving completion and show appropriate feedback + */ + private monitorSavingCompletion(): void { + // Use a timeout to check the saving state periodically + const checkSavingStatus = () => { const saving = this.downloadClientSaving(); const error = this.downloadClientError(); - // Only proceed if not in saving state if (!saving) { - // Check for errors + // Saving is complete if (error) { this.notificationService.showError(`Failed to save: ${error}`); this.error.emit(error); @@ -258,11 +267,14 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD this.hasActualChanges = false; this.storeOriginalValues(); } - - // Cleanup effect - savingEffect.destroy(); + } else { + // Still saving, check again in a short while + setTimeout(checkSavingStatus, 100); } - }); + }; + + // Start monitoring + setTimeout(checkSavingStatus, 100); } /** diff --git a/code/UI/src/app/shared/models/download-client-config.model.ts b/code/UI/src/app/shared/models/download-client-config.model.ts index e789e910..fa1490c8 100644 --- a/code/UI/src/app/shared/models/download-client-config.model.ts +++ b/code/UI/src/app/shared/models/download-client-config.model.ts @@ -60,6 +60,51 @@ export interface ClientConfig { urlBase: string; } +/** + * DTO for creating a new download client (without ID) + */ +export interface CreateDownloadClientDto { + /** + * Whether this client is enabled + */ + enabled: boolean; + + /** + * Friendly name for this client + */ + name: string; + + /** + * Type name of download client (backend enum) + */ + typeName: string; + + /** + * Type of download client (backend enum) + */ + type: string; + + /** + * Host address for the download client + */ + host?: string; + + /** + * Username for authentication + */ + username?: string; + + /** + * Password for authentication + */ + password?: string; + + /** + * The base URL path component, used by clients like Transmission and Deluge + */ + urlBase?: string; +} + /** * Update DTO model for download client configuration */ @@ -71,11 +116,11 @@ export interface DownloadClientConfigUpdateDto extends DownloadClientConfig { } /** - * Update DTO for client configuration (includes password) + * Update DTO for individual client (includes password) */ export interface ClientConfigUpdateDto extends ClientConfig { /** - * Password for authentication (only included in update) + * Password for authentication (required for updates) */ - password?: string; + password: string; }