try add sonarr config #2

This commit is contained in:
Flaminel
2025-06-11 17:39:53 +03:00
parent 64a24051d7
commit 225e80cdbe
3 changed files with 280 additions and 334 deletions

View File

@@ -69,70 +69,55 @@
</div>
<!-- Instances Section -->
<div class="section-header">
<div class="section-header mt-4">
<h3>Sonarr Instances</h3>
<small class="form-helper-text">Configure multiple Sonarr server instances</small>
<small>Configure multiple Sonarr server instances</small>
</div>
<!-- Instances Table -->
<div class="instances-table mt-2">
<p-table [value]="instances.controls" [responsive]="true" styleClass="p-datatable-sm">
<ng-template pTemplate="header">
<tr>
<th>Name</th>
<th>URL</th>
<th>API Key</th>
<th style="width: 120px">Actions</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-instance let-i="rowIndex">
<tr [formGroup]="instance">
<td>{{ instance.get('name')?.value }}</td>
<td>{{ instance.get('url')?.value }}</td>
<td>{{ instance.get('apiKey')?.value | slice:0:8 }}•••••••</td>
<td>
<div class="flex justify-content-end gap-2">
<button
pButton
type="button"
icon="pi pi-pencil"
class="p-button-text p-button-sm"
(click)="openEditInstanceDialog(i)"
[disabled]="!sonarrForm.get('enabled')?.value"
></button>
<button
pButton
type="button"
icon="pi pi-trash"
class="p-button-text p-button-danger p-button-sm"
(click)="removeInstance(i)"
[disabled]="!sonarrForm.get('enabled')?.value"
></button>
<!-- 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 || !sonarrForm.get('enabled')?.value"></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>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="4" class="text-center">
<div class="p-3">No Sonarr instances configured</div>
</td>
</tr>
</ng-template>
</p-table>
</div>
<!-- Add Instance Button -->
<div class="mt-3">
<button
pButton
type="button"
label="Add Sonarr Instance"
icon="pi pi-plus"
class="p-button-outlined"
(click)="openAddInstanceDialog()"
[disabled]="!sonarrForm.get('enabled')?.value"
></button>
</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 Sonarr Instance"
(click)="addInstance()" [disabled]="!sonarrForm.get('enabled')?.value" class="p-button-success"></button>
</div>
</div>
<!-- Action buttons -->
@@ -159,70 +144,3 @@
</form>
</div>
</p-card>
<!-- Instance Dialog -->
<p-dialog
[(visible)]="showInstanceDialog"
[header]="editingInstanceIndex !== null ? 'Edit Sonarr Instance' : 'Add Sonarr Instance'"
[modal]="true"
[style]="{ width: '450px' }"
[draggable]="false"
[resizable]="false"
>
<form [formGroup]="instanceForm" class="p-fluid">
<div class="field mb-4">
<label for="name" class="font-bold">Name*</label>
<input
type="text"
pInputText
id="name"
formControlName="name"
placeholder="My Sonarr Server"
required
/>
<small *ngIf="hasInstanceError('name', 'required')" class="p-error">Name is required</small>
</div>
<div class="field mb-4">
<label for="url" class="font-bold">URL*</label>
<input
type="text"
pInputText
id="url"
formControlName="url"
placeholder="http://localhost:8989"
required
/>
<small *ngIf="hasInstanceError('url', 'required')" class="p-error">URL is required</small>
</div>
<div class="field mb-4">
<label for="apiKey" class="font-bold">API Key*</label>
<input
type="password"
pInputText
id="apiKey"
formControlName="apiKey"
placeholder="Your Sonarr API key"
required
/>
<small *ngIf="hasInstanceError('apiKey', 'required')" class="p-error">API key is required</small>
</div>
</form>
<ng-template pTemplate="footer">
<button
pButton
label="Cancel"
icon="pi pi-times"
class="p-button-text"
(click)="cancelInstanceDialog()"
></button>
<button
pButton
label="Save"
icon="pi pi-check"
(click)="saveInstance()"
[disabled]="instanceForm.invalid"
></button>
</ng-template>
</p-dialog>

View File

@@ -2,17 +2,115 @@
@import '../styles/settings-shared.scss';
.instances-table {
margin-bottom: 1rem;
}
.section-header {
margin-top: 1.5rem;
margin-bottom: 0.5rem;
margin-bottom: 1rem;
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 0.25rem;
font-weight: 500;
}
small {
color: var(--text-color-secondary);
}
}
.instances-container {
margin-bottom: 1.5rem;
}
.instance-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.instance-item {
background-color: var(--surface-hover);
border-radius: 6px;
padding: 1rem;
border-left: 4px solid var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
&:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
}
.instance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.instance-title {
display: flex;
align-items: center;
gap: 0.5rem;
.instance-icon {
color: var(--primary-color);
font-size: 1rem;
}
.instance-name-input {
font-weight: 500;
font-size: 1.1rem;
}
}
.instance-content {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
.instance-field {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: flex-start;
}
label {
font-weight: 500;
margin-bottom: 0.5rem;
@media (min-width: 768px) {
flex: 0 0 200px;
margin-bottom: 0;
padding-top: 0.5rem;
}
}
.field-input {
flex: 1;
max-width: 100%;
@media (min-width: 768px) {
max-width: 400px;
}
}
}
.empty-instances-message {
background-color: var(--surface-ground);
border-radius: 6px;
border: 1px dashed var(--surface-border);
color: var(--text-color-secondary);
font-style: italic;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--surface-border);
}

