From 00dfd637972d45d4fd073e58722cfac35fda71d7 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 15 Jun 2025 22:21:22 +0300 Subject: [PATCH] try fix sonarr --- code/Data/DataContext.cs | 9 + ...=> 20250615190608_InitialData.Designer.cs} | 2 +- ...lData.cs => 20250615190608_InitialData.cs} | 2 +- .../Models/Configuration/Arr/ArrInstance.cs | 2 +- .../Controllers/ConfigurationController.cs | 25 +- code/Executable/DTOs/ArrConfigDto.cs | 16 + .../core/services/configuration.service.ts | 6 +- .../settings/sonarr/sonarr-config.store.ts | 20 +- .../sonarr/sonarr-settings.component.html | 348 +++++++++----- .../sonarr/sonarr-settings.component.scss | 281 ++++++++++- .../sonarr/sonarr-settings.component.ts | 437 ++++++++---------- 11 files changed, 764 insertions(+), 384 deletions(-) rename code/Data/Migrations/Data/{20250615170420_InitialData.Designer.cs => 20250615190608_InitialData.Designer.cs} (99%) rename code/Data/Migrations/Data/{20250615170420_InitialData.cs => 20250615190608_InitialData.cs} (99%) create mode 100644 code/Executable/DTOs/ArrConfigDto.cs diff --git a/code/Data/DataContext.cs b/code/Data/DataContext.cs index 22133eba..1ddff922 100644 --- a/code/Data/DataContext.cs +++ b/code/Data/DataContext.cs @@ -58,6 +58,15 @@ public class DataContext : DbContext entity.ComplexProperty(e => e.ContentBlocker); }); + // Configure ArrConfig -> ArrInstance relationship + modelBuilder.Entity(entity => + { + entity.HasMany(a => a.Instances) + .WithOne(i => i.ArrConfig) + .HasForeignKey(i => i.ArrConfigId) + .OnDelete(DeleteBehavior.Cascade); + }); + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { var enumProperties = entityType.ClrType.GetProperties() diff --git a/code/Data/Migrations/Data/20250615170420_InitialData.Designer.cs b/code/Data/Migrations/Data/20250615190608_InitialData.Designer.cs similarity index 99% rename from code/Data/Migrations/Data/20250615170420_InitialData.Designer.cs rename to code/Data/Migrations/Data/20250615190608_InitialData.Designer.cs index 7c49ae6d..526857ad 100644 --- a/code/Data/Migrations/Data/20250615170420_InitialData.Designer.cs +++ b/code/Data/Migrations/Data/20250615190608_InitialData.Designer.cs @@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Data.Migrations.Data { [DbContext(typeof(DataContext))] - [Migration("20250615170420_InitialData")] + [Migration("20250615190608_InitialData")] partial class InitialData { /// diff --git a/code/Data/Migrations/Data/20250615170420_InitialData.cs b/code/Data/Migrations/Data/20250615190608_InitialData.cs similarity index 99% rename from code/Data/Migrations/Data/20250615170420_InitialData.cs rename to code/Data/Migrations/Data/20250615190608_InitialData.cs index 7ceaeae5..cd192be3 100644 --- a/code/Data/Migrations/Data/20250615170420_InitialData.cs +++ b/code/Data/Migrations/Data/20250615190608_InitialData.cs @@ -202,7 +202,7 @@ namespace Data.Migrations.Data principalTable: "download_cleaner_configs", principalColumn: "id"); }); - + migrationBuilder.InsertData( table: "queue_cleaner_configs", columns: new[] diff --git a/code/Data/Models/Configuration/Arr/ArrInstance.cs b/code/Data/Models/Configuration/Arr/ArrInstance.cs index d7659de4..c76c8f65 100644 --- a/code/Data/Models/Configuration/Arr/ArrInstance.cs +++ b/code/Data/Models/Configuration/Arr/ArrInstance.cs @@ -12,7 +12,7 @@ public sealed class ArrInstance public Guid ArrConfigId { get; set; } - public ArrConfig ArrConfig { get; set; } + public ArrConfig? ArrConfig { get; set; } public required string Name { get; set; } diff --git a/code/Executable/Controllers/ConfigurationController.cs b/code/Executable/Controllers/ConfigurationController.cs index 0c953818..2adab0e3 100644 --- a/code/Executable/Controllers/ConfigurationController.cs +++ b/code/Executable/Controllers/ConfigurationController.cs @@ -228,9 +228,10 @@ public class ConfigurationController : ControllerBase try { var config = await _dataContext.ArrConfigs + .Include(x => x.Instances) .AsNoTracking() .FirstAsync(x => x.Type == InstanceType.Sonarr); - return Ok(config); + return Ok(config.Adapt()); } finally { @@ -577,7 +578,6 @@ public class ConfigurationController : ControllerBase { // Get the Sonarr config to add the instance to var config = await _dataContext.ArrConfigs - .Include(c => c.Instances) .FirstAsync(x => x.Type == InstanceType.Sonarr); // Create the new instance @@ -585,14 +585,17 @@ public class ConfigurationController : ControllerBase { Name = newInstance.Name, Url = new Uri(newInstance.Url), - ApiKey = newInstance.ApiKey + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, }; - // Add to the config - config.Instances.Add(instance); + // Add to the config's instances collection + // config.Instances.Add(instance); + await _dataContext.ArrInstances.AddAsync(instance); + // Save changes await _dataContext.SaveChangesAsync(); - return CreatedAtAction(nameof(GetSonarrConfig), new { id = instance.Id }, instance); + return CreatedAtAction(nameof(GetSonarrConfig), new { id = instance.Id }, instance.Adapt()); } catch (Exception ex) { @@ -629,7 +632,7 @@ public class ConfigurationController : ControllerBase await _dataContext.SaveChangesAsync(); - return Ok(instance); + return Ok(instance.Adapt()); } catch (Exception ex) { @@ -692,7 +695,9 @@ public class ConfigurationController : ControllerBase { Name = newInstance.Name, Url = new Uri(newInstance.Url), - ApiKey = newInstance.ApiKey + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, + ArrConfig = config // Set the navigation property }; // Add to the config @@ -799,7 +804,9 @@ public class ConfigurationController : ControllerBase { Name = newInstance.Name, Url = new Uri(newInstance.Url), - ApiKey = newInstance.ApiKey + ApiKey = newInstance.ApiKey, + ArrConfigId = config.Id, + ArrConfig = config // Set the navigation property }; // Add to the config diff --git a/code/Executable/DTOs/ArrConfigDto.cs b/code/Executable/DTOs/ArrConfigDto.cs new file mode 100644 index 00000000..9a79bd32 --- /dev/null +++ b/code/Executable/DTOs/ArrConfigDto.cs @@ -0,0 +1,16 @@ +using Data.Enums; + +namespace Executable.DTOs; + +public class ArrConfigDto +{ + public Guid Id { get; set; } + + public required InstanceType Type { get; set; } + + public bool Enabled { get; set; } + + public short FailedImportMaxStrikes { get; set; } = -1; + + public List Instances { get; set; } = []; +} \ 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 56f8f5f9..228222ab 100644 --- a/code/UI/src/app/core/services/configuration.service.ts +++ b/code/UI/src/app/core/services/configuration.service.ts @@ -133,10 +133,10 @@ export class ConfigurationService { ); } /** - * Update Sonarr configuration + * Update Sonarr configuration (global settings only) */ - updateSonarrConfig(config: SonarrConfig): Observable { - return this.http.put(`${this.apiUrl}/api/configuration/sonarr`, config).pipe( + updateSonarrConfig(config: {enabled: boolean, failedImportMaxStrikes: number}): Observable { + return this.http.put(`${this.apiUrl}/api/configuration/sonarr`, config).pipe( catchError((error) => { console.error("Error updating Sonarr config:", error); return throwError(() => new Error(error.error?.error || "Failed to update Sonarr configuration")); 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 ccf9e931..e8b11941 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-config.store.ts +++ b/code/UI/src/app/settings/sonarr/sonarr-config.store.ts @@ -49,18 +49,22 @@ export class SonarrConfigStore extends signalStore( ), /** - * Save the Sonarr configuration + * Save the Sonarr global configuration */ - saveConfig: rxMethod( - (config$: Observable) => config$.pipe( + saveConfig: rxMethod<{enabled: boolean, failedImportMaxStrikes: number}>( + (globalConfig$: Observable<{enabled: boolean, failedImportMaxStrikes: number}>) => globalConfig$.pipe( tap(() => patchState(store, { saving: true, error: null })), - switchMap(config => configService.updateSonarrConfig(config).pipe( + switchMap(globalConfig => configService.updateSonarrConfig(globalConfig).pipe( tap({ next: () => { - patchState(store, { - config, - saving: false - }); + const currentConfig = store.config(); + if (currentConfig) { + // Update the local config with the new global settings + patchState(store, { + config: { ...currentConfig, ...globalConfig }, + saving: false + }); + } }, error: (error) => { patchState(store, { 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 ad50a3aa..606cf680 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.html +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.html @@ -1,129 +1,229 @@ - - -
-
-

