From 1194db6c1e2e340ee9353880d78526e47b2c237e Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 15 Jun 2025 18:54:28 +0300 Subject: [PATCH] try fix arr #1 --- .../Controllers/ConfigurationController.cs | 397 +++++++++++++++++- code/Executable/DTOs/CreateArrInstanceDto.cs | 18 + code/Executable/DTOs/UpdateLidarrConfigDto.cs | 11 + code/Executable/DTOs/UpdateRadarrConfigDto.cs | 11 + code/Executable/DTOs/UpdateSonarrConfigDto.cs | 36 ++ .../core/services/configuration.service.ts | 115 +++++ .../settings/lidarr/lidarr-config.store.ts | 108 ++++- .../lidarr/lidarr-settings.component.html | 2 +- .../lidarr/lidarr-settings.component.ts | 261 +++++++----- .../queue-cleaner-settings.component.html | 2 +- .../settings/radarr/radarr-config.store.ts | 108 ++++- .../radarr/radarr-settings.component.html | 2 +- .../radarr/radarr-settings.component.ts | 261 +++++++----- .../settings/sonarr/sonarr-config.store.ts | 337 ++++++++++++++- .../sonarr/sonarr-settings.component.html | 2 +- .../sonarr/sonarr-settings.component.ts | 262 +++++++----- .../src/app/shared/models/arr-config.model.ts | 9 + 17 files changed, 1606 insertions(+), 336 deletions(-) create mode 100644 code/Executable/DTOs/CreateArrInstanceDto.cs create mode 100644 code/Executable/DTOs/UpdateLidarrConfigDto.cs create mode 100644 code/Executable/DTOs/UpdateRadarrConfigDto.cs create mode 100644 code/Executable/DTOs/UpdateSonarrConfigDto.cs diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index 45881c55..e8d8f970 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -411,24 +411,36 @@ public class ConfigurationController : ControllerBase } [HttpPut("sonarr")] - public async Task UpdateSonarrConfig([FromBody] SonarrConfig newConfig) + public async Task UpdateSonarrConfig([FromBody] UpdateSonarrConfigDto newConfigDto) { await DataContext.Lock.WaitAsync(); try { - // Validate the configuration - newConfig.Validate(); - // Get existing config var oldConfig = await _dataContext.SonarrConfigs + .Include(x => x.Instances) .FirstAsync(); - // Apply updates from DTO, excluding the ID property to avoid EF key modification error + // Create new config with updated basic settings only (instances managed separately) + var updatedConfig = new SonarrConfig + { + Id = oldConfig.Id, // Keep the existing ID + Enabled = newConfigDto.Enabled, + FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes, + SearchType = newConfigDto.SearchType, + Instances = oldConfig.Instances // Keep existing instances unchanged + }; + + // Validate the configuration + updatedConfig.Validate(); + + // Update the existing entity using Mapster, excluding the ID var config = new TypeAdapterConfig(); config.NewConfig() - .Ignore(dest => dest.Id); + .Ignore(dest => dest.Id) + .Ignore(dest => dest.Instances); // Don't update instances here - newConfig.Adapt(oldConfig, config); + updatedConfig.Adapt(oldConfig, config); // Persist the configuration await _dataContext.SaveChangesAsync(); @@ -447,24 +459,35 @@ public class ConfigurationController : ControllerBase } [HttpPut("radarr")] - public async Task UpdateRadarrConfig([FromBody] RadarrConfig newConfig) + public async Task UpdateRadarrConfig([FromBody] UpdateRadarrConfigDto newConfigDto) { await DataContext.Lock.WaitAsync(); try { - // Validate the configuration - newConfig.Validate(); - // Get existing config var oldConfig = await _dataContext.RadarrConfigs + .Include(x => x.Instances) .FirstAsync(); - // Apply updates from DTO, excluding the ID property to avoid EF key modification error + // Create new config with updated basic settings only (instances managed separately) + var updatedConfig = new RadarrConfig + { + Id = oldConfig.Id, // Keep the existing ID + Enabled = newConfigDto.Enabled, + FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes, + Instances = oldConfig.Instances // Keep existing instances unchanged + }; + + // Validate the configuration + updatedConfig.Validate(); + + // Update the existing entity using Mapster, excluding the ID var config = new TypeAdapterConfig(); config.NewConfig() - .Ignore(dest => dest.Id); + .Ignore(dest => dest.Id) + .Ignore(dest => dest.Instances); // Don't update instances here - newConfig.Adapt(oldConfig, config); + updatedConfig.Adapt(oldConfig, config); // Persist the configuration await _dataContext.SaveChangesAsync(); @@ -483,24 +506,35 @@ public class ConfigurationController : ControllerBase } [HttpPut("lidarr")] - public async Task UpdateLidarrConfig([FromBody] LidarrConfig newConfig) + public async Task UpdateLidarrConfig([FromBody] UpdateLidarrConfigDto newConfigDto) { await DataContext.Lock.WaitAsync(); try { - // Validate the configuration - newConfig.Validate(); - // Get existing config var oldConfig = await _dataContext.LidarrConfigs + .Include(x => x.Instances) .FirstAsync(); - // Apply updates from DTO, excluding the ID property to avoid EF key modification error + // Create new config with updated basic settings only (instances managed separately) + var updatedConfig = new LidarrConfig + { + Id = oldConfig.Id, // Keep the existing ID + Enabled = newConfigDto.Enabled, + FailedImportMaxStrikes = newConfigDto.FailedImportMaxStrikes, + Instances = oldConfig.Instances // Keep existing instances unchanged + }; + + // Validate the configuration + updatedConfig.Validate(); + + // Update the existing entity using Mapster, excluding the ID var config = new TypeAdapterConfig(); config.NewConfig() - .Ignore(dest => dest.Id); + .Ignore(dest => dest.Id) + .Ignore(dest => dest.Instances); // Don't update instances here - newConfig.Adapt(oldConfig, config); + updatedConfig.Adapt(oldConfig, config); // Persist the configuration await _dataContext.SaveChangesAsync(); @@ -551,4 +585,325 @@ public class ConfigurationController : ControllerBase _logger.LogInformation("{name} is disabled, stopping the job", jobType.ToString()); await _jobManagementService.StopJob(jobType); } + + [HttpPost("sonarr/instances")] + public async Task CreateSonarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config to add the instance to + var config = await _dataContext.SonarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + // Create the new instance + var instance = new ArrInstance + { + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey + }; + + // Add to the config + config.Instances.Add(instance); + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetSonarrConfig), new { id = instance.Id }, instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Sonarr instance"); + return StatusCode(500, "Failed to create Sonarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("sonarr/instances/{id}")] + public async Task UpdateSonarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config and find the instance + var config = await _dataContext.SonarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Sonarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Sonarr instance with ID {Id}", id); + return StatusCode(500, "Failed to update Sonarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("sonarr/instances/{id}")] + public async Task DeleteSonarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Sonarr config and find the instance + var config = await _dataContext.SonarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Sonarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Sonarr instance with ID {Id}", id); + return StatusCode(500, "Failed to delete Sonarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("radarr/instances")] + public async Task CreateRadarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config to add the instance to + var config = await _dataContext.RadarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + // Create the new instance + var instance = new ArrInstance + { + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey + }; + + // Add to the config + config.Instances.Add(instance); + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetRadarrConfig), new { id = instance.Id }, instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Radarr instance"); + return StatusCode(500, "Failed to create Radarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("radarr/instances/{id}")] + public async Task UpdateRadarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config and find the instance + var config = await _dataContext.RadarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Radarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Radarr instance with ID {Id}", id); + return StatusCode(500, "Failed to update Radarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("radarr/instances/{id}")] + public async Task DeleteRadarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Radarr config and find the instance + var config = await _dataContext.RadarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Radarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Radarr instance with ID {Id}", id); + return StatusCode(500, "Failed to delete Radarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPost("lidarr/instances")] + public async Task CreateLidarrInstance([FromBody] CreateArrInstanceDto newInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config to add the instance to + var config = await _dataContext.LidarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + // Create the new instance + var instance = new ArrInstance + { + Name = newInstance.Name, + Url = new Uri(newInstance.Url), + ApiKey = newInstance.ApiKey + }; + + // Add to the config + config.Instances.Add(instance); + await _dataContext.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetLidarrConfig), new { id = instance.Id }, instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create Lidarr instance"); + return StatusCode(500, "Failed to create Lidarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpPut("lidarr/instances/{id}")] + public async Task UpdateLidarrInstance(Guid id, [FromBody] CreateArrInstanceDto updatedInstance) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config and find the instance + var config = await _dataContext.LidarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Lidarr instance with ID {id} not found"); + } + + // Update the instance properties + instance.Name = updatedInstance.Name; + instance.Url = new Uri(updatedInstance.Url); + instance.ApiKey = updatedInstance.ApiKey; + + await _dataContext.SaveChangesAsync(); + + return Ok(instance); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update Lidarr instance with ID {Id}", id); + return StatusCode(500, "Failed to update Lidarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } + + [HttpDelete("lidarr/instances/{id}")] + public async Task DeleteLidarrInstance(Guid id) + { + await DataContext.Lock.WaitAsync(); + try + { + // Get the Lidarr config and find the instance + var config = await _dataContext.LidarrConfigs + .Include(c => c.Instances) + .FirstAsync(); + + var instance = config.Instances.FirstOrDefault(i => i.Id == id); + if (instance == null) + { + return NotFound($"Lidarr instance with ID {id} not found"); + } + + // Remove the instance + config.Instances.Remove(instance); + await _dataContext.SaveChangesAsync(); + + return NoContent(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete Lidarr instance with ID {Id}", id); + return StatusCode(500, "Failed to delete Lidarr instance"); + } + finally + { + DataContext.Lock.Release(); + } + } } \ No newline at end of file diff --git a/code/Executable/DTOs/CreateArrInstanceDto.cs b/code/Executable/DTOs/CreateArrInstanceDto.cs new file mode 100644 index 00000000..0834e679 --- /dev/null +++ b/code/Executable/DTOs/CreateArrInstanceDto.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Executable.DTOs; + +/// +/// DTO for creating new Arr instances without requiring an ID +/// +public record CreateArrInstanceDto +{ + [Required] + public required string Name { get; init; } + + [Required] + public required string Url { get; init; } + + [Required] + public required string ApiKey { get; init; } +} \ No newline at end of file diff --git a/code/Executable/DTOs/UpdateLidarrConfigDto.cs b/code/Executable/DTOs/UpdateLidarrConfigDto.cs new file mode 100644 index 00000000..b11552f7 --- /dev/null +++ b/code/Executable/DTOs/UpdateLidarrConfigDto.cs @@ -0,0 +1,11 @@ +namespace Executable.DTOs; + +/// +/// DTO for updating Lidarr configuration basic settings (instances managed separately) +/// +public record UpdateLidarrConfigDto +{ + public bool Enabled { get; init; } + + public short FailedImportMaxStrikes { get; init; } = -1; +} \ No newline at end of file diff --git a/code/Executable/DTOs/UpdateRadarrConfigDto.cs b/code/Executable/DTOs/UpdateRadarrConfigDto.cs new file mode 100644 index 00000000..26283c6e --- /dev/null +++ b/code/Executable/DTOs/UpdateRadarrConfigDto.cs @@ -0,0 +1,11 @@ +namespace Executable.DTOs; + +/// +/// DTO for updating Radarr configuration basic settings (instances managed separately) +/// +public record UpdateRadarrConfigDto +{ + public bool Enabled { get; init; } + + public short FailedImportMaxStrikes { get; init; } = -1; +} \ No newline at end of file diff --git a/code/Executable/DTOs/UpdateSonarrConfigDto.cs b/code/Executable/DTOs/UpdateSonarrConfigDto.cs new file mode 100644 index 00000000..204ba7f3 --- /dev/null +++ b/code/Executable/DTOs/UpdateSonarrConfigDto.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using Common.Configuration.Arr; + +namespace Executable.DTOs; + +/// +/// DTO for updating Sonarr configuration basic settings (instances managed separately) +/// +public record UpdateSonarrConfigDto +{ + public bool Enabled { get; init; } + + public short FailedImportMaxStrikes { get; init; } = -1; + + public SonarrSearchType SearchType { get; init; } +} + +/// +/// DTO for Arr instances that can handle both existing (with ID) and new (without ID) instances +/// +public record ArrInstanceDto +{ + /// + /// ID for existing instances, null for new instances + /// + public Guid? Id { get; init; } + + [Required] + public required string Name { get; init; } + + [Required] + public required string Url { get; init; } + + [Required] + public required string ApiKey { get; init; } +} \ 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 175ac02b..56f8f5f9 100644 --- a/code/UI/src/app/core/services/configuration.service.ts +++ b/code/UI/src/app/core/services/configuration.service.ts @@ -7,6 +7,7 @@ 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, CreateDownloadClientDto } from "../../shared/models/download-client-config.model"; +import { ArrInstance, CreateArrInstanceDto } from "../../shared/models/arr-config.model"; @Injectable({ providedIn: "root", @@ -248,4 +249,118 @@ export class ConfigurationService { }) ); } + + // ===== SONARR INSTANCE MANAGEMENT ===== + + /** + * Create a new Sonarr instance + */ + createSonarrInstance(instance: CreateArrInstanceDto): Observable { + return this.http.post(`${this.apiUrl}/api/configuration/sonarr/instances`, instance).pipe( + catchError((error) => { + console.error("Error creating Sonarr instance:", error); + return throwError(() => new Error(error.error?.error || "Failed to create Sonarr instance")); + }) + ); + } + + /** + * Update a Sonarr instance by ID + */ + updateSonarrInstance(id: string, instance: CreateArrInstanceDto): Observable { + return this.http.put(`${this.apiUrl}/api/configuration/sonarr/instances/${id}`, instance).pipe( + catchError((error) => { + console.error(`Error updating Sonarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to update Sonarr instance with ID ${id}`)); + }) + ); + } + + /** + * Delete a Sonarr instance by ID + */ + deleteSonarrInstance(id: string): Observable { + return this.http.delete(`${this.apiUrl}/api/configuration/sonarr/instances/${id}`).pipe( + catchError((error) => { + console.error(`Error deleting Sonarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to delete Sonarr instance with ID ${id}`)); + }) + ); + } + + // ===== RADARR INSTANCE MANAGEMENT ===== + + /** + * Create a new Radarr instance + */ + createRadarrInstance(instance: CreateArrInstanceDto): Observable { + return this.http.post(`${this.apiUrl}/api/configuration/radarr/instances`, instance).pipe( + catchError((error) => { + console.error("Error creating Radarr instance:", error); + return throwError(() => new Error(error.error?.error || "Failed to create Radarr instance")); + }) + ); + } + + /** + * Update a Radarr instance by ID + */ + updateRadarrInstance(id: string, instance: CreateArrInstanceDto): Observable { + return this.http.put(`${this.apiUrl}/api/configuration/radarr/instances/${id}`, instance).pipe( + catchError((error) => { + console.error(`Error updating Radarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to update Radarr instance with ID ${id}`)); + }) + ); + } + + /** + * Delete a Radarr instance by ID + */ + deleteRadarrInstance(id: string): Observable { + return this.http.delete(`${this.apiUrl}/api/configuration/radarr/instances/${id}`).pipe( + catchError((error) => { + console.error(`Error deleting Radarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to delete Radarr instance with ID ${id}`)); + }) + ); + } + + // ===== LIDARR INSTANCE MANAGEMENT ===== + + /** + * Create a new Lidarr instance + */ + createLidarrInstance(instance: CreateArrInstanceDto): Observable { + return this.http.post(`${this.apiUrl}/api/configuration/lidarr/instances`, instance).pipe( + catchError((error) => { + console.error("Error creating Lidarr instance:", error); + return throwError(() => new Error(error.error?.error || "Failed to create Lidarr instance")); + }) + ); + } + + /** + * Update a Lidarr instance by ID + */ + updateLidarrInstance(id: string, instance: CreateArrInstanceDto): Observable { + return this.http.put(`${this.apiUrl}/api/configuration/lidarr/instances/${id}`, instance).pipe( + catchError((error) => { + console.error(`Error updating Lidarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to update Lidarr instance with ID ${id}`)); + }) + ); + } + + /** + * Delete a Lidarr instance by ID + */ + deleteLidarrInstance(id: string): Observable { + return this.http.delete(`${this.apiUrl}/api/configuration/lidarr/instances/${id}`).pipe( + catchError((error) => { + console.error(`Error deleting Lidarr instance with ID ${id}:`, error); + return throwError(() => new Error(error.error?.error || `Failed to delete Lidarr instance with ID ${id}`)); + }) + ); + } } diff --git a/code/UI/src/app/settings/lidarr/lidarr-config.store.ts b/code/UI/src/app/settings/lidarr/lidarr-config.store.ts index 2b2e3c67..8cbf26cc 100644 --- a/code/UI/src/app/settings/lidarr/lidarr-config.store.ts +++ b/code/UI/src/app/settings/lidarr/lidarr-config.store.ts @@ -3,20 +3,23 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngr import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { LidarrConfig } from '../../shared/models/lidarr-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; -import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; +import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs'; +import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model'; export interface LidarrConfigState { config: LidarrConfig | null; loading: boolean; saving: boolean; error: string | null; + instanceOperations: number; } const initialState: LidarrConfigState = { config: null, loading: false, saving: false, - error: null + error: null, + instanceOperations: 0 }; @Injectable() @@ -88,7 +91,106 @@ export class LidarrConfigStore extends signalStore( */ resetError() { patchState(store, { error: null }); - } + }, + + // ===== INSTANCE MANAGEMENT ===== + + /** + * Create a new Lidarr instance + */ + createInstance: rxMethod( + (instance$: Observable) => instance$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(instance => configService.createLidarrInstance(instance).pipe( + tap({ + next: (newInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + patchState(store, { + config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || 'Failed to create Lidarr instance' + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Update a Lidarr instance by ID + */ + updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>( + (params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(({ id, instance }) => configService.updateLidarrInstance(id, instance).pipe( + tap({ + next: (updatedInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.map((inst: ArrInstance) => + inst.id === id ? updatedInstance : inst + ); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to update Lidarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Delete a Lidarr instance by ID + */ + deleteInstance: rxMethod( + (id$: Observable) => id$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(id => configService.deleteLidarrInstance(id).pipe( + tap({ + next: () => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to delete Lidarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ) })), withHooks({ onInit({ loadConfig }) { diff --git a/code/UI/src/app/settings/lidarr/lidarr-settings.component.html b/code/UI/src/app/settings/lidarr/lidarr-settings.component.html index 7cd882ab..004b9525 100644 --- a/code/UI/src/app/settings/lidarr/lidarr-settings.component.html +++ b/code/UI/src/app/settings/lidarr/lidarr-settings.component.html @@ -71,7 +71,7 @@ + (click)="removeInstance(i)" [disabled]="lidarrForm.disabled"> Name is required diff --git a/code/UI/src/app/settings/lidarr/lidarr-settings.component.ts b/code/UI/src/app/settings/lidarr/lidarr-settings.component.ts index 362640f3..20162ab1 100644 --- a/code/UI/src/app/settings/lidarr/lidarr-settings.component.ts +++ b/code/UI/src/app/settings/lidarr/lidarr-settings.component.ts @@ -5,6 +5,7 @@ import { Subject, takeUntil } from "rxjs"; import { LidarrConfigStore } from "./lidarr-config.store"; import { CanComponentDeactivate } from "../../core/guards"; import { LidarrConfig } from "../../shared/models/lidarr-config.model"; +import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model"; // PrimeNG Components import { CardModule } from "primeng/card"; @@ -65,6 +66,7 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat lidarrLoading = this.lidarrStore.loading; lidarrError = this.lidarrStore.error; lidarrSaving = this.lidarrStore.saving; + instanceOperations = this.lidarrStore.instanceOperations; /** * Check if component can be deactivated (navigation guard) @@ -203,115 +205,34 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat } } - /** - * Save the Lidarr configuration - */ - saveLidarrConfig(): void { - if (this.lidarrForm.valid) { - // Mark form as saving - this.lidarrForm.disable(); - - // Get data from form - const formValue = this.lidarrForm.getRawValue(); - - // Create config object - const lidarrConfig: LidarrConfig = { - enabled: formValue.enabled, - failedImportMaxStrikes: formValue.failedImportMaxStrikes, - instances: formValue.instances || [] - }; - - // Save the configuration - this.lidarrStore.saveConfig(lidarrConfig); - - // Setup a one-time check for save completion - const checkSaveCompletion = () => { - // Check if we're done saving - if (!this.lidarrSaving()) { - // Re-enable the form - this.lidarrForm.enable(); - - // If still disabled, update control states based on enabled state - if (!this.lidarrForm.get('enabled')?.value) { - this.updateMainControlsState(false); - } - - // Update original values to match current form state - this.storeOriginalValues(); - - // Notify listeners that we've completed the save - this.saved.emit(); - - // Show success message - this.notificationService.showSuccess("Lidarr configuration saved successfully"); - } else { - // If still saving, check again in a moment - setTimeout(checkSaveCompletion, 100); - } - }; - - // 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.lidarrForm); - } - } - - /** - * Reset the Lidarr configuration form to default values - */ - resetLidarrConfig(): void { - this.lidarrForm.reset({ - enabled: false, - failedImportMaxStrikes: -1, - }); - - // Clear all instances - const instancesArray = this.lidarrForm.get('instances') as FormArray; - instancesArray.clear(); - - // Update control states after reset - this.updateMainControlsState(false); - - // Mark form as dirty so the save button is enabled after reset - this.lidarrForm.markAsDirty(); - this.hasActualChanges = true; - } - /** * Add a new instance to the instances form array + * @param instance Optional instance configuration to initialize the form with */ - addInstance(): void { - const instancesArray = this.lidarrForm.get('instances') as FormArray; - - instancesArray.push( - this.formBuilder.group({ - id: [''], - name: ['', Validators.required], - url: ['', Validators.required], - apiKey: ['', Validators.required], - }) - ); - - this.lidarrForm.markAsDirty(); - this.hasActualChanges = true; + addInstance(instance: ArrInstance | null = null): void { + const instanceForm = this.formBuilder.group({ + id: [instance?.id || ''], + name: [instance?.name || '', Validators.required], + url: [instance?.url?.toString() || '', Validators.required], + apiKey: [instance?.apiKey || '', Validators.required] + }); + + this.instances.push(instanceForm); } /** - * Remove an instance from the list + * Remove an instance at the specified index */ removeInstance(index: number): void { - const instancesArray = this.lidarrForm.get('instances') as FormArray; - instancesArray.removeAt(index); + const instanceForm = this.getInstanceAsFormGroup(index); + const instanceId = instanceForm.get('id')?.value; + + // Just remove from the form array - deletion will be handled on save + this.instances.removeAt(index); + + // Mark form as dirty to enable save button this.lidarrForm.markAsDirty(); - this.hasActualChanges = true; + this.hasActualChanges = this.formValuesChanged(); } /** @@ -368,4 +289,144 @@ export class LidarrSettingsComponent implements OnDestroy, CanComponentDeactivat const control = (instancesArray.controls[instanceIndex] as FormGroup).get(fieldName); return control !== null && control.hasError(errorName) && control.touched; } + + /** + * Save the Lidarr configuration + */ + saveLidarrConfig(): void { + // Mark all form controls as touched to trigger validation + this.markFormGroupTouched(this.lidarrForm); + + if (this.lidarrForm.invalid) { + this.notificationService.showError('Please fix the validation errors before saving'); + return; + } + + if (!this.hasActualChanges) { + this.notificationService.showSuccess('No changes detected'); + return; + } + + // Get the current config to preserve existing instances + const currentConfig = this.lidarrConfig(); + if (!currentConfig) return; + + // Create the updated main config + const updatedConfig: LidarrConfig = { + ...currentConfig, + enabled: this.lidarrForm.get('enabled')?.value, + failedImportMaxStrikes: this.lidarrForm.get('failedImportMaxStrikes')?.value + }; + + // Get the instances from the form + const formInstances = this.instances.getRawValue(); + + // Separate creates and updates + const creates: CreateArrInstanceDto[] = []; + const updates: Array<{ id: string, instance: ArrInstance }> = []; + + formInstances.forEach((instance: any) => { + if (instance.id) { + // This is an existing instance, prepare for update + const updateInstance: ArrInstance = { + id: instance.id, + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + updates.push({ id: instance.id, instance: updateInstance }); + } else { + // This is a new instance, prepare for creation (don't send ID) + const createInstance: CreateArrInstanceDto = { + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + creates.push(createInstance); + } + }); + + // Save main config first, then handle instances + this.lidarrStore.saveConfig(updatedConfig); + + // Handle instance operations if there are any + if (creates.length > 0) { + creates.forEach(instance => this.lidarrStore.createInstance(instance)); + } + if (updates.length > 0) { + updates.forEach(({ id, instance }) => this.lidarrStore.updateInstance({ id, instance })); + } + + // Monitor the saving state to show completion feedback + this.monitorSavingCompletion(); + } + + /** + * Monitor saving completion and show appropriate feedback + */ + private monitorSavingCompletion(): void { + // Use a timeout to check the saving state periodically + const checkSavingStatus = () => { + const saving = this.lidarrSaving(); + const error = this.lidarrError(); + const pendingOps = this.instanceOperations(); + + if (!saving && Object.keys(pendingOps).length === 0) { + // Operations are complete + if (error) { + this.notificationService.showError(`Save completed with issues: ${error}`); + this.error.emit(error); + // Don't mark as pristine if there were errors + } else { + // Complete success + this.notificationService.showSuccess('Lidarr configuration saved successfully'); + this.saved.emit(); + + // Reload config from backend to ensure UI is in sync + this.lidarrStore.loadConfig(); + + // Reset form state after successful save + setTimeout(() => { + this.lidarrForm.markAsPristine(); + this.hasActualChanges = false; + this.storeOriginalValues(); + }, 100); + } + } else { + // Still saving, check again in a short while + setTimeout(checkSavingStatus, 100); + } + }; + + // Start monitoring + setTimeout(checkSavingStatus, 100); + } + + /** + * Reset the Lidarr configuration form to default values + */ + resetLidarrConfig(): void { + // Clear all instances + const instancesArray = this.lidarrForm.get('instances') as FormArray; + instancesArray.clear(); + + // Reset main config to defaults + this.lidarrForm.patchValue({ + enabled: false, + failedImportMaxStrikes: -1 + }); + + // Check if this reset actually changes anything compared to the original state + const hasChangesAfterReset = this.formValuesChanged(); + + if (hasChangesAfterReset) { + // Only mark as dirty if the reset actually changes something + this.lidarrForm.markAsDirty(); + this.hasActualChanges = true; + } else { + // If reset brings us back to original state, mark as pristine + this.lidarrForm.markAsPristine(); + this.hasActualChanges = false; + } + } } diff --git a/code/UI/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html b/code/UI/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html index d15b4748..ec3cc970 100644 --- a/code/UI/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html +++ b/code/UI/src/app/settings/queue-cleaner/queue-cleaner-settings.component.html @@ -198,7 +198,7 @@ - Downloading Metadata Settings + Downloading Metadata Settings (qBittorrent only)
diff --git a/code/UI/src/app/settings/radarr/radarr-config.store.ts b/code/UI/src/app/settings/radarr/radarr-config.store.ts index adba18e0..87901ea4 100644 --- a/code/UI/src/app/settings/radarr/radarr-config.store.ts +++ b/code/UI/src/app/settings/radarr/radarr-config.store.ts @@ -3,20 +3,23 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngr import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { RadarrConfig } from '../../shared/models/radarr-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; -import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; +import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs'; +import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model'; export interface RadarrConfigState { config: RadarrConfig | null; loading: boolean; saving: boolean; error: string | null; + instanceOperations: number; } const initialState: RadarrConfigState = { config: null, loading: false, saving: false, - error: null + error: null, + instanceOperations: 0 }; @Injectable() @@ -88,7 +91,106 @@ export class RadarrConfigStore extends signalStore( */ resetError() { patchState(store, { error: null }); - } + }, + + // ===== INSTANCE MANAGEMENT ===== + + /** + * Create a new Radarr instance + */ + createInstance: rxMethod( + (instance$: Observable) => instance$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(instance => configService.createRadarrInstance(instance).pipe( + tap({ + next: (newInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + patchState(store, { + config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || 'Failed to create Radarr instance' + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Update a Radarr instance by ID + */ + updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>( + (params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(({ id, instance }) => configService.updateRadarrInstance(id, instance).pipe( + tap({ + next: (updatedInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.map((inst: ArrInstance) => + inst.id === id ? updatedInstance : inst + ); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to update Radarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Delete a Radarr instance by ID + */ + deleteInstance: rxMethod( + (id$: Observable) => id$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(id => configService.deleteRadarrInstance(id).pipe( + tap({ + next: () => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to delete Radarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ) })), withHooks({ onInit({ loadConfig }) { diff --git a/code/UI/src/app/settings/radarr/radarr-settings.component.html b/code/UI/src/app/settings/radarr/radarr-settings.component.html index 21eb7ec6..efc08045 100644 --- a/code/UI/src/app/settings/radarr/radarr-settings.component.html +++ b/code/UI/src/app/settings/radarr/radarr-settings.component.html @@ -71,7 +71,7 @@
+ (click)="removeInstance(i)" [disabled]="radarrForm.disabled"> Name is required diff --git a/code/UI/src/app/settings/radarr/radarr-settings.component.ts b/code/UI/src/app/settings/radarr/radarr-settings.component.ts index d7c065f4..b35d4539 100644 --- a/code/UI/src/app/settings/radarr/radarr-settings.component.ts +++ b/code/UI/src/app/settings/radarr/radarr-settings.component.ts @@ -5,6 +5,7 @@ import { Subject, takeUntil } from "rxjs"; import { RadarrConfigStore } from "./radarr-config.store"; import { CanComponentDeactivate } from "../../core/guards"; import { RadarrConfig } from "../../shared/models/radarr-config.model"; +import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model"; // PrimeNG Components import { CardModule } from "primeng/card"; @@ -65,6 +66,7 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat radarrLoading = this.radarrStore.loading; radarrError = this.radarrStore.error; radarrSaving = this.radarrStore.saving; + instanceOperations = this.radarrStore.instanceOperations; /** * Check if component can be deactivated (navigation guard) @@ -203,115 +205,34 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat } } - /** - * Save the Radarr configuration - */ - saveRadarrConfig(): void { - if (this.radarrForm.valid) { - // Mark form as saving - this.radarrForm.disable(); - - // Get data from form - const formValue = this.radarrForm.getRawValue(); - - // Create config object - const radarrConfig: RadarrConfig = { - enabled: formValue.enabled, - failedImportMaxStrikes: formValue.failedImportMaxStrikes, - instances: formValue.instances || [] - }; - - // Save the configuration - this.radarrStore.saveConfig(radarrConfig); - - // Setup a one-time check for save completion - const checkSaveCompletion = () => { - // Check if we're done saving - if (!this.radarrSaving()) { - // Re-enable the form - this.radarrForm.enable(); - - // If still disabled, update control states based on enabled state - if (!this.radarrForm.get('enabled')?.value) { - this.updateMainControlsState(false); - } - - // Update original values to match current form state - this.storeOriginalValues(); - - // Notify listeners that we've completed the save - this.saved.emit(); - - // Show success message - this.notificationService.showSuccess("Radarr configuration saved successfully"); - } else { - // If still saving, check again in a moment - setTimeout(checkSaveCompletion, 100); - } - }; - - // 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.radarrForm); - } - } - - /** - * Reset the Radarr configuration form to default values - */ - resetRadarrConfig(): void { - this.radarrForm.reset({ - enabled: false, - failedImportMaxStrikes: -1, - }); - - // Clear all instances - const instancesArray = this.radarrForm.get('instances') as FormArray; - instancesArray.clear(); - - // Update control states after reset - this.updateMainControlsState(false); - - // Mark form as dirty so the save button is enabled after reset - this.radarrForm.markAsDirty(); - this.hasActualChanges = true; - } - /** * Add a new instance to the instances form array + * @param instance Optional instance configuration to initialize the form with */ - addInstance(): void { - const instancesArray = this.radarrForm.get('instances') as FormArray; - - instancesArray.push( - this.formBuilder.group({ - id: [''], - name: ['', Validators.required], - url: ['', Validators.required], - apiKey: ['', Validators.required], - }) - ); - - this.radarrForm.markAsDirty(); - this.hasActualChanges = true; + addInstance(instance: ArrInstance | null = null): void { + const instanceForm = this.formBuilder.group({ + id: [instance?.id || ''], + name: [instance?.name || '', Validators.required], + url: [instance?.url?.toString() || '', Validators.required], + apiKey: [instance?.apiKey || '', Validators.required] + }); + + this.instances.push(instanceForm); } /** - * Remove an instance from the list + * Remove an instance at the specified index */ removeInstance(index: number): void { - const instancesArray = this.radarrForm.get('instances') as FormArray; - instancesArray.removeAt(index); + const instanceForm = this.getInstanceAsFormGroup(index); + const instanceId = instanceForm.get('id')?.value; + + // Just remove from the form array - deletion will be handled on save + this.instances.removeAt(index); + + // Mark form as dirty to enable save button this.radarrForm.markAsDirty(); - this.hasActualChanges = true; + this.hasActualChanges = this.formValuesChanged(); } /** @@ -368,4 +289,144 @@ export class RadarrSettingsComponent implements OnDestroy, CanComponentDeactivat const control = (instancesArray.controls[instanceIndex] as FormGroup).get(fieldName); return control !== null && control.hasError(errorName) && control.touched; } + + /** + * Save the Radarr configuration + */ + saveRadarrConfig(): void { + // Mark all form controls as touched to trigger validation + this.markFormGroupTouched(this.radarrForm); + + if (this.radarrForm.invalid) { + this.notificationService.showError('Please fix the validation errors before saving'); + return; + } + + if (!this.hasActualChanges) { + this.notificationService.showSuccess('No changes detected'); + return; + } + + // Get the current config to preserve existing instances + const currentConfig = this.radarrConfig(); + if (!currentConfig) return; + + // Create the updated main config + const updatedConfig: RadarrConfig = { + ...currentConfig, + enabled: this.radarrForm.get('enabled')?.value, + failedImportMaxStrikes: this.radarrForm.get('failedImportMaxStrikes')?.value + }; + + // Get the instances from the form + const formInstances = this.instances.getRawValue(); + + // Separate creates and updates + const creates: CreateArrInstanceDto[] = []; + const updates: Array<{ id: string, instance: ArrInstance }> = []; + + formInstances.forEach((instance: any) => { + if (instance.id) { + // This is an existing instance, prepare for update + const updateInstance: ArrInstance = { + id: instance.id, + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + updates.push({ id: instance.id, instance: updateInstance }); + } else { + // This is a new instance, prepare for creation (don't send ID) + const createInstance: CreateArrInstanceDto = { + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + creates.push(createInstance); + } + }); + + // Save main config first, then handle instances + this.radarrStore.saveConfig(updatedConfig); + + // Handle instance operations if there are any + if (creates.length > 0) { + creates.forEach(instance => this.radarrStore.createInstance(instance)); + } + if (updates.length > 0) { + updates.forEach(({ id, instance }) => this.radarrStore.updateInstance({ id, instance })); + } + + // Monitor the saving state to show completion feedback + this.monitorSavingCompletion(); + } + + /** + * Monitor saving completion and show appropriate feedback + */ + private monitorSavingCompletion(): void { + // Use a timeout to check the saving state periodically + const checkSavingStatus = () => { + const saving = this.radarrSaving(); + const error = this.radarrError(); + const pendingOps = this.instanceOperations(); + + if (!saving && Object.keys(pendingOps).length === 0) { + // Operations are complete + if (error) { + this.notificationService.showError(`Save completed with issues: ${error}`); + this.error.emit(error); + // Don't mark as pristine if there were errors + } else { + // Complete success + this.notificationService.showSuccess('Radarr configuration saved successfully'); + this.saved.emit(); + + // Reload config from backend to ensure UI is in sync + this.radarrStore.loadConfig(); + + // Reset form state after successful save + setTimeout(() => { + this.radarrForm.markAsPristine(); + this.hasActualChanges = false; + this.storeOriginalValues(); + }, 100); + } + } else { + // Still saving, check again in a short while + setTimeout(checkSavingStatus, 100); + } + }; + + // Start monitoring + setTimeout(checkSavingStatus, 100); + } + + /** + * Reset the Radarr configuration form to default values + */ + resetRadarrConfig(): void { + // Clear all instances + const instancesArray = this.radarrForm.get('instances') as FormArray; + instancesArray.clear(); + + // Reset main config to defaults + this.radarrForm.patchValue({ + enabled: false, + failedImportMaxStrikes: -1 + }); + + // Check if this reset actually changes anything compared to the original state + const hasChangesAfterReset = this.formValuesChanged(); + + if (hasChangesAfterReset) { + // Only mark as dirty if the reset actually changes something + this.radarrForm.markAsDirty(); + this.hasActualChanges = true; + } else { + // If reset brings us back to original state, mark as pristine + this.radarrForm.markAsPristine(); + this.hasActualChanges = false; + } + } } diff --git a/code/UI/src/app/settings/sonarr/sonarr-config.store.ts b/code/UI/src/app/settings/sonarr/sonarr-config.store.ts index 859d5576..f3737da6 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-config.store.ts +++ b/code/UI/src/app/settings/sonarr/sonarr-config.store.ts @@ -3,20 +3,23 @@ import { patchState, signalStore, withHooks, withMethods, withState } from '@ngr import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { SonarrConfig } from '../../shared/models/sonarr-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; -import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; +import { EMPTY, Observable, catchError, switchMap, tap, forkJoin, of } from 'rxjs'; +import { ArrInstance, CreateArrInstanceDto } from '../../shared/models/arr-config.model'; export interface SonarrConfigState { config: SonarrConfig | null; loading: boolean; saving: boolean; error: string | null; + instanceOperations: number; } const initialState: SonarrConfigState = { config: null, loading: false, saving: false, - error: null + error: null, + instanceOperations: 0 }; @Injectable() @@ -88,7 +91,335 @@ export class SonarrConfigStore extends signalStore( */ resetError() { patchState(store, { error: null }); - } + }, + + // ===== INSTANCE MANAGEMENT ===== + + /** + * Create a new Sonarr instance + */ + createInstance: rxMethod( + (instance$: Observable) => instance$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(instance => configService.createSonarrInstance(instance).pipe( + tap({ + next: (newInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + patchState(store, { + config: { ...currentConfig, instances: [...currentConfig.instances, newInstance] }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || 'Failed to create Sonarr instance' + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Update a Sonarr instance by ID + */ + updateInstance: rxMethod<{ id: string, instance: CreateArrInstanceDto }>( + (params$: Observable<{ id: string, instance: CreateArrInstanceDto }>) => params$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(({ id, instance }) => configService.updateSonarrInstance(id, instance).pipe( + tap({ + next: (updatedInstance) => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.map((inst: ArrInstance) => + inst.id === id ? updatedInstance : inst + ); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to update Sonarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Delete a Sonarr instance by ID + */ + deleteInstance: rxMethod( + (id$: Observable) => id$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: store.instanceOperations() + 1 })), + switchMap(id => configService.deleteSonarrInstance(id).pipe( + tap({ + next: () => { + const currentConfig = store.config(); + if (currentConfig) { + const updatedInstances = currentConfig.instances.filter((inst: ArrInstance) => inst.id !== id); + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: store.instanceOperations() - 1 + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: store.instanceOperations() - 1, + error: error.message || `Failed to delete Sonarr instance with ID ${id}` + }); + } + }), + catchError(() => EMPTY) + )) + ) + ), + + /** + * Batch create multiple instances + */ + createInstances: rxMethod( + (instances$: Observable) => instances$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: 0 })), + switchMap(instances => { + if (instances.length === 0) { + patchState(store, { saving: false }); + return EMPTY; + } + + patchState(store, { instanceOperations: instances.length }); + + const createOperations = instances.map(instance => + configService.createSonarrInstance(instance).pipe( + catchError(error => { + console.error('Failed to create Sonarr instance:', error); + return of(null); + }) + ) + ); + + return forkJoin(createOperations).pipe( + tap({ + next: (results) => { + const currentConfig = store.config(); + if (currentConfig) { + const successfulInstances = results.filter(instance => instance !== null) as ArrInstance[]; + const updatedInstances = [...currentConfig.instances, ...successfulInstances]; + + const failedCount = results.filter(instance => instance === null).length; + + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: 0, + error: failedCount > 0 ? `${failedCount} instance(s) failed to create` : null + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: 0, + error: error.message || 'Failed to create instances' + }); + } + }) + ); + }) + ) + ), + + /** + * Batch update multiple instances + */ + updateInstances: rxMethod>( + (updates$: Observable>) => updates$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: 0 })), + switchMap(updates => { + if (updates.length === 0) { + patchState(store, { saving: false }); + return EMPTY; + } + + patchState(store, { instanceOperations: updates.length }); + + const updateOperations = updates.map(({ id, instance }) => + configService.updateSonarrInstance(id, instance).pipe( + catchError(error => { + console.error('Failed to update Sonarr instance:', error); + return of(null); + }) + ) + ); + + return forkJoin(updateOperations).pipe( + tap({ + next: (results) => { + const currentConfig = store.config(); + if (currentConfig) { + let updatedInstances = [...currentConfig.instances]; + let failedCount = 0; + + results.forEach((result, index) => { + if (result !== null) { + const instanceIndex = updatedInstances.findIndex(inst => inst.id === updates[index].id); + if (instanceIndex !== -1) { + updatedInstances[instanceIndex] = result; + } + } else { + failedCount++; + } + }); + + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: 0, + error: failedCount > 0 ? `${failedCount} instance(s) failed to update` : null + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: 0, + error: error.message || 'Failed to update instances' + }); + } + }) + ); + }) + ) + ), + + /** + * Process mixed operations (creates, updates, deletes) in batch + */ + processBatchInstanceOperations: rxMethod<{ + creates: CreateArrInstanceDto[], + updates: Array<{ id: string, instance: CreateArrInstanceDto }>, + deletes: string[] + }>( + (operations$: Observable<{ + creates: CreateArrInstanceDto[], + updates: Array<{ id: string, instance: CreateArrInstanceDto }>, + deletes: string[] + }>) => operations$.pipe( + tap(() => patchState(store, { saving: true, error: null, instanceOperations: 0 })), + switchMap(({ creates, updates, deletes }) => { + const totalOperations = creates.length + updates.length + deletes.length; + + if (totalOperations === 0) { + patchState(store, { saving: false }); + return EMPTY; + } + + patchState(store, { instanceOperations: totalOperations }); + + // Prepare all operations + const createOps = creates.map(instance => + configService.createSonarrInstance(instance).pipe( + catchError(error => { + console.error('Failed to create Sonarr instance:', error); + return of(null); + }) + ) + ); + + const updateOps = updates.map(({ id, instance }) => + configService.updateSonarrInstance(id, instance).pipe( + catchError(error => { + console.error('Failed to update Sonarr instance:', error); + return of(null); + }) + ) + ); + + const deleteOps = deletes.map(id => + configService.deleteSonarrInstance(id).pipe( + catchError(error => { + console.error('Failed to delete Sonarr instance:', error); + return of(null); + }) + ) + ); + + // Execute all operations in parallel + return forkJoin([...createOps, ...updateOps, ...deleteOps]).pipe( + tap({ + next: (results) => { + const currentConfig = store.config(); + if (currentConfig) { + let updatedInstances = [...currentConfig.instances]; + let failedCount = 0; + + // Process create results + const createResults = results.slice(0, creates.length); + const successfulCreates = createResults.filter(instance => instance !== null) as ArrInstance[]; + updatedInstances = [...updatedInstances, ...successfulCreates]; + failedCount += createResults.filter(instance => instance === null).length; + + // Process update results + const updateResults = results.slice(creates.length, creates.length + updates.length); + updateResults.forEach((result, index) => { + if (result !== null) { + const instanceIndex = updatedInstances.findIndex(inst => inst.id === updates[index].id); + if (instanceIndex !== -1) { + updatedInstances[instanceIndex] = result as ArrInstance; + } + } else { + failedCount++; + } + }); + + // Process delete results (successful deletes are already handled by removing from array) + const deleteResults = results.slice(creates.length + updates.length); + deleteResults.forEach((result, index) => { + if (result !== null) { + // Delete was successful, remove from array + updatedInstances = updatedInstances.filter(inst => inst.id !== deletes[index]); + } else { + failedCount++; + } + }); + + patchState(store, { + config: { ...currentConfig, instances: updatedInstances }, + saving: false, + instanceOperations: 0, + error: failedCount > 0 ? `${failedCount} operation(s) failed` : null + }); + } + }, + error: (error) => { + patchState(store, { + saving: false, + instanceOperations: 0, + error: error.message || 'Failed to process operations' + }); + } + }) + ); + }) + ) + ) })), withHooks({ onInit({ loadConfig }) { diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.html b/code/UI/src/app/settings/sonarr/sonarr-settings.component.html index ae3ef975..75e66d1b 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.html +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.html @@ -89,7 +89,7 @@ + (click)="removeInstance(i)" [disabled]="sonarrForm.disabled"> Name is required diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts b/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts index 077a8e4f..0c5841be 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts @@ -5,6 +5,7 @@ import { Subject, takeUntil } from "rxjs"; import { SonarrConfigStore } from "./sonarr-config.store"; import { CanComponentDeactivate } from "../../core/guards"; import { SonarrConfig, SonarrSearchType } from "../../shared/models/sonarr-config.model"; +import { CreateArrInstanceDto, ArrInstance } from "../../shared/models/arr-config.model"; // PrimeNG Components import { CardModule } from "primeng/card"; @@ -72,6 +73,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat sonarrLoading = this.sonarrStore.loading; sonarrError = this.sonarrStore.error; sonarrSaving = this.sonarrStore.saving; + instanceOperations = this.sonarrStore.instanceOperations; /** * Check if component can be deactivated (navigation guard) @@ -212,117 +214,34 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } } - /** - * Save the Sonarr configuration - */ - saveSonarrConfig(): void { - if (this.sonarrForm.valid) { - // Mark form as saving - this.sonarrForm.disable(); - - // Get data from form - const formValue = this.sonarrForm.getRawValue(); - - // Create config object - const sonarrConfig: SonarrConfig = { - enabled: formValue.enabled, - failedImportMaxStrikes: formValue.failedImportMaxStrikes, - searchType: formValue.searchType, - instances: formValue.instances || [] - }; - - // Save the configuration - this.sonarrStore.saveConfig(sonarrConfig); - - // Setup a one-time check for save completion - const checkSaveCompletion = () => { - // Check if we're done saving - if (!this.sonarrSaving()) { - // Re-enable the form - this.sonarrForm.enable(); - - // If still disabled, update control states based on enabled state - if (!this.sonarrForm.get('enabled')?.value) { - this.updateMainControlsState(false); - } - - // Update original values to match current form state - this.storeOriginalValues(); - - // Notify listeners that we've completed the save - this.saved.emit(); - - // Show success message - this.notificationService.showSuccess("Sonarr configuration saved successfully"); - } else { - // If still saving, check again in a moment - setTimeout(checkSaveCompletion, 100); - } - }; - - // 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.sonarrForm); - } - } - - /** - * Reset the Sonarr configuration form to default values - */ - resetSonarrConfig(): void { - this.sonarrForm.reset({ - enabled: false, - failedImportMaxStrikes: -1, - searchType: SonarrSearchType.Episode, - }); - - // Clear all instances - const instancesArray = this.sonarrForm.get('instances') as FormArray; - instancesArray.clear(); - - // Update control states after reset - this.updateMainControlsState(false); - - // Mark form as dirty so the save button is enabled after reset - this.sonarrForm.markAsDirty(); - this.hasActualChanges = true; - } - /** * Add a new instance to the instances form array + * @param instance Optional instance configuration to initialize the form with */ - addInstance(): void { - const instancesArray = this.sonarrForm.get('instances') as FormArray; - - instancesArray.push( - this.formBuilder.group({ - id: [''], - name: ['', Validators.required], - url: ['', Validators.required], - apiKey: ['', Validators.required], - }) - ); - - this.sonarrForm.markAsDirty(); - this.hasActualChanges = true; + addInstance(instance: ArrInstance | null = null): void { + const instanceForm = this.formBuilder.group({ + id: [instance?.id || ''], + name: [instance?.name || '', Validators.required], + url: [instance?.url?.toString() || '', Validators.required], + apiKey: [instance?.apiKey || '', Validators.required] + }); + + this.instances.push(instanceForm); } /** - * Remove an instance from the list + * Remove an instance at the specified index */ removeInstance(index: number): void { - const instancesArray = this.sonarrForm.get('instances') as FormArray; - instancesArray.removeAt(index); + const instanceForm = this.getInstanceAsFormGroup(index); + const instanceId = instanceForm.get('id')?.value; + + // Just remove from the form array - deletion will be handled on save + this.instances.removeAt(index); + + // Mark form as dirty to enable save button this.sonarrForm.markAsDirty(); - this.hasActualChanges = true; + this.hasActualChanges = this.formValuesChanged(); } /** @@ -379,4 +298,143 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat const control = (instancesArray.controls[instanceIndex] as FormGroup).get(fieldName); return control !== null && control.hasError(errorName) && control.touched; } + + /** + * Save the Sonarr configuration + */ + saveSonarrConfig(): void { + // Mark all form controls as touched to trigger validation + this.markFormGroupTouched(this.sonarrForm); + + if (this.sonarrForm.invalid) { + this.notificationService.showError('Please fix the validation errors before saving'); + return; + } + + if (!this.hasActualChanges) { + this.notificationService.showSuccess('No changes detected'); + return; + } + + // Get the current config to preserve existing instances + const currentConfig = this.sonarrConfig(); + if (!currentConfig) return; + + // Create the updated main config + const updatedConfig: SonarrConfig = { + ...currentConfig, + enabled: this.sonarrForm.get('enabled')?.value, + failedImportMaxStrikes: this.sonarrForm.get('failedImportMaxStrikes')?.value, + searchType: this.sonarrForm.get('searchType')?.value + }; + + // Get the instances from the form + const formInstances = this.instances.getRawValue(); + + // Separate creates and updates + const creates: CreateArrInstanceDto[] = []; + const updates: Array<{ id: string, instance: ArrInstance }> = []; + + formInstances.forEach((instance: any) => { + if (instance.id) { + // This is an existing instance, prepare for update + const updateInstance: ArrInstance = { + id: instance.id, + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + updates.push({ id: instance.id, instance: updateInstance }); + } else { + // This is a new instance, prepare for creation (don't send ID) + const createInstance: CreateArrInstanceDto = { + name: instance.name, + url: instance.url, + apiKey: instance.apiKey + }; + creates.push(createInstance); + } + }); + + // Save main config first, then handle instances + this.sonarrStore.saveConfig(updatedConfig); + + // Handle instance operations if there are any + if (creates.length > 0 || updates.length > 0) { + this.sonarrStore.processBatchInstanceOperations({ creates, updates, deletes: [] }); + } + + // Monitor the saving state to show completion feedback + this.monitorSavingCompletion(); + } + + /** + * Monitor saving completion and show appropriate feedback + */ + private monitorSavingCompletion(): void { + // Use a timeout to check the saving state periodically + const checkSavingStatus = () => { + const saving = this.sonarrSaving(); + const error = this.sonarrError(); + const pendingOps = this.instanceOperations(); + + if (!saving && Object.keys(pendingOps).length === 0) { + // Operations are complete + if (error) { + this.notificationService.showError(`Save completed with issues: ${error}`); + this.error.emit(error); + // Don't mark as pristine if there were errors + } else { + // Complete success + this.notificationService.showSuccess('Sonarr configuration saved successfully'); + this.saved.emit(); + + // Reload config from backend to ensure UI is in sync + this.sonarrStore.loadConfig(); + + // Reset form state after successful save + setTimeout(() => { + this.sonarrForm.markAsPristine(); + this.hasActualChanges = false; + this.storeOriginalValues(); + }, 100); + } + } else { + // Still saving, check again in a short while + setTimeout(checkSavingStatus, 100); + } + }; + + // Start monitoring + setTimeout(checkSavingStatus, 100); + } + + /** + * Reset the Sonarr configuration form to default values + */ + resetSonarrConfig(): void { + // Clear all instances + const instancesArray = this.sonarrForm.get('instances') as FormArray; + instancesArray.clear(); + + // Reset main config to defaults + this.sonarrForm.patchValue({ + enabled: false, + failedImportMaxStrikes: -1, + searchType: SonarrSearchType.Episode + }); + + // Check if this reset actually changes anything compared to the original state + const hasChangesAfterReset = this.formValuesChanged(); + + if (hasChangesAfterReset) { + // Only mark as dirty if the reset actually changes something + this.sonarrForm.markAsDirty(); + this.hasActualChanges = true; + } else { + // If reset brings us back to original state, mark as pristine + this.sonarrForm.markAsPristine(); + this.hasActualChanges = false; + } + } } diff --git a/code/UI/src/app/shared/models/arr-config.model.ts b/code/UI/src/app/shared/models/arr-config.model.ts index 028ee09d..46af7675 100644 --- a/code/UI/src/app/shared/models/arr-config.model.ts +++ b/code/UI/src/app/shared/models/arr-config.model.ts @@ -7,3 +7,12 @@ export interface ArrInstance { url: string; apiKey: string; } + +/** + * DTO for creating new Arr instances without requiring an ID + */ +export interface CreateArrInstanceDto { + name: string; + url: string; + apiKey: string; +}