Files
Cleanuparr/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts
2026-03-06 17:53:04 +02:00

269 lines
10 KiB
TypeScript

import { Component, ChangeDetectionStrategy, inject, signal, OnInit, computed, viewChildren, effect, untracked } from '@angular/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
SelectComponent, ChipInputComponent, AccordionComponent, EmptyStateComponent, LoadingStateComponent,
type SelectOption,
} from '@ui';
import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util';
const BLOCKLIST_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Blacklist', value: BlocklistType.Blacklist },
{ label: 'Whitelist', value: BlocklistType.Whitelist },
];
const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
{ label: 'Seconds', value: ScheduleUnit.Seconds },
{ label: 'Minutes', value: ScheduleUnit.Minutes },
{ label: 'Hours', value: ScheduleUnit.Hours },
];
const ARR_NAMES = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'] as const;
@Component({
selector: 'app-malware-blocker',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
],
templateUrl: './malware-blocker.component.html',
styleUrl: './malware-blocker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
private readonly api = inject(MalwareBlockerApi);
private readonly toast = inject(ToastService);
private readonly chipInputs = viewChildren(ChipInputComponent);
private readonly savedSnapshot = signal('');
readonly blocklistTypeOptions = BLOCKLIST_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly arrNames = ARR_NAMES;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly saving = signal(false);
readonly saved = signal(false);
readonly enabled = signal(false);
readonly useAdvancedScheduling = signal(false);
readonly cronExpression = signal('');
readonly scheduleEvery = signal<unknown>(5);
readonly scheduleUnit = signal<unknown>(ScheduleUnit.Seconds);
readonly ignoredDownloads = signal<string[]>([]);
readonly ignorePrivate = signal(false);
readonly deletePrivate = signal(false);
readonly arrExpanded = signal(false);
readonly scheduleIntervalOptions = computed(() => {
const unit = this.scheduleUnit() as ScheduleUnit;
const values = MalwareScheduleOptions[unit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
// Per-arr blocklist settings
readonly arrBlocklists = signal<Record<string, { enabled: boolean; blocklistPath: string; blocklistType: unknown }>>({
sonarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
radarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
lidarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
readarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
whisparr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
});
readonly deletePrivateDisabled = computed(() => this.ignorePrivate());
constructor() {
effect(() => {
const unit = this.scheduleUnit();
const options = MalwareScheduleOptions[unit as ScheduleUnit] ?? [];
const current = this.scheduleEvery();
if (options.length > 0 && !options.includes(current as number)) {
untracked(() => this.scheduleEvery.set(options[0]));
}
});
effect(() => {
const ignorePrivate = this.ignorePrivate();
if (ignorePrivate) {
untracked(() => this.deletePrivate.set(false));
}
});
}
readonly scheduleEveryError = computed(() => {
if (this.useAdvancedScheduling()) return undefined;
const unit = this.scheduleUnit() as ScheduleUnit;
const options = MalwareScheduleOptions[unit] ?? [];
if (!options.includes(this.scheduleEvery() as number)) return 'Please select a value';
return undefined;
});
readonly cronError = computed(() => {
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required';
return undefined;
});
blocklistPathError(arrName: string): string | undefined {
const bl = this.arrBlocklists()[arrName];
if (bl?.enabled && !bl.blocklistPath?.trim()) {
return 'Path is required when blocklist is enabled';
}
return undefined;
}
readonly noBlocklistError = computed(() => {
if (!this.enabled()) return undefined;
const blocklists = this.arrBlocklists();
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
if (!hasAnyEnabled) {
return 'At least one blocklist must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noBlocklistError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
for (const name of ARR_NAMES) {
if (this.blocklistPathError(name)) return true;
}
return false;
});
private config: MalwareBlockerConfig | null = null;
ngOnInit(): void {
this.loadConfig();
}
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
this.config = config;
this.enabled.set(config.enabled);
this.useAdvancedScheduling.set(config.useAdvancedScheduling);
this.cronExpression.set(config.cronExpression);
const parsed = parseCronToJobSchedule(config.cronExpression);
if (parsed) {
this.scheduleEvery.set(parsed.every);
this.scheduleUnit.set(parsed.type);
}
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.ignorePrivate.set(config.ignorePrivate);
this.deletePrivate.set(config.deletePrivate);
const blocklists: Record<string, any> = {};
for (const name of ARR_NAMES) {
const bl = config[name];
blocklists[name] = {
enabled: bl.enabled,
blocklistPath: bl.blocklistPath,
blocklistType: bl.blocklistType,
};
}
this.arrBlocklists.set(blocklists);
this.loader.stop();
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to load malware blocker settings');
this.loader.stop();
this.loadError.set(true);
},
});
}
updateArrBlocklist(arrName: string, field: string, value: any): void {
this.arrBlocklists.update((current) => ({
...current,
[arrName]: { ...current[arrName], [field]: value },
}));
}
capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
}
save(): void {
if (!this.config) return;
const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit };
const cronExpression = this.useAdvancedScheduling()
? this.cronExpression()
: generateCronExpression(jobSchedule);
const blocklists = this.arrBlocklists();
const config: MalwareBlockerConfig = {
...this.config,
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
sonarr: { enabled: blocklists['sonarr'].enabled, blocklistPath: blocklists['sonarr'].blocklistPath, blocklistType: blocklists['sonarr'].blocklistType as BlocklistType },
radarr: { enabled: blocklists['radarr'].enabled, blocklistPath: blocklists['radarr'].blocklistPath, blocklistType: blocklists['radarr'].blocklistType as BlocklistType },
lidarr: { enabled: blocklists['lidarr'].enabled, blocklistPath: blocklists['lidarr'].blocklistPath, blocklistType: blocklists['lidarr'].blocklistType as BlocklistType },
readarr: { enabled: blocklists['readarr'].enabled, blocklistPath: blocklists['readarr'].blocklistPath, blocklistType: blocklists['readarr'].blocklistType as BlocklistType },
whisparr: { enabled: blocklists['whisparr'].enabled, blocklistPath: blocklists['whisparr'].blocklistPath, blocklistType: blocklists['whisparr'].blocklistType as BlocklistType },
};
this.saving.set(true);
this.api.updateConfig(config).subscribe({
next: () => {
this.toast.success('Malware blocker settings saved');
this.saving.set(false);
this.saved.set(true);
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400
? err.message
: 'Failed to save malware blocker settings');
this.saving.set(false);
},
});
}
private buildSnapshot(): string {
return JSON.stringify({
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression: this.cronExpression(),
scheduleEvery: this.scheduleEvery(),
scheduleUnit: this.scheduleUnit(),
ignoredDownloads: this.ignoredDownloads(),
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
arrBlocklists: this.arrBlocklists(),
});
}
readonly dirty = computed(() => {
const saved = this.savedSnapshot();
return saved !== '' && saved !== this.buildSnapshot();
});
hasPendingChanges(): boolean {
return this.dirty();
}
}