Sonarr Configuration

- Configure Sonarr integration settings +
+ + + + +
+ + + + +
+
+

Global Settings

+ Configure general Sonarr integration settings +
+ +
+
+ +
+
+ +
+ + When enabled, Sonarr API integration will be used +
+
+ +
+ +
+
+ +
+ Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable) +
+
+ + + +
+
+ + + + +
+
+

Sonarr Instances

+ Manage multiple Sonarr server instances +
+ +
+
+ + +
+
+ +

Enable Sonarr integration to manage instances

+
-
- + + +
+ +

No Sonarr instances configured

+ Add an instance to start using Sonarr integration
+ + +
+
+
+
+ + {{ instance.name }} +
+
+ + +
+
+ +
+
+ + {{ instance.url }} +
+ +
+ + {{ instance.apiKey | slice:0:8 }}... +
+
+
+
+ +
+
+ + + +
+
+ + + Name is required +
+ +
+ + + URL is required + URL must be a valid URL + URL must use http or https protocol +
+ +
+ + + API key is required +
+
+ + + +
-
- - - - -
- -
- -
- - When enabled, Sonarr API integration will be used -
-
- -
- -
-
- -
- Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable) -
-
- - -
-

Sonarr Instances

- Configure multiple Sonarr server instances -
- - -
- -
-

