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

609 lines
22 KiB
TypeScript

import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChildren, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, min, max, maxLength, validate, disabled, FormField } from '@angular/forms/signals';
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 },
];
interface QueueCleanerFormModel {
enabled: boolean;
useAdvancedScheduling: boolean;
cronExpression: string;
scheduleEvery: number;
scheduleUnit: ScheduleUnit;
ignoredDownloads: string[];
processNoContentId: boolean;
failedMaxStrikes: number | null;
failedIgnorePrivate: boolean;
failedDeletePrivate: boolean;
failedSkipNotFound: boolean;
failedPatterns: string[];
failedPatternMode: PatternMode;
failedChangeCategory: boolean;
metadataMaxStrikes: number | null;
}
interface StallRuleFormModel {
name: string;
enabled: boolean;
maxStrikes: number | null;
privacyType: TorrentPrivacyType;
minCompletion: number | null;
maxCompletion: number | null;
resetOnProgress: boolean;
minProgress: string;
deletePrivate: boolean;
changeCategory: boolean;
}
interface SlowRuleFormModel {
name: string;
enabled: boolean;
maxStrikes: number | null;
minSpeed: string;
maxTimeHours: number | null;
privacyType: TorrentPrivacyType;
minCompletion: number | null;
maxCompletion: number | null;
ignoreAboveSize: string;
resetOnProgress: boolean;
deletePrivate: boolean;
changeCategory: boolean;
}
@Component({
selector: 'app-queue-cleaner',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
SizeInputComponent, NgIcon, FormField,
],
templateUrl: './queue-cleaner.component.html',
styleUrl: './queue-cleaner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QueueCleanerComponent implements 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' },
];
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
private readonly stallRulesResource = rxResource({
stream: () => this.api.getStallRules(),
defaultValue: [] as StallRule[],
});
private readonly slowRulesResource = rxResource({
stream: () => this.api.getSlowRules(),
defaultValue: [] as SlowRule[],
});
readonly loader = new DeferredLoader();
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
private readonly model = signal<QueueCleanerFormModel>({
enabled: false,
useAdvancedScheduling: false,
cronExpression: '',
scheduleEvery: 5,
scheduleUnit: ScheduleUnit.Minutes,
ignoredDownloads: [],
processNoContentId: false,
failedMaxStrikes: 3,
failedIgnorePrivate: false,
failedDeletePrivate: false,
failedSkipNotFound: false,
failedPatterns: [],
failedPatternMode: PatternMode.Exclude,
failedChangeCategory: false,
metadataMaxStrikes: 3,
});
readonly failedSubFieldsDisabled = computed(() => this.model().failedMaxStrikes === 0);
readonly failedDeletePrivateDisabled = computed(() =>
this.failedSubFieldsDisabled() || this.model().failedIgnorePrivate
);
readonly qcForm = form(this.model, (p) => {
required(p.failedMaxStrikes, { message: 'This field is required' });
min(p.failedMaxStrikes, 0, { message: 'Value cannot be negative' });
max(p.failedMaxStrikes, 5000, { message: 'Value cannot exceed 5000' });
required(p.metadataMaxStrikes, { message: 'This field is required' });
min(p.metadataMaxStrikes, 0, { message: 'Value cannot be negative' });
max(p.metadataMaxStrikes, 5000, { message: 'Value cannot exceed 5000' });
validate(p.scheduleEvery, ({ value, valueOf }) => {
if (!valueOf(p.enabled) || valueOf(p.useAdvancedScheduling)) {
return undefined;
}
const options = ScheduleOptions[valueOf(p.scheduleUnit)] ?? [];
return options.includes(value()) ? undefined : { kind: 'schedule', message: 'Please select a value' };
});
validate(p.cronExpression, ({ value, valueOf }) => {
return valueOf(p.enabled) && valueOf(p.useAdvancedScheduling) && !value().trim()
? { kind: 'required', message: 'Cron expression is required' }
: undefined;
});
validate(p.failedPatterns, ({ value, valueOf }) => {
if (valueOf(p.failedMaxStrikes) === 0) {
return undefined;
}
return valueOf(p.failedPatternMode) === PatternMode.Include && value().length === 0
? { kind: 'required', message: 'At least one pattern is required when using Include mode' }
: undefined;
});
disabled(p.failedIgnorePrivate, () => this.model().failedMaxStrikes === 0);
disabled(p.failedChangeCategory, () => this.model().failedMaxStrikes === 0);
disabled(p.failedDeletePrivate, () => this.failedDeletePrivateDisabled());
disabled(p.failedSkipNotFound, () => this.model().failedMaxStrikes === 0);
disabled(p.failedPatternMode, () => this.model().failedMaxStrikes === 0);
disabled(p.failedPatterns, () => this.model().failedMaxStrikes === 0);
});
readonly scheduleIntervalOptions = computed(() => {
const values = ScheduleOptions[this.model().scheduleUnit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
// UI-only expansion state
readonly failedExpanded = signal(true);
readonly metadataExpanded = signal(false);
// Stall rules
readonly stallRules = computed(() => this.stallRulesResource.value());
readonly stallRulesLoading = computed(() => this.stallRulesResource.isLoading());
readonly stallExpanded = signal(false);
readonly stallModalVisible = signal(false);
readonly editingStallRule = signal<StallRule | null>(null);
// Stall rule form
private readonly stallDefaults: StallRuleFormModel = {
name: '', enabled: true, maxStrikes: 3, privacyType: TorrentPrivacyType.Both,
minCompletion: 0, maxCompletion: 100, resetOnProgress: false, minProgress: '',
deletePrivate: false, changeCategory: false,
};
readonly stallModel = signal<StallRuleFormModel>({ ...this.stallDefaults });
readonly stallForm = form(this.stallModel, (p) => {
required(p.name, { message: 'Name is required' });
maxLength(p.name, 100, { message: 'Name cannot exceed 100 characters' });
required(p.maxStrikes, { message: 'This field is required' });
min(p.maxStrikes, 3, { message: 'Min value is 3' });
max(p.maxStrikes, 5000, { message: 'Max value is 5000' });
min(p.minCompletion, 0);
max(p.minCompletion, 100);
min(p.maxCompletion, 1);
max(p.maxCompletion, 100);
validate(p.maxCompletion, ({ value, valueOf }) => {
const minC = valueOf(p.minCompletion) ?? 0;
const maxC = value() ?? 100;
if (maxC <= 0) return { kind: 'completion', message: 'Max percentage must be greater than 0' };
if (maxC < minC) return { kind: 'completion', message: 'Max percentage must be greater than or equal to Min percentage' };
return undefined;
});
disabled(p.deletePrivate, () => this.stallModel().privacyType === TorrentPrivacyType.Public);
});
// Slow rules
readonly slowRules = computed(() => this.slowRulesResource.value());
readonly slowRulesLoading = computed(() => this.slowRulesResource.isLoading());
readonly slowExpanded = signal(false);
readonly slowModalVisible = signal(false);
readonly editingSlowRule = signal<SlowRule | null>(null);
// Slow rule form
private readonly slowDefaults: SlowRuleFormModel = {
name: '', enabled: true, maxStrikes: 3, minSpeed: '', maxTimeHours: 0,
privacyType: TorrentPrivacyType.Both, minCompletion: 0, maxCompletion: 100,
ignoreAboveSize: '', resetOnProgress: false, deletePrivate: false, changeCategory: false,
};
readonly slowModel = signal<SlowRuleFormModel>({ ...this.slowDefaults });
readonly slowForm = form(this.slowModel, (p) => {
required(p.name, { message: 'Name is required' });
maxLength(p.name, 100, { message: 'Name cannot exceed 100 characters' });
required(p.maxStrikes, { message: 'This field is required' });
min(p.maxStrikes, 3, { message: 'Min value is 3' });
max(p.maxStrikes, 5000, { message: 'Max value is 5000' });
min(p.maxTimeHours, 0);
min(p.minCompletion, 0);
max(p.minCompletion, 100);
min(p.maxCompletion, 1);
max(p.maxCompletion, 100);
validate(p.maxCompletion, ({ value, valueOf }) => {
const minC = valueOf(p.minCompletion) ?? 0;
const maxC = value() ?? 100;
if (maxC <= 0) return { kind: 'completion', message: 'Max percentage must be greater than 0' };
if (maxC < minC) return { kind: 'completion', message: 'Max percentage must be greater than or equal to Min percentage' };
return undefined;
});
disabled(p.deletePrivate, () => this.slowModel().privacyType === TorrentPrivacyType.Public);
});
constructor() {
effect(() => {
const unit = this.model().scheduleUnit;
const options = ScheduleOptions[unit] ?? [];
const current = this.model().scheduleEvery;
if (options.length > 0 && !options.includes(current)) {
untracked(() => this.model.update(m => ({ ...m, scheduleEvery: options[0] })));
}
});
// These reset effects guard on the current value: model.update always creates a new object,
// so writing unconditionally would re-trigger the effect forever (infinite loop / page freeze).
effect(() => {
const m = this.model();
if (m.failedIgnorePrivate && m.failedDeletePrivate) {
untracked(() => this.model.update(mm => ({ ...mm, failedDeletePrivate: false })));
}
});
effect(() => {
const m = this.model();
if (m.failedChangeCategory && m.failedDeletePrivate) {
untracked(() => this.model.update(mm => ({ ...mm, failedDeletePrivate: false })));
}
});
effect(() => {
const m = this.stallModel();
if ((m.changeCategory || m.privacyType === TorrentPrivacyType.Public) && m.deletePrivate) {
untracked(() => this.stallModel.update(s => ({ ...s, deletePrivate: false })));
}
});
effect(() => {
const m = this.slowModel();
if ((m.changeCategory || m.privacyType === TorrentPrivacyType.Public) && m.deletePrivate) {
untracked(() => this.slowModel.update(s => ({ ...s, deletePrivate: false })));
}
});
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.config = config;
const parsed = parseCronToJobSchedule(config.cronExpression);
this.model.set({
enabled: config.enabled,
useAdvancedScheduling: config.useAdvancedScheduling,
cronExpression: config.cronExpression,
scheduleEvery: parsed?.every ?? 5,
scheduleUnit: parsed?.type ?? ScheduleUnit.Minutes,
ignoredDownloads: config.ignoredDownloads ?? [],
processNoContentId: config.processNoContentId,
failedMaxStrikes: config.failedImport.maxStrikes,
failedIgnorePrivate: config.failedImport.ignorePrivate,
failedDeletePrivate: config.failedImport.deletePrivate,
failedSkipNotFound: config.failedImport.skipIfNotFoundInClient,
failedPatterns: config.failedImport.patterns ?? [],
failedPatternMode: config.failedImport.patternMode ?? PatternMode.Exclude,
failedChangeCategory: config.failedImport.changeCategory ?? false,
metadataMaxStrikes: config.downloadingMetadataMaxStrikes,
});
this.savedSnapshot.set(this.buildSnapshot());
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load queue cleaner settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
}
});
effect(() => {
if (this.stallRulesResource.error()) {
this.toast.error('Failed to load stall rules');
}
});
effect(() => {
if (this.slowRulesResource.error()) {
this.toast.error('Failed to load slow rules');
}
});
}
readonly patternLabel = computed(() =>
this.model().failedPatternMode === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns'
);
readonly patternHint = computed(() =>
this.model().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()));
readonly hasErrors = computed(() =>
this.qcForm().invalid() || this.chipInputs().some(c => c.hasUncommittedInput())
);
private config: QueueCleanerConfig | null = null;
retry(): void {
this.configResource.reload();
this.stallRulesResource.reload();
this.slowRulesResource.reload();
}
// Stall rule CRUD
openStallModal(rule?: StallRule): void {
this.editingStallRule.set(rule ?? null);
if (rule) {
this.stallModel.set({
name: rule.name,
enabled: rule.enabled,
maxStrikes: rule.maxStrikes,
privacyType: rule.privacyType,
minCompletion: rule.minCompletionPercentage,
maxCompletion: rule.maxCompletionPercentage,
resetOnProgress: rule.resetStrikesOnProgress,
minProgress: rule.minimumProgress ?? '',
deletePrivate: rule.deletePrivateTorrentsFromClient,
changeCategory: rule.changeCategory ?? false,
});
} else {
this.stallModel.set({ ...this.stallDefaults });
}
this.stallModalVisible.set(true);
}
saveStallRule(): void {
if (this.stallForm().invalid()) return;
const m = this.stallModel();
const changeCategory = m.changeCategory;
const dto: CreateStallRuleDto = {
name: m.name.trim(),
enabled: m.enabled,
maxStrikes: m.maxStrikes ?? 3,
privacyType: m.privacyType,
minCompletionPercentage: m.minCompletion ?? 0,
maxCompletionPercentage: m.maxCompletion ?? 100,
resetStrikesOnProgress: m.resetOnProgress,
minimumProgress: m.minProgress.trim() || null,
deletePrivateTorrentsFromClient: changeCategory ? false : m.deletePrivate,
changeCategory,
};
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.stallRulesResource.reload();
},
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.stallRulesResource.reload();
},
error: () => this.toast.error('Failed to delete stall rule'),
});
}
// Slow rule CRUD
openSlowModal(rule?: SlowRule): void {
this.editingSlowRule.set(rule ?? null);
if (rule) {
this.slowModel.set({
name: rule.name,
enabled: rule.enabled,
maxStrikes: rule.maxStrikes,
minSpeed: rule.minSpeed,
maxTimeHours: rule.maxTimeHours,
privacyType: rule.privacyType,
minCompletion: rule.minCompletionPercentage,
maxCompletion: rule.maxCompletionPercentage,
ignoreAboveSize: rule.ignoreAboveSize ?? '',
resetOnProgress: rule.resetStrikesOnProgress,
deletePrivate: rule.deletePrivateTorrentsFromClient,
changeCategory: rule.changeCategory ?? false,
});
} else {
this.slowModel.set({ ...this.slowDefaults });
}
this.slowModalVisible.set(true);
}
saveSlowRule(): void {
if (this.slowForm().invalid()) return;
const m = this.slowModel();
const changeCategory = m.changeCategory;
const dto: CreateSlowRuleDto = {
name: m.name.trim(),
enabled: m.enabled,
maxStrikes: m.maxStrikes ?? 3,
privacyType: m.privacyType,
minCompletionPercentage: m.minCompletion ?? 0,
maxCompletionPercentage: m.maxCompletion ?? 100,
resetStrikesOnProgress: m.resetOnProgress,
minSpeed: m.minSpeed.trim(),
maxTimeHours: m.maxTimeHours ?? 0,
ignoreAboveSize: m.ignoreAboveSize.trim() || undefined,
deletePrivateTorrentsFromClient: changeCategory ? false : m.deletePrivate,
changeCategory,
};
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.slowRulesResource.reload();
},
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.slowRulesResource.reload();
},
error: () => this.toast.error('Failed to delete slow rule'),
});
}
save(): void {
if (!this.config) return;
const m = this.model();
const jobSchedule = { every: m.scheduleEvery ?? 5, type: m.scheduleUnit };
const cronExpression = m.useAdvancedScheduling
? m.cronExpression
: generateCronExpression(jobSchedule);
const config: QueueCleanerConfig = {
...this.config,
enabled: m.enabled,
useAdvancedScheduling: m.useAdvancedScheduling,
cronExpression,
ignoredDownloads: m.ignoredDownloads,
processNoContentId: m.processNoContentId,
failedImport: {
maxStrikes: m.failedMaxStrikes ?? 3,
ignorePrivate: m.failedIgnorePrivate,
deletePrivate: m.failedChangeCategory ? false : m.failedDeletePrivate,
skipIfNotFoundInClient: m.failedSkipNotFound,
patterns: m.failedPatterns,
patternMode: m.failedPatternMode,
changeCategory: m.failedChangeCategory,
},
downloadingMetadataMaxStrikes: m.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(this.model());
}
readonly dirty = computed(() => {
const saved = this.savedSnapshot();
return saved !== '' && saved !== this.buildSnapshot();
});
hasPendingChanges(): boolean {
return this.dirty();
}
}