try fix sonarr

This commit is contained in:
Flaminel
2025-06-15 22:21:22 +03:00
parent d5e3e9954d
commit 00dfd63797
11 changed files with 764 additions and 384 deletions

View File

@@ -133,10 +133,10 @@ export class ConfigurationService {
);
}
/**
* Update Sonarr configuration
* Update Sonarr configuration (global settings only)
*/
updateSonarrConfig(config: SonarrConfig): Observable<SonarrConfig> {
return this.http.put<SonarrConfig>(`${this.apiUrl}/api/configuration/sonarr`, config).pipe(
updateSonarrConfig(config: {enabled: boolean, failedImportMaxStrikes: number}): Observable<any> {
return this.http.put<any>(`${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"));

View File

@@ -49,18 +49,22 @@ export class SonarrConfigStore extends signalStore(
),
/**
* Save the Sonarr configuration
* Save the Sonarr global configuration
*/
saveConfig: rxMethod<SonarrConfig>(
(config$: Observable<SonarrConfig>) => 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, {

View File

@@ -1,129 +1,229 @@
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Sonarr Configuration</h2>
<span class="card-subtitle">Configure Sonarr integration settings</span>
<div class="sonarr-settings-container">
<!-- Loading/Error State Component -->
<app-loading-error-state
*ngIf="sonarrLoading() || sonarrError()"
[loading]="sonarrLoading()"
[error]="sonarrError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Content - only shown when not loading and no error -->
<div *ngIf="!sonarrLoading() && !sonarrError()" class="cards-container">
<!-- Global Configuration Card -->
<p-card styleClass="settings-card">
<ng-template pTemplate="header">
<div class="card-header">
<div class="header-title-container">
<h3 class="card-title">Global Settings</h3>
<span class="card-subtitle">Configure general Sonarr integration settings</span>
</div>
<i class="pi pi-cog text-xl"></i>
</div>
</ng-template>
<form [formGroup]="globalForm" class="p-fluid">
<div class="field-row">
<label class="field-label">Enable Sonarr Integration</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, Sonarr API integration will be used</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Failed Import Max Strikes</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[min]="-1"
[showButtons]="true"
buttonLayout="horizontal"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
[disabled]="!globalForm.get('enabled')?.value"
></p-inputNumber>
</div>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
<!-- Save Button -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save Global Settings"
icon="pi pi-save"
class="p-button-primary"
[disabled]="!globalForm.dirty || !hasGlobalChanges || globalForm.invalid || sonarrSaving()"
[loading]="sonarrSaving()"
(click)="saveGlobalConfig()"
></button>
</div>
</form>
</p-card>
<!-- Instance Management Card -->
<p-card styleClass="settings-card">
<ng-template pTemplate="header">
<div class="card-header">
<div class="header-title-container">
<h3 class="card-title">Sonarr Instances</h3>
<span class="card-subtitle">Manage multiple Sonarr server instances</span>
</div>
<button
pButton
type="button"
icon="pi pi-plus"
label="Add Instance"
class="p-button-outlined"
[disabled]="instanceManagementDisabled"
(click)="openAddInstanceModal()"
></button>
</div>
</ng-template>
<!-- Disabled state overlay -->
<div *ngIf="instanceManagementDisabled" class="disabled-overlay">
<div class="disabled-message">
<i class="pi pi-info-circle"></i>
<p>Enable Sonarr integration to manage instances</p>
</div>
</div>
<div class="flex align-items-center gap-2">
<i class="pi pi-cog text-xl"></i>
<!-- Empty state when no instances -->
<div *ngIf="instances.length === 0 && !instanceManagementDisabled" class="empty-instances-message p-3 text-center">
<i class="pi pi-inbox empty-icon"></i>
<p>No Sonarr instances configured</p>
<small>Add an instance to start using Sonarr integration</small>
</div>
<!-- Instances List -->
<div *ngIf="instances.length > 0" class="instances-list">
<div *ngFor="let instance of instances" class="instance-item">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-server instance-icon"></i>
<span class="instance-name">{{ instance.name }}</span>
</div>
<div class="instance-actions">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
[disabled]="instanceManagementDisabled"
(click)="openEditInstanceModal(instance)"
pTooltip="Edit instance"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-sm p-button-danger"
[disabled]="instanceManagementDisabled"
(click)="deleteInstance(instance)"
pTooltip="Delete instance"
></button>
</div>
</div>
<div class="instance-content">
<div class="instance-field">
<label>URL</label>
<span class="field-value">{{ instance.url }}</span>
</div>
<div class="instance-field">
<label>API Key</label>
<span class="field-value api-key">{{ instance.apiKey | slice:0:8 }}...</span>
</div>
</div>
</div>
</div>
</p-card>
</div>
</div>
<!-- Instance Modal -->
<p-dialog
[(visible)]="showInstanceModal"
[modal]="true"
[closable]="true"
[draggable]="false"
[resizable]="false"
styleClass="instance-modal"
[header]="modalTitle"
(onHide)="closeInstanceModal()"
>
<form [formGroup]="instanceForm" class="p-fluid instance-form">
<div class="field">
<label for="instance-name">Name *</label>
<input
id="instance-name"
type="text"
pInputText
formControlName="name"
placeholder="My Sonarr Instance"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field">
<label for="instance-url">URL *</label>
<input
id="instance-url"
type="text"
pInputText
formControlName="url"
placeholder="http://localhost:8989"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'url', 'required')" class="p-error">URL is required</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidUri')" class="p-error">URL must be a valid URL</small>
<small *ngIf="hasError(instanceForm, 'url', 'invalidProtocol')" class="p-error">URL must use http or https protocol</small>
</div>
<div class="field">
<label for="instance-apikey">API Key *</label>
<input
id="instance-apikey"
type="password"
pInputText
formControlName="apiKey"
placeholder="Your Sonarr API key"
class="w-full"
/>
<small *ngIf="hasError(instanceForm, 'apiKey', 'required')" class="p-error">API key is required</small>
</div>
</form>
<ng-template pTemplate="footer">
<div class="modal-footer">
<button
pButton
type="button"
label="Cancel"
class="p-button-text"
(click)="closeInstanceModal()"
></button>
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="instanceForm.invalid || sonarrSaving()"
[loading]="sonarrSaving()"
(click)="saveInstance()"
></button>
</div>
</ng-template>
</p-dialog>
<div class="card-content">
<!-- Loading/Error State Component -->
<app-loading-error-state
*ngIf="sonarrLoading() || sonarrError()"
[loading]="sonarrLoading()"
[error]="sonarrError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Form Content - only shown when not loading and no error -->
<form *ngIf="!sonarrLoading() && !sonarrError()" [formGroup]="sonarrForm" class="p-fluid">
<!-- Main Settings -->
<div class="field-row">
<label class="field-label">Enable Sonarr Integration</label>
<div class="field-input">
<p-checkbox formControlName="enabled" [binary]="true"></p-checkbox>
<small class="form-helper-text">When enabled, Sonarr API integration will be used</small>
</div>
</div>
<div class="field-row">
<label class="field-label">Failed Import Max Strikes</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="failedImportMaxStrikes"
[min]="-1"
[showButtons]="true"
buttonLayout="horizontal"
incrementButtonIcon="pi pi-plus"
decrementButtonIcon="pi pi-minus"
></p-inputNumber>
</div>
<small class="form-helper-text">Maximum number of strikes before removing a failed import (-1 to use global setting; 0 to disable)</small>
</div>
</div>
<!-- Instances Section -->
<div class="section-header mt-4">
<h3>Sonarr Instances</h3>
<small>Configure multiple Sonarr server instances</small>
</div>
<!-- Instances Container -->
<div class="instances-container">
<!-- Empty state message when no instances -->
<div *ngIf="instances.controls.length === 0" class="empty-instances-message p-3 text-center">
<p>No Sonarr instances defined. Add an instance to start using Sonarr integration.</p>
</div>
<!-- Instance cards -->
<div class="instance-list">
<div *ngFor="let instance of instances.controls; let i = index" class="instance-item" [formGroup]="getInstanceAsFormGroup(i)">
<div class="instance-header">
<div class="instance-title">
<i class="pi pi-server instance-icon"></i>
<input type="text" pInputText formControlName="name" placeholder="Instance name" class="instance-name-input" />
</div>
<button pButton type="button" icon="pi pi-trash" class="p-button-danger p-button-sm"
(click)="removeInstance(i)" [disabled]="sonarrForm.disabled"></button>
</div>
<small *ngIf="hasInstanceFieldError(i, 'name', 'required')" class="p-error block">Name is required</small>
<div class="instance-content">
<div class="instance-field">
<label>URL</label>
<div class="field-input">
<input type="text" pInputText formControlName="url" placeholder="http://localhost:8989" required />
<small *ngIf="hasInstanceFieldError(i, 'url', 'required')" class="p-error block">URL is required</small>
<small *ngIf="hasInstanceFieldError(i, 'url', 'invalidUri')" class="p-error block">URL must be a valid URL</small>
<small *ngIf="hasInstanceFieldError(i, 'url', 'invalidProtocol')" class="p-error block">URL must use http or https protocol</small>
</div>
</div>
<div class="instance-field">
<label>API Key</label>
<div class="field-input">
<input type="password" pInputText formControlName="apiKey" placeholder="Your Sonarr API key" required />
<small *ngIf="hasInstanceFieldError(i, 'apiKey', 'required')" class="p-error block">API key is required</small>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-content-end mt-3">
<button pButton type="button" icon="pi pi-plus" label="Add Instance"
(click)="addInstance()" [disabled]="!sonarrForm.get('enabled')?.value" class="p-button-outlined"></button>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!sonarrForm.dirty || !hasActualChanges) || sonarrForm.invalid || sonarrSaving()"
[loading]="sonarrSaving()"
(click)="saveSonarrConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetSonarrConfig()"
></button>
</div>
</form>
</div>
</p-card>
<!-- Confirmation Dialog -->
<p-confirmDialog></p-confirmDialog>

View File

@@ -1,4 +1,283 @@
/* Sonarr Settings Styles */
@import '../styles/settings-shared.scss';
@import '../styles/arr-shared.scss';
@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;
}
}
}

View File

@@ -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<void>();
@Output() error = new EventEmitter<string>();
// 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<void>();
// 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';
}
}