No Sonarr instances defined. Add an instance to start using Sonarr integration.

-
- - -
-
-
-
- - -
- -
- Name is required - -
-
- -
- - URL is required - URL must be a valid URL - URL must use http or https protocol -
-
- -
- -
- - API key is required -
-
-
-
-
- -
- -
-
- - - -
-
- + + diff --git a/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss b/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss index 486cf16c..467a291b 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss +++ b/code/UI/src/app/settings/sonarr/sonarr-settings.component.scss @@ -1,4 +1,283 @@ /* Sonarr Settings Styles */ @import '../styles/settings-shared.scss'; -@import '../styles/arr-shared.scss'; \ No newline at end of file +@import '../styles/arr-shared.scss'; + +.sonarr-settings-container { + display: flex; + flex-direction: column; + gap: 1.5rem; + height: 100%; +} + +.cards-container { + display: flex; + flex-direction: column; + gap: 1.5rem; + flex: 1; +} + +// Card styling +.settings-card { + display: flex; + flex-direction: column; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--surface-border); + + .header-title-container { + .card-title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-color); + } + + .card-subtitle { + font-size: 0.875rem; + color: var(--text-color-secondary); + } + } +} + +// Form styling +.field-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; + + .field-label { + font-weight: 500; + color: var(--text-color); + margin-bottom: 0.25rem; + } + + .field-input { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .form-helper-text { + color: var(--text-color-secondary); + font-size: 0.875rem; + } +} + +.card-footer { + display: flex; + justify-content: flex-start; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--surface-border); +} + +// Instance management +.disabled-overlay { + position: relative; + background: var(--surface-100); + border-radius: 6px; + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + + .disabled-message { + text-align: center; + color: var(--text-color-secondary); + + i { + font-size: 2rem; + margin-bottom: 0.5rem; + display: block; + } + + p { + margin: 0; + font-weight: 500; + } + } +} + +.empty-instances-message { + text-align: center; + color: var(--text-color-secondary); + min-height: 120px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .empty-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; + } + + p { + margin: 0 0 0.25rem 0; + font-weight: 500; + font-size: 1rem; + } + + small { + font-size: 0.875rem; + opacity: 0.8; + } +} + +.instances-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.instance-item { + background: var(--surface-50); + border: 1px solid var(--surface-border); + border-radius: 6px; + padding: 1rem; + transition: all 0.2s ease; + + &:hover { + background: var(--surface-100); + border-color: var(--primary-color); + } + + .instance-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + + .instance-title { + display: flex; + align-items: center; + gap: 0.5rem; + + .instance-icon { + color: var(--primary-color); + font-size: 1.25rem; + } + + .instance-name { + font-weight: 600; + color: var(--text-color); + font-size: 1rem; + } + } + + .instance-actions { + display: flex; + gap: 0.25rem; + } + } + + .instance-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + + .instance-field { + display: flex; + flex-direction: column; + gap: 0.25rem; + + label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-color-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .field-value { + color: var(--text-color); + font-family: var(--font-family); + font-size: 0.9rem; + word-break: break-all; + + &.api-key { + font-family: 'Courier New', monospace; + font-size: 0.875rem; + background: var(--surface-100); + padding: 0.25rem 0.5rem; + border-radius: 4px; + display: inline-block; + } + } + } + } +} + +// Modal styling +:host ::ng-deep .instance-modal { + width: 90vw; + max-width: 500px; + + .p-dialog-content { + padding: 0 !important; + } + + .instance-form { + padding: 1.5rem; + + .field { + margin-bottom: 1.5rem; + + label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-color); + } + + input { + width: 100%; + } + + .p-error { + display: block; + margin-top: 0.25rem; + font-size: 0.875rem; + } + } + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--surface-border); + background: var(--surface-50); + } +} + +// Responsive design +@media (max-width: 768px) { + .cards-container { + gap: 1rem; + } + + .instance-item .instance-content { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .card-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + + .header-title-container .card-title { + font-size: 1rem; + } + } +} \ No newline at end of file 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 4cb5d71f..b7596677 100644 --- a/code/UI/src/app/settings/sonarr/sonarr-settings.component.ts +++ b/code/UI/src/app/settings/sonarr/sonarr-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 { FormBuilder, FormGroup, ReactiveFormsModule, Validators, AbstractControl, ValidationErrors } from "@angular/forms"; import { Subject, takeUntil } from "rxjs"; import { SonarrConfigStore } from "./sonarr-config.store"; import { CanComponentDeactivate } from "../../core/guards"; @@ -13,10 +13,11 @@ import { InputTextModule } from "primeng/inputtext"; import { CheckboxModule } from "primeng/checkbox"; import { ButtonModule } from "primeng/button"; import { InputNumberModule } from "primeng/inputnumber"; -import { SelectButtonModule } from "primeng/selectbutton"; import { ToastModule } from "primeng/toast"; +import { DialogModule } from "primeng/dialog"; +import { ConfirmDialogModule } from "primeng/confirmdialog"; +import { ConfirmationService } from "primeng/api"; import { NotificationService } from "../../core/services/notification.service"; -import { SelectModule } from 'primeng/select'; import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component"; @Component({ @@ -30,12 +31,12 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro CheckboxModule, ButtonModule, InputNumberModule, - SelectButtonModule, ToastModule, + DialogModule, + ConfirmDialogModule, LoadingErrorStateComponent, - SelectModule ], - providers: [SonarrConfigStore], + providers: [SonarrConfigStore, ConfirmationService], templateUrl: "./sonarr-settings.component.html", styleUrls: ["./sonarr-settings.component.scss"], }) @@ -43,22 +44,26 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat @Output() saved = new EventEmitter(); @Output() error = new EventEmitter(); - // Sonarr Configuration Form - sonarrForm: FormGroup; + // Forms + globalForm: FormGroup; + instanceForm: FormGroup; + + // Modal state + showInstanceModal = false; + modalMode: 'add' | 'edit' = 'add'; + editingInstance: ArrInstance | null = null; // Original form values for tracking changes - private originalFormValues: any; - - // Track whether the form has actual changes compared to original values - hasActualChanges = false; + private originalGlobalValues: any; + hasGlobalChanges = false; // Clean up subscriptions private destroy$ = new Subject(); - // Inject the necessary services + // Services private formBuilder = inject(FormBuilder); - // Using the notification service for all toast messages private notificationService = inject(NotificationService); + private confirmationService = inject(ConfirmationService); private sonarrStore = inject(SonarrConfigStore); // Signals from store @@ -66,24 +71,26 @@ 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) */ canDeactivate(): boolean { - return !this.sonarrForm?.dirty || !this.hasActualChanges; + return !this.globalForm?.dirty || !this.hasGlobalChanges; } constructor() { - // Initialize the main form - this.sonarrForm = this.formBuilder.group({ + // Initialize forms + this.globalForm = this.formBuilder.group({ enabled: [false], failedImportMaxStrikes: [-1], }); - // Add instances FormArray to main form - this.sonarrForm.addControl('instances', this.formBuilder.array([])); + this.instanceForm = this.formBuilder.group({ + name: ['', Validators.required], + url: ['', [Validators.required, this.uriValidator.bind(this)]], + apiKey: ['', Validators.required], + }); // Load Sonarr config data this.sonarrStore.loadConfig(); @@ -92,15 +99,15 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat effect(() => { const config = this.sonarrConfig(); if (config) { - this.updateFormFromConfig(config); + this.updateGlobalFormFromConfig(config); } }); - // Track form changes for dirty state - this.sonarrForm.valueChanges + // Track global form changes + this.globalForm.valueChanges .pipe(takeUntil(this.destroy$)) .subscribe(() => { - this.hasActualChanges = this.formValuesChanged(); + this.hasGlobalChanges = this.globalFormValuesChanged(); }); } @@ -113,51 +120,32 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } /** - * Update form with values from the configuration + * Update global form with values from the configuration */ - private updateFormFromConfig(config: SonarrConfig): void { - // Update main form controls - this.sonarrForm.patchValue({ + private updateGlobalFormFromConfig(config: SonarrConfig): void { + this.globalForm.patchValue({ enabled: config.enabled, failedImportMaxStrikes: config.failedImportMaxStrikes, }); - // Clear and rebuild the instances form array - const instancesArray = this.sonarrForm.get('instances') as FormArray; - instancesArray.clear(); - - // Add all instances to the form array - if (config.instances && config.instances.length > 0) { - config.instances.forEach(instance => { - instancesArray.push( - this.formBuilder.group({ - id: [instance.id || ''], - name: [instance.name, Validators.required], - url: [instance.url, [Validators.required, this.uriValidator.bind(this)]], - apiKey: [instance.apiKey, Validators.required], - }) - ); - }); - } - - // Store original form values for dirty checking - this.storeOriginalValues(); + // Store original values for dirty checking + this.storeOriginalGlobalValues(); } /** - * Store original form values for dirty checking + * Store original global form values for dirty checking */ - private storeOriginalValues(): void { - this.originalFormValues = JSON.parse(JSON.stringify(this.sonarrForm.value)); - this.sonarrForm.markAsPristine(); - this.hasActualChanges = false; + private storeOriginalGlobalValues(): void { + this.originalGlobalValues = JSON.parse(JSON.stringify(this.globalForm.value)); + this.globalForm.markAsPristine(); + this.hasGlobalChanges = false; } /** - * Check if the current form values are different from the original values + * Check if the current global form values are different from the original values */ - private formValuesChanged(): boolean { - return !this.isEqual(this.sonarrForm.value, this.originalFormValues); + private globalFormValuesChanged(): boolean { + return !this.isEqual(this.globalForm.value, this.originalGlobalValues); } /** @@ -188,90 +176,6 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat return true; } - /** - * Update form control disabled states based on the configuration - */ - private updateFormControlDisabledStates(config: SonarrConfig): void { - const enabled = config.enabled; - this.updateMainControlsState(enabled); - } - - /** - * Update the state of main controls based on the 'enabled' control value - */ - private updateMainControlsState(enabled: boolean): void { - const failedImportMaxStrikesControl = this.sonarrForm.get('failedImportMaxStrikes'); - - if (enabled) { - failedImportMaxStrikesControl?.enable(); - } else { - failedImportMaxStrikesControl?.disable(); - } - } - - /** - * Add a new instance to the instances form array - * @param instance Optional instance configuration to initialize the form with - */ - 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, this.uriValidator.bind(this)]], - apiKey: [instance?.apiKey || '', Validators.required] - }); - - this.instances.push(instanceForm); - - // Mark form as dirty to enable save button - this.sonarrForm.markAsDirty(); - this.hasActualChanges = this.formValuesChanged(); - } - - /** - * Remove an instance at the specified index - */ - removeInstance(index: number): void { - 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 = this.formValuesChanged(); - } - - /** - * Get the instances form array - */ - get instances(): FormArray { - return this.sonarrForm.get('instances') as FormArray; - } - - /** - * Get an instance at the specified index as a FormGroup - */ - getInstanceAsFormGroup(index: number): FormGroup { - return this.instances.at(index) as FormGroup; - } - - /** - * Check if an instance field has an error - * @param instanceIndex The index of the instance in the array - * @param fieldName The name of the field to check - * @param errorName The name of the error to check for - * @returns True if the field has the specified error - */ - hasInstanceFieldError(instanceIndex: number, fieldName: string, errorName: string): boolean { - const instancesArray = this.sonarrForm.get('instances') as FormArray; - if (!instancesArray || !instancesArray.controls[instanceIndex]) return false; - - const control = (instancesArray.controls[instanceIndex] as FormGroup).get(fieldName); - return control !== null && control.hasError(errorName) && control.touched; - } - /** * Custom validator to check if the input is a valid URI */ @@ -308,148 +212,209 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat } /** - * Check if the form control has an error - * @param controlName The name of the control to check - * @param errorName The name of the error to check for - * @returns True if the control has the specified error + * Check if a form control has an error */ - hasError(controlName: string, errorName: string): boolean { - const control = this.sonarrForm.get(controlName); + hasError(form: FormGroup, controlName: string, errorName: string): boolean { + const control = form.get(controlName); return control !== null && control.hasError(errorName) && control.touched; } /** - * Save the Sonarr configuration + * Save the global Sonarr configuration */ - saveSonarrConfig(): void { - // Mark all form controls as touched to trigger validation - this.markFormGroupTouched(this.sonarrForm); + saveGlobalConfig(): void { + this.markFormGroupTouched(this.globalForm); - if (this.sonarrForm.invalid) { + if (this.globalForm.invalid) { this.notificationService.showError('Please fix the validation errors before saving'); return; } - if (!this.hasActualChanges) { + if (!this.hasGlobalChanges) { 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 + const updatedConfig = { + enabled: this.globalForm.get('enabled')?.value, + failedImportMaxStrikes: this.globalForm.get('failedImportMaxStrikes')?.value }; - // Get the instances from the form - const formInstances = this.instances.getRawValue(); + this.sonarrStore.saveConfig(updatedConfig); - // 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); - } - }); - - // Use the new method that saves config and processes instances sequentially - this.sonarrStore.saveConfigAndInstances({ - config: updatedConfig, - instanceOperations: { creates, updates, deletes: [] } - }); - - // Monitor the saving state to show completion feedback - this.monitorSavingCompletion(); + // Monitor saving completion + this.monitorGlobalSaving(); } /** - * Monitor saving completion and show appropriate feedback + * Monitor global saving completion */ - private monitorSavingCompletion(): void { - // Use a timeout to check the saving state periodically + private monitorGlobalSaving(): void { 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 (!saving) { if (error) { - this.notificationService.showError(`Save completed with issues: ${error}`); + this.notificationService.showError(`Save failed: ${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.notificationService.showSuccess('Global 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); + // Reset form state without reloading from backend + this.globalForm.markAsPristine(); + this.hasGlobalChanges = false; + this.storeOriginalGlobalValues(); } } 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 + * Get instances from current config */ - 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 + get instances(): ArrInstance[] { + return this.sonarrConfig()?.instances || []; + } + + /** + * Check if instance management should be disabled + */ + get instanceManagementDisabled(): boolean { + return !this.globalForm.get('enabled')?.value; + } + + /** + * Open modal to add new instance + */ + openAddInstanceModal(): void { + this.modalMode = 'add'; + this.editingInstance = null; + this.instanceForm.reset(); + this.showInstanceModal = true; + } + + /** + * Open modal to edit existing instance + */ + openEditInstanceModal(instance: ArrInstance): void { + this.modalMode = 'edit'; + this.editingInstance = instance; + this.instanceForm.patchValue({ + name: instance.name, + url: instance.url, + apiKey: instance.apiKey, }); - - // 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; + this.showInstanceModal = true; + } + + /** + * Close instance modal + */ + closeInstanceModal(): void { + this.showInstanceModal = false; + this.editingInstance = null; + this.instanceForm.reset(); + } + + /** + * Save instance (add or edit) + */ + saveInstance(): void { + this.markFormGroupTouched(this.instanceForm); + + if (this.instanceForm.invalid) { + this.notificationService.showError('Please fix the validation errors before saving'); + return; } + + const instanceData: CreateArrInstanceDto = { + name: this.instanceForm.get('name')?.value, + url: this.instanceForm.get('url')?.value, + apiKey: this.instanceForm.get('apiKey')?.value, + }; + + if (this.modalMode === 'add') { + this.sonarrStore.createInstance(instanceData); + } else if (this.editingInstance) { + this.sonarrStore.updateInstance({ + id: this.editingInstance.id!, + instance: instanceData + }); + } + + this.monitorInstanceSaving(); + } + + /** + * Monitor instance saving completion + */ + private monitorInstanceSaving(): void { + const checkSavingStatus = () => { + const saving = this.sonarrSaving(); + const error = this.sonarrError(); + + if (!saving) { + if (error) { + this.notificationService.showError(`Operation failed: ${error}`); + } else { + const action = this.modalMode === 'add' ? 'created' : 'updated'; + this.notificationService.showSuccess(`Instance ${action} successfully`); + this.closeInstanceModal(); + } + } else { + setTimeout(checkSavingStatus, 100); + } + }; + + setTimeout(checkSavingStatus, 100); + } + + /** + * Delete instance with confirmation + */ + deleteInstance(instance: ArrInstance): void { + this.confirmationService.confirm({ + message: `Are you sure you want to delete the instance "${instance.name}"?`, + header: 'Confirm Deletion', + icon: 'pi pi-exclamation-triangle', + acceptButtonStyleClass: 'p-button-danger', + accept: () => { + this.sonarrStore.deleteInstance(instance.id!); + + // Monitor deletion + const checkDeletionStatus = () => { + const saving = this.sonarrSaving(); + const error = this.sonarrError(); + + if (!saving) { + if (error) { + this.notificationService.showError(`Deletion failed: ${error}`); + } else { + this.notificationService.showSuccess('Instance deleted successfully'); + } + } else { + setTimeout(checkDeletionStatus, 100); + } + }; + + setTimeout(checkDeletionStatus, 100); + } + }); + } + + /** + * Get modal title based on mode + */ + get modalTitle(): string { + return this.modalMode === 'add' ? 'Add Sonarr Instance' : 'Edit Sonarr Instance'; } }