From 3c2e36eb9e510b7f075cd846dee6f751146f3179 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 15 Jun 2025 02:26:37 +0300 Subject: [PATCH] try fix download client UI --- .../Controllers/ConfigurationController.cs | 113 ++++++- .../core/services/configuration.service.ts | 38 ++- .../download-client-config.store.ts | 102 +++++- .../download-client-settings.component.ts | 296 +++++++++++------- .../models/download-client-config.model.ts | 7 +- 5 files changed, 432 insertions(+), 124 deletions(-) diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index a738d94e..f0838e23 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -11,6 +11,7 @@ using Infrastructure.Verticals.ContentBlocker; using Mapster; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System; namespace Executable.Controllers; @@ -76,9 +77,12 @@ public class ConfigurationController : ControllerBase await DataContext.Lock.WaitAsync(); try { - var config = await _dataContext.DownloadClients + var clients = await _dataContext.DownloadClients .AsNoTracking() .ToListAsync(); + + // Return in the expected format with clients wrapper + var config = new { clients = clients }; return Ok(config); } finally @@ -86,6 +90,111 @@ public class ConfigurationController : ControllerBase DataContext.Lock.Release(); } } + + [HttpPost("download_client")] + public async Task CreateDownloadClientConfig([FromBody] DownloadClientConfig newClient) + { + if (newClient == null) + { + return BadRequest("Invalid download client data"); + } + + await DataContext.Lock.WaitAsync(); + try + { + // Validate the configuration + newClient.Validate(); + + // Add the new client to the database + _dataContext.DownloadClients.Add(newClient); + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetDownloadClientConfig), new { id = newClient.Id }, newClient); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create download client"); + return StatusCode(500, "Failed to create download client configuration"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("download_client/{id}")] + public async Task UpdateDownloadClientConfig(Guid id, [FromBody] DownloadClientConfig updatedClient) + { + if (updatedClient == null) + { + return BadRequest("Invalid download client data"); + } + + await DataContext.Lock.WaitAsync(); + try + { + // Find the existing download client + var existingClient = await _dataContext.DownloadClients + .FirstOrDefaultAsync(c => c.Id == id); + + if (existingClient == null) + { + return NotFound($"Download client with ID {id} not found"); + } + + // Ensure the ID in the path matches the entity being updated + updatedClient = updatedClient with { Id = id }; + + // Apply updates from DTO + updatedClient.Adapt(existingClient); + + // Persist the configuration + await _dataContext.SaveChangesAsync(); + + return Ok(existingClient); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update download client with ID {Id}", id); + return StatusCode(500, "Failed to update download client configuration"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("download_client/{id}")] + public async Task DeleteDownloadClientConfig(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Find the existing download client + var existingClient = await _dataContext.DownloadClients + .FirstOrDefaultAsync(c => c.Id == id); + + if (existingClient == null) + { + return NotFound($"Download client with ID {id} not found"); + } + + // Remove the client from the database + _dataContext.DownloadClients.Remove(existingClient); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete download client with ID {Id}", id); + return StatusCode(500, "Failed to delete download client configuration"); + } + finally + { + DataContext.Lock.Release(); + } + } [HttpGet("general")] public async Task GetGeneralConfig() @@ -244,7 +353,7 @@ public class ConfigurationController : ControllerBase DataContext.Lock.Release(); } } - + [HttpPut("general")] public async Task UpdateGeneralConfig([FromBody] GeneralConfig newConfig) { diff --git a/code/UI/src/app/core/services/configuration.service.ts b/code/UI/src/app/core/services/configuration.service.ts index a6e753a7..fb09a3f7 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 { DownloadClientConfig } from "../../shared/models/download-client-config.model"; +import { ClientConfig, DownloadClientConfig } from "../../shared/models/download-client-config.model"; @Injectable({ providedIn: "root", @@ -212,4 +212,40 @@ export class ConfigurationService { }) ); } + + /** + * Create a new Download Client + */ + createDownloadClient(client: ClientConfig): Observable { + return this.http.post(`${this.apiUrl}/api/configuration/download_client`, client).pipe( + catchError((error) => { + console.error("Error creating Download Client:", error); + return throwError(() => new Error(error.error?.error || "Failed to create Download Client")); + }) + ); + } + + /** + * Update a specific Download Client by ID + */ + updateDownloadClient(id: string, client: ClientConfig): Observable { + return this.http.put(`${this.apiUrl}/api/configuration/download_client/${id}`, client).pipe( + catchError((error) => { + console.error(`Error updating Download Client with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to update Download Client with ID ${id}`)); + }) + ); + } + + /** + * Delete a Download Client by ID + */ + deleteDownloadClient(id: string): Observable { + return this.http.delete(`${this.apiUrl}/api/configuration/download_client/${id}`).pipe( + catchError((error) => { + console.error(`Error deleting Download Client with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to delete Download Client with ID ${id}`)); + }) + ); + } } 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 29ef69c2..26193304 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 { DownloadClientConfig } from '../../shared/models/download-client-config.model'; +import { ClientConfig, DownloadClientConfig } from '../../shared/models/download-client-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; @@ -88,7 +88,105 @@ export class DownloadClientConfigStore extends signalStore( */ resetError() { patchState(store, { error: null }); - } + }, + + /** + * Create a new download client + */ + createClient: rxMethod( + (client$: Observable) => client$.pipe( + tap(() => patchState(store, { saving: true, error: null })), + switchMap(client => configService.createDownloadClient(client).pipe( + tap({ + next: (newClient) => { + const currentConfig = store.config(); + if (currentConfig) { + // Add the new client to the clients array + const updatedClients = [...currentConfig.clients, newClient]; + + patchState(store, { + config: { clients: updatedClients }, + saving: false + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + error: error.message || 'Failed to create Download Client' + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Update a specific download client by ID + */ + updateClient: rxMethod<{ id: string, client: ClientConfig }>( + (params$: Observable<{ id: string, client: ClientConfig }>) => params$.pipe( + tap(() => patchState(store, { saving: true, error: null })), + switchMap(({ id, client }) => configService.updateDownloadClient(id, client).pipe( + tap({ + next: (updatedClient) => { + const currentConfig = store.config(); + if (currentConfig) { + // Find and replace the updated client in the clients array + const updatedClients = currentConfig.clients.map((c: ClientConfig) => + c.id === id ? updatedClient : c + ); + + patchState(store, { + config: { clients: updatedClients }, + saving: false + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + error: error.message || `Failed to update Download Client with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Delete a download client by ID + */ + deleteClient: rxMethod( + (id$: Observable) => id$.pipe( + tap(() => patchState(store, { saving: true, error: null })), + switchMap(id => configService.deleteDownloadClient(id).pipe( + tap({ + next: () => { + const currentConfig = store.config(); + if (currentConfig) { + // Remove the client from the clients array + const updatedClients = currentConfig.clients.filter((c: ClientConfig) => c.id !== id); + + patchState(store, { + config: { clients: updatedClients }, + saving: false + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + error: error.message || `Failed to delete Download Client with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ) })), withHooks({ onInit({ loadConfig }) { 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 17081733..a50becf2 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 @@ -1,6 +1,6 @@ import { Component, EventEmitter, OnDestroy, Output, effect, inject } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms"; +import { FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors, FormControl } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; import { DownloadClientConfigStore } from "./download-client-config.store"; import { CanComponentDeactivate } from "../../core/guards"; @@ -72,6 +72,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD downloadClientError = this.downloadClientStore.error; downloadClientSaving = this.downloadClientStore.saving; + /** + * Get the clients form array + */ + public get clients(): FormArray { + return this.downloadClientForm.get('clients') as FormArray; + } + /** * Check if component can be deactivated (navigation guard) */ @@ -96,6 +103,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD this.updateFormFromConfig(config); } }); + + // Track form changes for dirty state + this.downloadClientForm.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.hasActualChanges = this.formValuesChanged(); + }); } /** @@ -179,63 +193,76 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD * Save the Download Client configuration */ saveDownloadClientConfig(): void { - if (this.downloadClientForm.valid) { - // Get the form values - const formValues = this.downloadClientForm.value as DownloadClientConfig; - - // Flag to track actual changes - this.hasActualChanges = this.formValuesChanged(); - - // Save the configuration - this.downloadClientStore.saveConfig(formValues); - - // Setup a one-time check for save completion - const checkSaveCompletion = () => { - // Check if saving is complete - if (!this.downloadClientSaving()) { - // Check if there's an error - const error = this.downloadClientError(); - if (error) { - // Show error notification - this.notificationService.showError('Failed to save configuration'); - - // Emit error for parent components - this.error.emit(error); - return; - } - - // Save successful - - // Store new original values - this.storeOriginalValues(); - - // Mark form as pristine after save - this.downloadClientForm.markAsPristine(); - this.hasActualChanges = false; - - // Notify listeners that we've completed the save - this.saved.emit(); - - // Show success message - this.notificationService.showSuccess("Download Client configuration saved successfully"); - } else { - // If still saving, check again in a moment - setTimeout(checkSaveCompletion, 100); - } + // Mark all form controls as touched to trigger validation + this.markFormGroupTouched(this.downloadClientForm); + + if (this.downloadClientForm.invalid) { + this.notificationService.showError('Please fix the validation errors before saving'); + return; + } + + if (!this.hasActualChanges) { + this.notificationService.showSuccess('No changes detected'); + return; + } + + // Get the clients from the form + const formClients = this.clients.getRawValue(); + + // Keep track of operations + let operationsCount = 0; + + // Process each client + formClients.forEach((client: any) => { + // Map the client type for backend compatibility + const mappedType = this.mapClientTypeForBackend(client.type); + const backendClient = { + ...client, + typeName: mappedType.typeName, + type: mappedType.type }; - // Start checking for save completion - checkSaveCompletion(); - } else { - // Form is invalid, show error message - this.notificationService.showValidationError(); - - // Emit error for parent components - this.error.emit("Please fix validation errors before saving."); - - // Mark all controls as touched to show validation errors - this.markFormGroupTouched(this.downloadClientForm); + if (client.id) { + // This is an existing client, use the individual update endpoint + operationsCount++; + this.downloadClientStore.updateClient({ id: client.id, client: backendClient }); + } else { + // This is a new client, create it + operationsCount++; + this.downloadClientStore.createClient(backendClient); + } + }); + + // If we don't have any clients to process, show a message + if (operationsCount === 0) { + this.notificationService.showSuccess('No clients to save'); + return; } + + // Setup effect to run once when saving is complete + const savingEffect = effect(() => { + const saving = this.downloadClientSaving(); + const error = this.downloadClientError(); + + // Only proceed if not in saving state + if (!saving) { + // Check for errors + if (error) { + this.notificationService.showError(`Failed to save: ${error}`); + this.error.emit(error); + } else { + // Success + this.notificationService.showSuccess('Download Client configuration saved successfully'); + this.saved.emit(); + this.downloadClientForm.markAsPristine(); + this.hasActualChanges = false; + this.storeOriginalValues(); + } + + // Cleanup effect + savingEffect.destroy(); + } + }); } /** @@ -253,70 +280,92 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD /** * Add a new client to the clients form array + * @param client Optional client configuration to initialize the form with */ 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; + // If client has typeName from backend, map it to frontend type + const frontendType = client?.typeName + ? this.mapClientTypeFromBackend(client.typeName) + : client?.type || null; - // 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 ?? ''] + const clientForm = this.formBuilder.group({ + id: [client?.id || ''], + name: [client?.name || '', Validators.required], + type: [frontendType, Validators.required], + host: [client?.host || '', Validators.required], + username: [client?.username || ''], + password: [client?.password || ''], + urlBase: [client?.urlBase || ''], + enabled: [client?.enabled ?? true] }); - // 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); - } - }); - } + // Set up client type change handler + clientForm.get('type')?.valueChanges.subscribe(() => { + this.onClientTypeChange(clientForm); + }); - clientsArray.push(clientFormGroup); - this.downloadClientForm.markAsDirty(); - // Recalculate if actual changes exist by comparing with original values - this.hasActualChanges = this.formValuesChanged(); + this.clients.push(clientForm); } - + /** - * Remove a client from the list + * Map frontend client type to backend TypeName and Type + */ + private mapClientTypeForBackend(frontendType: DownloadClientType): { typeName: string, type: string } { + switch (frontendType) { + case DownloadClientType.QBittorrent: + return { typeName: 'QBittorrent', type: 'Torrent' }; + case DownloadClientType.Deluge: + return { typeName: 'Deluge', type: 'Torrent' }; + case DownloadClientType.Transmission: + return { typeName: 'Transmission', type: 'Torrent' }; + case DownloadClientType.Usenet: + return { typeName: 'Usenet', type: 'Usenet' }; + default: + return { typeName: 'QBittorrent', type: 'Torrent' }; + } + } + + /** + * Map backend TypeName to frontend client type + */ + private mapClientTypeFromBackend(backendTypeName: string): DownloadClientType { + switch (backendTypeName) { + case 'QBittorrent': + return DownloadClientType.QBittorrent; + case 'Deluge': + return DownloadClientType.Deluge; + case 'Transmission': + return DownloadClientType.Transmission; + case 'Usenet': + return DownloadClientType.Usenet; + default: + return DownloadClientType.QBittorrent; + } + } + + /** + * Remove a client at the specified index */ removeClient(index: number): void { - const clientsArray = this.downloadClientForm.get('clients') as FormArray; - clientsArray.removeAt(index); - this.downloadClientForm.markAsDirty(); - // Recalculate if actual changes exist by comparing with original values - this.hasActualChanges = this.formValuesChanged(); + const clientForm = this.getClientAsFormGroup(index); + const clientId = clientForm.get('id')?.value; + + // If this is an existing client (has ID), delete it from the backend + if (clientId) { + this.downloadClientStore.deleteClient(clientId); + } + + // Remove from the form array + this.clients.removeAt(index); + + // If no clients remain, add an empty one + if (this.clients.length === 0) { + this.addClient(); + } } /** - * Get the clients form array - */ - get clients(): FormArray { - return this.downloadClientForm.get('clients') as FormArray; - } - - /** - * Get a client at the specified index as a FormGroup - */ - getClientAsFormGroup(index: number): FormGroup { - return this.clients.at(index) as FormGroup; - } - - /** - * Mark all controls in a form group as touched + * Mark all controls in a form group as touched to trigger validation */ private markFormGroupTouched(formGroup: FormGroup): void { Object.values(formGroup.controls).forEach((control) => { @@ -328,6 +377,13 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD }); } + /** + * Get a client at the specified index as a FormGroup + */ + public getClientAsFormGroup(index: number): FormGroup { + return this.clients.at(index) as FormGroup; + } + /** * Check if the form control has an error * @param controlName The name of the control to check @@ -347,10 +403,9 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD * @returns True if the field has the specified error */ hasClientFieldError(clientIndex: number, fieldName: string, errorName: string): boolean { - const clientsArray = this.downloadClientForm.get('clients') as FormArray; - if (!clientsArray || !clientsArray.controls[clientIndex]) return false; + if (!this.clients || !this.clients.controls[clientIndex]) return false; - const control = (clientsArray.controls[clientIndex] as FormGroup).get(fieldName); + const control = this.getClientAsFormGroup(clientIndex).get(fieldName); return control !== null && control.hasError(errorName) && control.touched; } @@ -363,8 +418,6 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD } try { - // Try to create a URL from the input value - // This will throw an error if the input is not a valid URL const url = new URL(control.value); // Check that we have a valid protocol (http or https) @@ -377,30 +430,37 @@ export class DownloadClientSettingsComponent implements OnDestroy, CanComponentD return { invalidUri: true }; // Invalid URI } } - + /** * Checks if a client type is Usenet */ - isUsenetClient(clientType: DownloadClientType | null | undefined): boolean { + public isUsenetClient(clientType: DownloadClientType | null | undefined): boolean { return clientType === DownloadClientType.Usenet; } - + /** - * Update validators for a client form group based on the client type + * Handle client type changes to update validation + * @param clientFormGroup The form group containing the client type and host controls */ - private updateValidatorsForClientType(clientFormGroup: FormGroup, clientType: DownloadClientType): void { + onClientTypeChange(clientFormGroup: FormGroup): void { + const clientType = clientFormGroup.get('type')?.value; const hostControl = clientFormGroup.get('host'); + if (!hostControl) return; - if (clientType === DownloadClientType.Usenet) { + if (this.isUsenetClient(clientType)) { // For Usenet, remove all validators hostControl.clearValidators(); } else { // For other client types, add required and URI validators - hostControl.setValidators([Validators.required, this.uriValidator]); + hostControl.setValidators([ + Validators.required, + this.uriValidator.bind(this) + ]); } // Update validation state hostControl.updateValueAndValidity(); } + } 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 765fd556..e789e910 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 @@ -30,10 +30,15 @@ export interface ClientConfig { name: string; /** - * Type of download client + * Type of download client (frontend enum) */ type: DownloadClientType; + /** + * Type name of download client (backend enum) + */ + typeName?: string; + /** * Host address for the download client */