mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-27 02:23:56 -04:00
269 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|