Files
Cleanuparr/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts

584 lines
21 KiB
TypeScript

import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
SizeInputComponent,
type SelectOption, type SizeUnit,
} from '@ui';
import { NgIcon } from '@ng-icons/core';
import { QueueCleanerApi } from '@core/api/queue-cleaner.api';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import { QueueCleanerConfig, ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
import { StallRule, SlowRule, CreateStallRuleDto, CreateSlowRuleDto } from '@shared/models/queue-rule.model';
import { ScheduleUnit, PatternMode, TorrentPrivacyType } 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';
import { analyzeCoverage } from './coverage-analysis.util';
const PATTERN_MODE_OPTIONS: SelectOption[] = [
{ label: 'Exclude', value: PatternMode.Exclude },
{ label: 'Include', value: PatternMode.Include },
];
const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Public', value: TorrentPrivacyType.Public },
{ label: 'Private', value: TorrentPrivacyType.Private },
{ label: 'Both', value: TorrentPrivacyType.Both },
];
const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
{ label: 'Seconds', value: ScheduleUnit.Seconds },
{ label: 'Minutes', value: ScheduleUnit.Minutes },
{ label: 'Hours', value: ScheduleUnit.Hours },
];
@Component({
selector: 'app-queue-cleaner',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
SizeInputComponent, NgIcon,
],
templateUrl: './queue-cleaner.component.html',
styleUrl: './queue-cleaner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QueueCleanerComponent implements OnInit, HasPendingChanges {
private readonly api = inject(QueueCleanerApi);
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
private readonly chipInputs = viewChildren(ChipInputComponent);
private readonly savedSnapshot = signal('');
readonly patternModeOptions = PATTERN_MODE_OPTIONS;
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly speedUnits: SizeUnit[] = [
{ label: 'KB/s', value: 'KB' },
{ label: 'MB/s', value: 'MB' },
];
readonly sizeUnits: SizeUnit[] = [
{ label: 'KB', value: 'KB' },
{ label: 'MB', value: 'MB' },
];
readonly sizeUnitsLarge: SizeUnit[] = [
{ label: 'MB', value: 'MB' },
{ label: 'GB', value: 'GB' },
];
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.Minutes);
readonly ignoredDownloads = signal<string[]>([]);
readonly processNoContentId = signal(false);
readonly scheduleIntervalOptions = computed(() => {
const unit = this.scheduleUnit() as ScheduleUnit;
const values = ScheduleOptions[unit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
// Failed import
readonly failedMaxStrikes = signal<number | null>(3);
readonly failedIgnorePrivate = signal(false);
readonly failedDeletePrivate = signal(false);
readonly failedSkipNotFound = signal(false);
readonly failedPatterns = signal<string[]>([]);
readonly failedPatternMode = signal<unknown>(PatternMode.Exclude);
readonly failedExpanded = signal(true);
// Metadata
readonly metadataMaxStrikes = signal<number | null>(3);
readonly metadataExpanded = signal(false);
// Stall rules
readonly stallRules = signal<StallRule[]>([]);
readonly stallRulesLoading = signal(false);
readonly stallExpanded = signal(false);
readonly stallModalVisible = signal(false);
readonly editingStallRule = signal<StallRule | null>(null);
// Stall rule form
readonly stallName = signal('');
readonly stallEnabled = signal(true);
readonly stallMaxStrikes = signal<number | null>(3);
readonly stallPrivacyType = signal<unknown>(TorrentPrivacyType.Both);
readonly stallMinCompletion = signal<number | null>(0);
readonly stallMaxCompletion = signal<number | null>(100);
readonly stallResetOnProgress = signal(false);
readonly stallMinProgress = signal('');
readonly stallDeletePrivate = signal(false);
// Slow rules
readonly slowRules = signal<SlowRule[]>([]);
readonly slowRulesLoading = signal(false);
readonly slowExpanded = signal(false);
readonly slowModalVisible = signal(false);
readonly editingSlowRule = signal<SlowRule | null>(null);
// Slow rule form
readonly slowName = signal('');
readonly slowEnabled = signal(true);
readonly slowMaxStrikes = signal<number | null>(3);
readonly slowMinSpeed = signal('');
readonly slowMaxTimeHours = signal<number | null>(0);
readonly slowPrivacyType = signal<unknown>(TorrentPrivacyType.Both);
readonly slowMinCompletion = signal<number | null>(0);
readonly slowMaxCompletion = signal<number | null>(100);
readonly slowIgnoreAboveSize = signal('');
readonly slowResetOnProgress = signal(false);
readonly slowDeletePrivate = signal(false);
constructor() {
effect(() => {
const unit = this.scheduleUnit();
const options = ScheduleOptions[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.failedIgnorePrivate();
if (ignorePrivate) {
untracked(() => this.failedDeletePrivate.set(false));
}
});
}
// Validation
readonly scheduleEveryError = computed(() => {
if (this.useAdvancedScheduling()) return undefined;
const unit = this.scheduleUnit() as ScheduleUnit;
const options = ScheduleOptions[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;
});
readonly failedMaxStrikesError = computed(() => {
const v = this.failedMaxStrikes();
if (v == null) return 'This field is required';
if (v < 0) return 'Value cannot be negative';
if (v > 5000) return 'Value cannot exceed 5000';
return undefined;
});
readonly failedPatternsError = computed(() => {
if (this.failedSubFieldsDisabled()) return undefined;
if (this.failedPatternMode() === PatternMode.Include && this.failedPatterns().length === 0) {
return 'At least one pattern is required when using Include mode';
}
return undefined;
});
readonly metadataMaxStrikesError = computed(() => {
const v = this.metadataMaxStrikes();
if (v == null) return 'This field is required';
if (v < 0) return 'Value cannot be negative';
if (v > 5000) return 'Value cannot exceed 5000';
return undefined;
});
readonly failedSubFieldsDisabled = computed(() => {
return this.failedMaxStrikes() === 0;
});
readonly failedDeletePrivateDisabled = computed(() => {
return this.failedSubFieldsDisabled() || this.failedIgnorePrivate();
});
readonly patternLabel = computed(() => {
return this.failedPatternMode() === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns';
});
readonly patternHint = computed(() => {
return this.failedPatternMode() === PatternMode.Include
? 'Only failed imports containing these patterns will be removed and everything else will be skipped'
: 'Failed imports containing these patterns will be skipped and everything else will be removed';
});
// Coverage analysis
readonly stallCoverage = computed(() => analyzeCoverage(this.stallRules()));
readonly slowCoverage = computed(() => analyzeCoverage(this.slowRules()));
// Stall modal validation
readonly stallNameError = computed(() => {
if (!this.stallName().trim()) return 'Name is required';
if (this.stallName().length > 100) return 'Name cannot exceed 100 characters';
return undefined;
});
readonly stallMaxStrikesError = computed(() => {
const v = this.stallMaxStrikes();
if (v == null) return 'This field is required';
if (v < 3) return 'Min value is 3';
if (v > 5000) return 'Max value is 5000';
return undefined;
});
readonly stallCompletionError = computed(() => {
const min = this.stallMinCompletion() ?? 0;
const max = this.stallMaxCompletion() ?? 100;
if (max <= 0) return 'Max percentage must be greater than 0';
if (max < min) return 'Max percentage must be greater than or equal to Min percentage';
return undefined;
});
// Slow modal validation
readonly slowNameError = computed(() => {
if (!this.slowName().trim()) return 'Name is required';
if (this.slowName().length > 100) return 'Name cannot exceed 100 characters';
return undefined;
});
readonly slowMaxStrikesError = computed(() => {
const v = this.slowMaxStrikes();
if (v == null) return 'This field is required';
if (v < 3) return 'Min value is 3';
if (v > 5000) return 'Max value is 5000';
return undefined;
});
readonly slowCompletionError = computed(() => {
const min = this.slowMinCompletion() ?? 0;
const max = this.slowMaxCompletion() ?? 100;
if (max <= 0) return 'Max percentage must be greater than 0';
if (max < min) return 'Max percentage must be greater than or equal to Min percentage';
return undefined;
});
readonly hasErrors = computed(() => !!(
this.scheduleEveryError() ||
this.cronError() ||
this.failedMaxStrikesError() ||
this.failedPatternsError() ||
this.metadataMaxStrikesError() ||
this.chipInputs().some(c => c.hasUncommittedInput())
));
private config: QueueCleanerConfig | null = null;
ngOnInit(): void {
this.loadConfig();
this.loadStallRules();
this.loadSlowRules();
}
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.processNoContentId.set(config.processNoContentId);
this.failedMaxStrikes.set(config.failedImport.maxStrikes);
this.failedIgnorePrivate.set(config.failedImport.ignorePrivate);
this.failedDeletePrivate.set(config.failedImport.deletePrivate);
this.failedSkipNotFound.set(config.failedImport.skipIfNotFoundInClient);
this.failedPatterns.set(config.failedImport.patterns ?? []);
this.failedPatternMode.set(config.failedImport.patternMode ?? PatternMode.Exclude);
this.metadataMaxStrikes.set(config.downloadingMetadataMaxStrikes);
this.loader.stop();
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to load queue cleaner settings');
this.loader.stop();
this.loadError.set(true);
},
});
}
private loadStallRules(): void {
this.stallRulesLoading.set(true);
this.api.getStallRules().subscribe({
next: (rules) => {
this.stallRules.set(rules);
this.stallRulesLoading.set(false);
},
error: () => {
this.toast.error('Failed to load stall rules');
this.stallRulesLoading.set(false);
},
});
}
private loadSlowRules(): void {
this.slowRulesLoading.set(true);
this.api.getSlowRules().subscribe({
next: (rules) => {
this.slowRules.set(rules);
this.slowRulesLoading.set(false);
},
error: () => {
this.toast.error('Failed to load slow rules');
this.slowRulesLoading.set(false);
},
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.loadStallRules();
this.loadSlowRules();
}
// Stall rule CRUD
openStallModal(rule?: StallRule): void {
this.editingStallRule.set(rule ?? null);
if (rule) {
this.stallName.set(rule.name);
this.stallEnabled.set(rule.enabled);
this.stallMaxStrikes.set(rule.maxStrikes);
this.stallPrivacyType.set(rule.privacyType);
this.stallMinCompletion.set(rule.minCompletionPercentage);
this.stallMaxCompletion.set(rule.maxCompletionPercentage);
this.stallResetOnProgress.set(rule.resetStrikesOnProgress);
this.stallMinProgress.set(rule.minimumProgress ?? '');
this.stallDeletePrivate.set(rule.deletePrivateTorrentsFromClient);
} else {
this.stallName.set('');
this.stallEnabled.set(true);
this.stallMaxStrikes.set(3);
this.stallPrivacyType.set(TorrentPrivacyType.Both);
this.stallMinCompletion.set(0);
this.stallMaxCompletion.set(100);
this.stallResetOnProgress.set(false);
this.stallMinProgress.set('');
this.stallDeletePrivate.set(false);
}
this.stallModalVisible.set(true);
}
saveStallRule(): void {
if (this.stallNameError() || this.stallMaxStrikesError() || this.stallCompletionError()) return;
const dto: CreateStallRuleDto = {
name: this.stallName().trim(),
enabled: this.stallEnabled(),
maxStrikes: this.stallMaxStrikes() ?? 3,
privacyType: this.stallPrivacyType() as TorrentPrivacyType,
minCompletionPercentage: this.stallMinCompletion() ?? 0,
maxCompletionPercentage: this.stallMaxCompletion() ?? 100,
resetStrikesOnProgress: this.stallResetOnProgress(),
minimumProgress: this.stallMinProgress().trim() || null,
deletePrivateTorrentsFromClient: this.stallDeletePrivate(),
};
const editing = this.editingStallRule();
const request = editing?.id
? this.api.updateStallRule(editing.id, dto)
: this.api.createStallRule(dto);
request.subscribe({
next: () => {
this.toast.success(editing ? 'Stall rule updated' : 'Stall rule created');
this.stallModalVisible.set(false);
this.loadStallRules();
},
error: (e: Error) => this.toast.error(e.message),
});
}
async deleteStallRule(rule: StallRule): Promise<void> {
const confirmed = await this.confirm.confirm({
title: 'Delete Stall Rule',
message: `Are you sure you want to delete "${rule.name}"?`,
confirmLabel: 'Delete',
destructive: true,
});
if (!confirmed || !rule.id) return;
this.api.deleteStallRule(rule.id).subscribe({
next: () => {
this.toast.success('Stall rule deleted');
this.loadStallRules();
},
error: () => this.toast.error('Failed to delete stall rule'),
});
}
// Slow rule CRUD
openSlowModal(rule?: SlowRule): void {
this.editingSlowRule.set(rule ?? null);
if (rule) {
this.slowName.set(rule.name);
this.slowEnabled.set(rule.enabled);
this.slowMaxStrikes.set(rule.maxStrikes);
this.slowMinSpeed.set(rule.minSpeed);
this.slowMaxTimeHours.set(rule.maxTimeHours);
this.slowPrivacyType.set(rule.privacyType);
this.slowMinCompletion.set(rule.minCompletionPercentage);
this.slowMaxCompletion.set(rule.maxCompletionPercentage);
this.slowIgnoreAboveSize.set(rule.ignoreAboveSize ?? '');
this.slowResetOnProgress.set(rule.resetStrikesOnProgress);
this.slowDeletePrivate.set(rule.deletePrivateTorrentsFromClient);
} else {
this.slowName.set('');
this.slowEnabled.set(true);
this.slowMaxStrikes.set(3);
this.slowMinSpeed.set('');
this.slowMaxTimeHours.set(0);
this.slowPrivacyType.set(TorrentPrivacyType.Both);
this.slowMinCompletion.set(0);
this.slowMaxCompletion.set(100);
this.slowIgnoreAboveSize.set('');
this.slowResetOnProgress.set(false);
this.slowDeletePrivate.set(false);
}
this.slowModalVisible.set(true);
}
saveSlowRule(): void {
if (this.slowNameError() || this.slowMaxStrikesError() || this.slowCompletionError()) return;
const dto: CreateSlowRuleDto = {
name: this.slowName().trim(),
enabled: this.slowEnabled(),
maxStrikes: this.slowMaxStrikes() ?? 3,
privacyType: this.slowPrivacyType() as TorrentPrivacyType,
minCompletionPercentage: this.slowMinCompletion() ?? 0,
maxCompletionPercentage: this.slowMaxCompletion() ?? 100,
resetStrikesOnProgress: this.slowResetOnProgress(),
minSpeed: this.slowMinSpeed().trim(),
maxTimeHours: this.slowMaxTimeHours() ?? 0,
ignoreAboveSize: this.slowIgnoreAboveSize().trim() || undefined,
deletePrivateTorrentsFromClient: this.slowDeletePrivate(),
};
const editing = this.editingSlowRule();
const request = editing?.id
? this.api.updateSlowRule(editing.id, dto)
: this.api.createSlowRule(dto);
request.subscribe({
next: () => {
this.toast.success(editing ? 'Slow rule updated' : 'Slow rule created');
this.slowModalVisible.set(false);
this.loadSlowRules();
},
error: (e: Error) => this.toast.error(e.message),
});
}
async deleteSlowRule(rule: SlowRule): Promise<void> {
const confirmed = await this.confirm.confirm({
title: 'Delete Slow Rule',
message: `Are you sure you want to delete "${rule.name}"?`,
confirmLabel: 'Delete',
destructive: true,
});
if (!confirmed || !rule.id) return;
this.api.deleteSlowRule(rule.id).subscribe({
next: () => {
this.toast.success('Slow rule deleted');
this.loadSlowRules();
},
error: () => this.toast.error('Failed to delete slow rule'),
});
}
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 config: QueueCleanerConfig = {
...this.config,
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
processNoContentId: this.processNoContentId(),
failedImport: {
maxStrikes: this.failedMaxStrikes() ?? 3,
ignorePrivate: this.failedIgnorePrivate(),
deletePrivate: this.failedDeletePrivate(),
skipIfNotFoundInClient: this.failedSkipNotFound(),
patterns: this.failedPatterns(),
patternMode: this.failedPatternMode() as PatternMode,
},
downloadingMetadataMaxStrikes: this.metadataMaxStrikes() ?? 3,
};
this.saving.set(true);
this.api.updateConfig(config).subscribe({
next: () => {
this.toast.success('Queue cleaner settings saved');
this.saving.set(false);
this.saved.set(true);
setTimeout(() => this.saved.set(false), 1500);
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
this.toast.error('Failed to save queue cleaner 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(),
processNoContentId: this.processNoContentId(),
failedMaxStrikes: this.failedMaxStrikes(),
failedIgnorePrivate: this.failedIgnorePrivate(),
failedDeletePrivate: this.failedDeletePrivate(),
failedSkipNotFound: this.failedSkipNotFound(),
failedPatterns: this.failedPatterns(),
failedPatternMode: this.failedPatternMode(),
metadataMaxStrikes: this.metadataMaxStrikes(),
});
}
readonly dirty = computed(() => {
const saved = this.savedSnapshot();
return saved !== '' && saved !== this.buildSnapshot();
});
hasPendingChanges(): boolean {
return this.dirty();
}
onStallPrivacyTypeChange(value: unknown): void {
this.stallPrivacyType.set(value);
this.stallDeletePrivate.set(false);
}
onSlowPrivacyTypeChange(value: unknown): void {
this.slowPrivacyType.set(value);
this.slowDeletePrivate.set(false);
}
}