View File

@@ -12,10 +12,7 @@ import { InputTextModule } from "primeng/inputtext";
import { CheckboxModule } from "primeng/checkbox";
import { ButtonModule } from "primeng/button";
import { InputNumberModule } from "primeng/inputnumber";
import { AccordionModule } from "primeng/accordion";
import { SelectButtonModule } from "primeng/selectbutton";
import { DialogModule } from "primeng/dialog";
import { TableModule } from "primeng/table";
import { ToastModule } from "primeng/toast";
import { NotificationService } from "../../core/services/notification.service";
import { DropdownModule } from "primeng/dropdown";
@@ -32,10 +29,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro
CheckboxModule,
ButtonModule,
InputNumberModule,
AccordionModule,
SelectButtonModule,
DialogModule,
TableModule,
ToastModule,
DropdownModule,
LoadingErrorStateComponent,
@@ -50,18 +44,13 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
// Sonarr Configuration Form
sonarrForm: FormGroup;
// Original form values for tracking changes
private originalFormValues: any;
// Track whether the form has actual changes compared to original values
hasActualChanges = false;
// Dialog state
showInstanceDialog = false;
editingInstanceIndex: number | null = null;
instanceForm: FormGroup;
// SonarrSearchType options
searchTypeOptions = [
{ label: "Episode", value: SonarrSearchType.Episode },
@@ -78,11 +67,11 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
private notificationService = inject(NotificationService);
private sonarrStore = inject(SonarrConfigStore);
// Signals from the store
readonly sonarrConfig = this.sonarrStore.config;
readonly sonarrLoading = this.sonarrStore.loading;
readonly sonarrSaving = this.sonarrStore.saving;
readonly sonarrError = this.sonarrStore.error;
// Signals from store
sonarrConfig = this.sonarrStore.config;
sonarrLoading = this.sonarrStore.loading;
sonarrError = this.sonarrStore.error;
sonarrSaving = this.sonarrStore.saving;
/**
* Check if component can be deactivated (navigation guard)
@@ -99,27 +88,17 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
searchType: [SonarrSearchType.Episode, Validators.required],
});
// Initialize the instance form
this.instanceForm = this.formBuilder.group({
id: [''],
name: ['', Validators.required],
url: ['', [Validators.required]],
apiKey: ['', [Validators.required]],
});
// Add instances FormArray to main form
this.sonarrForm.addControl('instances', this.formBuilder.array([]));
// Setup value change listeners
this.setupFormValueChangeListeners();
// Load Sonarr config data
this.sonarrStore.loadConfig();
// Create an effect to respond to config changes
// Setup effect to update form when config changes
effect(() => {
const config = this.sonarrConfig();
if (config) {
this.updateForm(config);
this.storeOriginalValues();
this.updateFormControlDisabledStates(config);
this.updateFormFromConfig(config);
}
});
}
@@ -133,97 +112,9 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
}
/**
* Set up listeners for form control value changes to manage dependent control states
* Update form with values from the configuration
*/
private setupFormValueChangeListeners(): void {
// Listen for changes on the enabled control
this.sonarrForm
.get("enabled")
?.valueChanges.pipe(takeUntil(this.destroy$))
.subscribe((enabled) => {
this.updateMainControlsState(enabled);
});
// Listen for form changes to update the hasActualChanges flag
this.sonarrForm.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.hasActualChanges = this.formValuesChanged();
});
}
/**
* Store original form values for dirty checking
*/
private storeOriginalValues(): void {
this.originalFormValues = JSON.parse(JSON.stringify(this.sonarrForm.value));
this.sonarrForm.markAsPristine();
this.hasActualChanges = false;
}
/**
* Check if the current form values are different from the original values
*/
private formValuesChanged(): boolean {
return !this.isEqual(this.sonarrForm.value, this.originalFormValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
const val1 = obj1[key];
const val2 = obj2[key];
const areObjects = typeof val1 === "object" && typeof val2 === "object";
if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
return false;
}
}
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');
const searchTypeControl = this.sonarrForm.get('searchType');
if (enabled) {
failedImportMaxStrikesControl?.enable();
searchTypeControl?.enable();
} else {
failedImportMaxStrikesControl?.disable();
searchTypeControl?.disable();
}
}
/**
* Update the form with values from the configuration
*/
private updateForm(config: SonarrConfig): void {
private updateFormFromConfig(config: SonarrConfig): void {
// Update main form controls
this.sonarrForm.patchValue({
enabled: config.enabled,
@@ -248,13 +139,77 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
);
});
}
// Store original form values for dirty checking
this.storeOriginalValues();
}
/**
* Get the instances form array
* Store original form values for dirty checking
*/
get instances(): FormArray {
return this.sonarrForm.get('instances') as FormArray;
private storeOriginalValues(): void {
this.originalFormValues = JSON.parse(JSON.stringify(this.sonarrForm.value));
this.sonarrForm.markAsPristine();
this.hasActualChanges = false;
}
/**
* Check if the current form values are different from the original values
*/
private formValuesChanged(): boolean {
return !this.isEqual(this.sonarrForm.value, this.originalFormValues);
}
/**
* Deep compare two objects for equality
*/
private isEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 == null || obj2 == null) {
return false;
}
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
const val1 = obj1[key];
const val2 = obj2[key];
const areObjects = typeof val1 === "object" && typeof val2 === "object";
if ((areObjects && !this.isEqual(val1, val2)) || (!areObjects && val1 !== val2)) {
return false;
}
}
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');
const searchTypeControl = this.sonarrForm.get('searchType');
if (enabled) {
failedImportMaxStrikesControl?.enable();
searchTypeControl?.enable();
} else {
failedImportMaxStrikesControl?.disable();
searchTypeControl?.disable();
}
}
/**
@@ -267,7 +222,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
// Get data from form
const formValue = this.sonarrForm.getRawValue();
// Create config object
const sonarrConfig: SonarrConfig = {
enabled: formValue.enabled,
@@ -275,7 +230,7 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
searchType: formValue.searchType,
instances: formValue.instances || []
};
// Save the configuration
this.sonarrStore.saveConfig(sonarrConfig);
@@ -285,18 +240,18 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
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 {
@@ -304,16 +259,16 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
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);
}
@@ -335,71 +290,27 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
// 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;
}
/**
* Open the instance dialog for adding a new instance
* Add a new instance to the instances form array
*/
openAddInstanceDialog(): void {
this.editingInstanceIndex = null;
this.instanceForm.reset({
id: '',
name: '',
url: '',
apiKey: '',
});
this.showInstanceDialog = true;
}
/**
* Open the instance dialog for editing an existing instance
*/
openEditInstanceDialog(index: number): void {
const instanceToEdit = this.instances.at(index).value;
this.editingInstanceIndex = index;
this.instanceForm.reset({
id: instanceToEdit.id || '',
name: instanceToEdit.name,
url: instanceToEdit.url,
apiKey: instanceToEdit.apiKey,
});
this.showInstanceDialog = true;
}
/**
* Save the instance from the dialog
*/
saveInstance(): void {
if (this.instanceForm.invalid) {
this.markFormGroupTouched(this.instanceForm);
return;
}
const instanceData = this.instanceForm.value;
addInstance(): void {
const instancesArray = this.sonarrForm.get('instances') as FormArray;
if (this.editingInstanceIndex !== null) {
// Update existing instance
instancesArray.at(this.editingInstanceIndex).patchValue(instanceData);
} else {
// Add new instance
instancesArray.push(
this.formBuilder.group({
id: [instanceData.id || ''],
name: [instanceData.name, Validators.required],
url: [instanceData.url, Validators.required],
apiKey: [instanceData.apiKey, Validators.required],
})
);
}
instancesArray.push(
this.formBuilder.group({
id: [''],
name: ['', Validators.required],
url: ['', Validators.required],
apiKey: ['', Validators.required],
})
);
this.showInstanceDialog = false;
this.sonarrForm.markAsDirty();
this.hasActualChanges = true;
}
@@ -415,12 +326,21 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
}
/**
* Cancel the instance dialog
* Get the instances form array
*/
cancelInstanceDialog(): void {
this.showInstanceDialog = false;
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;
}
// hasInstanceFieldError is implemented below
/**
* Mark all controls in a form group as touched
*/
@@ -435,18 +355,28 @@ export class SonarrSettingsComponent implements OnDestroy, CanComponentDeactivat
}
/**
* Check if a form control has an error after it's been touched
* 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
*/
hasError(controlName: string, errorName: string): boolean {
const control = this.sonarrForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
return control !== null && control.hasError(errorName) && control.touched;
}
/**
* Get nested form control errors
* 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
*/
hasInstanceError(controlName: string, errorName: string): boolean {
const control = this.instanceForm.get(controlName);
return control ? control.touched && control.hasError(errorName) : false;
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;
}
}