mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-09 07:13:59 -04:00
Improve seeding rule customization (#553)
This commit is contained in:
@@ -32,6 +32,10 @@ export class DownloadCleanerApi {
|
||||
return this.http.delete<void>(`/api/seeding-rules/${id}`);
|
||||
}
|
||||
|
||||
reorderSeedingRules(clientId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`/api/seeding-rules/${clientId}/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
// Unlinked config
|
||||
getUnlinkedConfig(clientId: string): Observable<UnlinkedConfigModel | null> {
|
||||
return this.http.get<UnlinkedConfigModel | null>(`/api/unlinked-config/${clientId}`);
|
||||
|
||||
@@ -69,7 +69,11 @@ export class DocumentationService {
|
||||
'scheduleUnit': 'scheduling-mode',
|
||||
'scheduleEvery': 'scheduling-mode',
|
||||
'cronExpression': 'cron-expression',
|
||||
'name': 'category-name',
|
||||
'name': 'rule-name',
|
||||
'categories': 'categories',
|
||||
'trackerPatterns': 'tracker-patterns',
|
||||
'tagsAny': 'tags-any',
|
||||
'tagsAll': 'tags-all',
|
||||
'privacyType': 'privacy-type',
|
||||
'maxRatio': 'max-ratio',
|
||||
'minSeedTime': 'min-seed-time',
|
||||
|
||||
@@ -93,36 +93,63 @@
|
||||
description="Add a seeding rule to start cleaning downloads for this client"
|
||||
/>
|
||||
} @else {
|
||||
@for (rule of client.seedingRules; track rule.id ?? $index) {
|
||||
<div class="rule-card">
|
||||
<div class="rule-card__header">
|
||||
<h4 class="rule-card__name">{{ rule.name }}</h4>
|
||||
<div class="rule-card__actions">
|
||||
<button class="rule-card__action" (click)="openRuleModal(rule)" aria-label="Edit rule">
|
||||
<ng-icon name="tablerPencil" size="16" />
|
||||
</button>
|
||||
<button class="rule-card__action rule-card__action--danger" (click)="deleteRule(rule)" aria-label="Delete rule">
|
||||
<ng-icon name="tablerTrash" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-card__badges">
|
||||
<app-badge severity="info">{{ rule.privacyType }}</app-badge>
|
||||
@if (rule.maxRatio >= 0) {
|
||||
<app-badge>Ratio: {{ rule.maxRatio }}</app-badge>
|
||||
}
|
||||
@if (rule.maxSeedTime >= 0) {
|
||||
<app-badge>Max Seed: {{ rule.maxSeedTime }}h</app-badge>
|
||||
}
|
||||
@if (rule.minSeedTime > 0) {
|
||||
<app-badge>Min Seed: {{ rule.minSeedTime }}h</app-badge>
|
||||
}
|
||||
</div>
|
||||
<div class="rule-card__details">
|
||||
<span>Delete Files: {{ rule.deleteSourceFiles ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
@if (client.seedingRules.length > 1) {
|
||||
<div class="rules-flow-hint">
|
||||
<ng-icon name="tablerArrowDown" size="14" />
|
||||
Rules are evaluated top to bottom · drag to reorder
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
cdkDropList
|
||||
[cdkDropListData]="client.seedingRules"
|
||||
(cdkDropListDropped)="onRulesReorder($event)">
|
||||
@for (rule of client.seedingRules; track rule.id ?? $index) {
|
||||
<div class="rule-card" cdkDrag>
|
||||
<div class="rule-card__header">
|
||||
<button class="rule-card__drag-handle" cdkDragHandle title="Drag to reorder" type="button" aria-label="Drag to reorder">
|
||||
<ng-icon name="tablerGripVertical" size="16" />
|
||||
</button>
|
||||
<app-tooltip text="Evaluation priority. Rules are matched top to bottom, first match wins.">
|
||||
<span class="priority-badge">#{{ $index + 1 }}</span>
|
||||
</app-tooltip>
|
||||
<h4 class="rule-card__name">{{ rule.name }}</h4>
|
||||
<div class="rule-card__actions">
|
||||
<button class="rule-card__action" (click)="openRuleModal(rule)" aria-label="Edit rule">
|
||||
<ng-icon name="tablerPencil" size="16" />
|
||||
</button>
|
||||
<button class="rule-card__action rule-card__action--danger" (click)="deleteRule(rule)" aria-label="Delete rule">
|
||||
<ng-icon name="tablerTrash" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (rule.categories.length) {
|
||||
<div class="rule-card__categories">
|
||||
@for (cat of rule.categories; track cat) {
|
||||
<app-badge severity="default">{{ cat }}</app-badge>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="rule-card__badges">
|
||||
<app-badge severity="info">{{ rule.privacyType }}</app-badge>
|
||||
@if (rule.maxRatio >= 0) {
|
||||
<app-badge>Ratio: {{ rule.maxRatio }}</app-badge>
|
||||
}
|
||||
@if (rule.maxSeedTime >= 0) {
|
||||
<app-badge>Max Seed: {{ rule.maxSeedTime }}h</app-badge>
|
||||
}
|
||||
@if (rule.minSeedTime > 0) {
|
||||
<app-badge>Min Seed: {{ rule.minSeedTime }}h</app-badge>
|
||||
}
|
||||
@if (rule.trackerPatterns.length) {
|
||||
<app-badge severity="warning">{{ rule.trackerPatterns.length }} tracker pattern{{ rule.trackerPatterns.length !== 1 ? 's' : '' }}</app-badge>
|
||||
}
|
||||
</div>
|
||||
<div class="rule-card__details">
|
||||
<span>Delete Files: {{ rule.deleteSourceFiles ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="rule-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="openRuleModal()">
|
||||
@@ -200,13 +227,32 @@
|
||||
<!-- Seeding Rule Modal -->
|
||||
<app-modal [title]="editingRule() ? 'Edit Seeding Rule' : 'Add Seeding Rule'" [(visible)]="ruleModalVisible" size="lg">
|
||||
<div class="form-grid">
|
||||
<app-input label="Category Name" placeholder="tv-sonarr" [(value)]="ruleName"
|
||||
<app-input label="Rule Name" placeholder="My TV rule" [(value)]="ruleName"
|
||||
[error]="ruleNameError()"
|
||||
hint="The category name from your download client (e.g. tv-sonarr, radarr)"
|
||||
hint="A descriptive label for this rule"
|
||||
helpKey="download-cleaner:name" />
|
||||
<app-select label="Privacy Type" [options]="privacyTypeOptions" [(value)]="rulePrivacyType"
|
||||
hint="Which torrent types this rule applies to"
|
||||
helpKey="download-cleaner:privacyType" />
|
||||
<app-chip-input class="full-width" label="Categories" placeholder="Add category..."
|
||||
[(items)]="ruleCategories"
|
||||
[error]="ruleCategoriesError()"
|
||||
hint="One or more download client categories this rule applies to (e.g. tv-sonarr, radarr)"
|
||||
helpKey="download-cleaner:categories" #ruleChipInput />
|
||||
<app-chip-input class="full-width" label="Tracker Patterns" placeholder="Add tracker domain..."
|
||||
[(items)]="ruleTrackerPatterns"
|
||||
hint="Tracker domain suffixes to match (e.g. tracker.org). Empty means any tracker."
|
||||
helpKey="download-cleaner:trackerPatterns" #ruleChipInput />
|
||||
@if (isTagFilterableClient()) {
|
||||
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (Any)' : 'Tags (Any)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
|
||||
[(items)]="ruleTagsAny"
|
||||
[hint]="isSelectedClientTransmission() ? 'Torrent must have at least one of these labels. Empty means any labels.' : 'Torrent must have at least one of these tags. Empty means any tags.'"
|
||||
helpKey="download-cleaner:tagsAny" #ruleChipInput />
|
||||
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (All)' : 'Tags (All)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
|
||||
[(items)]="ruleTagsAll"
|
||||
[hint]="isSelectedClientTransmission() ? 'Torrent must have all of these labels. Empty means any labels.' : 'Torrent must have all of these tags. Empty means any tags.'"
|
||||
helpKey="download-cleaner:tagsAll" #ruleChipInput />
|
||||
}
|
||||
<app-number-input label="Max Ratio" [(value)]="ruleMaxRatio" [step]="0.1" [min]="-1"
|
||||
hint="Maximum ratio to seed before removing (-1 means disabled)"
|
||||
helpKey="download-cleaner:maxRatio" />
|
||||
@@ -225,7 +271,7 @@
|
||||
</div>
|
||||
<div modal-footer>
|
||||
<app-button variant="secondary" (clicked)="ruleModalVisible.set(false)">Cancel</app-button>
|
||||
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleDisabledError()" (clicked)="saveRule()">
|
||||
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleCategoriesError() || !!ruleDisabledError() || ruleHasUncommittedInputs()" (clicked)="saveRule()">
|
||||
{{ editingRule() ? 'Update' : 'Create' }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
@@ -42,11 +42,37 @@
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text-secondary);
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-default), color var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -83,6 +109,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -99,10 +132,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
// CDK drag-and-drop styles for rule cards
|
||||
.cdk-drag-preview {
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
opacity: 0.95;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 250ms var(--ease-default);
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.rules-flow-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rules-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
|
||||
import { NgIconComponent } from '@ng-icons/core';
|
||||
import { CdkDragDrop, CdkDropList, CdkDrag, CdkDragHandle, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
|
||||
TooltipComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
|
||||
@@ -38,9 +40,11 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
|
||||
standalone: true,
|
||||
imports: [
|
||||
NgIconComponent,
|
||||
CdkDropList, CdkDrag, CdkDragHandle,
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
|
||||
TooltipComponent,
|
||||
],
|
||||
templateUrl: './download-cleaner.component.html',
|
||||
styleUrl: './download-cleaner.component.scss',
|
||||
@@ -51,6 +55,11 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirm = inject(ConfirmService);
|
||||
private readonly chipInputs = viewChildren(ChipInputComponent);
|
||||
private readonly ruleChipInputs = viewChildren<ChipInputComponent>('ruleChipInput');
|
||||
|
||||
readonly ruleHasUncommittedInputs = computed(() =>
|
||||
this.ruleChipInputs().some(c => c.hasUncommittedInput())
|
||||
);
|
||||
|
||||
private readonly savedSnapshot = signal('');
|
||||
|
||||
@@ -95,6 +104,15 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.qBittorrent
|
||||
);
|
||||
|
||||
readonly isSelectedClientTransmission = computed(() =>
|
||||
this.selectedClient()?.downloadClientTypeName === DownloadClientTypeName.Transmission
|
||||
);
|
||||
|
||||
readonly isTagFilterableClient = computed(() => {
|
||||
const typeName = this.selectedClient()?.downloadClientTypeName;
|
||||
return typeName === DownloadClientTypeName.qBittorrent || typeName === DownloadClientTypeName.Transmission;
|
||||
});
|
||||
|
||||
readonly seedingRulesExpanded = signal(false);
|
||||
readonly unlinkedExpanded = signal(false);
|
||||
|
||||
@@ -102,6 +120,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
readonly ruleModalVisible = signal(false);
|
||||
readonly editingRule = signal<SeedingRule | null>(null);
|
||||
readonly ruleName = signal('');
|
||||
readonly ruleCategories = signal<string[]>([]);
|
||||
readonly ruleTrackerPatterns = signal<string[]>([]);
|
||||
readonly ruleTagsAny = signal<string[]>([]);
|
||||
readonly ruleTagsAll = signal<string[]>([]);
|
||||
readonly rulePrivacyType = signal<unknown>(TorrentPrivacyType.Public);
|
||||
readonly ruleMaxRatio = signal<number | null>(-1);
|
||||
readonly ruleMinSeedTime = signal<number | null>(0);
|
||||
@@ -143,6 +165,11 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly ruleCategoriesError = computed(() => {
|
||||
if (this.ruleCategories().length === 0) return 'At least one category is required';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
readonly ruleDisabledError = computed(() => {
|
||||
if ((this.ruleMaxRatio() ?? -1) < 0 && (this.ruleMaxSeedTime() ?? -1) < 0) {
|
||||
return 'Both max ratio and max seed time cannot be disabled at the same time';
|
||||
@@ -232,6 +259,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
this.editingRule.set(rule ?? null);
|
||||
if (rule) {
|
||||
this.ruleName.set(rule.name);
|
||||
this.ruleCategories.set([...(rule.categories ?? [])]);
|
||||
this.ruleTrackerPatterns.set([...(rule.trackerPatterns ?? [])]);
|
||||
this.ruleTagsAny.set([...(rule.tagsAny ?? [])]);
|
||||
this.ruleTagsAll.set([...(rule.tagsAll ?? [])]);
|
||||
this.rulePrivacyType.set(rule.privacyType);
|
||||
this.ruleMaxRatio.set(rule.maxRatio);
|
||||
this.ruleMinSeedTime.set(rule.minSeedTime);
|
||||
@@ -239,6 +270,10 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
this.ruleDeleteSourceFiles.set(rule.deleteSourceFiles);
|
||||
} else {
|
||||
this.ruleName.set('');
|
||||
this.ruleCategories.set([]);
|
||||
this.ruleTrackerPatterns.set([]);
|
||||
this.ruleTagsAny.set([]);
|
||||
this.ruleTagsAll.set([]);
|
||||
this.rulePrivacyType.set(TorrentPrivacyType.Public);
|
||||
this.ruleMaxRatio.set(-1);
|
||||
this.ruleMinSeedTime.set(0);
|
||||
@@ -249,12 +284,18 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
}
|
||||
|
||||
saveRule(): void {
|
||||
if (this.ruleNameError() || this.ruleDisabledError()) return;
|
||||
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) return;
|
||||
const clientId = this.selectedClientId();
|
||||
if (!clientId) return;
|
||||
|
||||
const sanitize = (list: string[]) => list.map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
const dto: Partial<SeedingRule> = {
|
||||
name: this.ruleName().trim(),
|
||||
categories: sanitize(this.ruleCategories()),
|
||||
trackerPatterns: sanitize(this.ruleTrackerPatterns()),
|
||||
tagsAny: sanitize(this.ruleTagsAny()),
|
||||
tagsAll: sanitize(this.ruleTagsAll()),
|
||||
privacyType: this.rulePrivacyType() as TorrentPrivacyType,
|
||||
maxRatio: this.ruleMaxRatio() ?? -1,
|
||||
minSeedTime: this.ruleMinSeedTime() ?? 0,
|
||||
@@ -297,6 +338,26 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
});
|
||||
}
|
||||
|
||||
onRulesReorder(event: CdkDragDrop<SeedingRule[]>): void {
|
||||
const clientId = this.selectedClientId();
|
||||
if (!clientId) return;
|
||||
|
||||
const rules = [...(this.selectedClient()?.seedingRules ?? [])];
|
||||
moveItemInArray(rules, event.previousIndex, event.currentIndex);
|
||||
|
||||
this.clientConfigs.update(configs =>
|
||||
configs.map(c => c.downloadClientId === clientId ? { ...c, seedingRules: rules } : c)
|
||||
);
|
||||
|
||||
const orderedIds = rules.map(r => r.id!).filter(Boolean);
|
||||
this.api.reorderSeedingRules(clientId, orderedIds).subscribe({
|
||||
error: () => {
|
||||
this.toast.error('Failed to reorder seeding rules');
|
||||
this.reloadSeedingRules(clientId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private reloadSeedingRules(clientId: string): void {
|
||||
this.rulesReloading.set(true);
|
||||
this.api.getSeedingRules(clientId).subscribe({
|
||||
|
||||
@@ -3,6 +3,11 @@ import { TorrentPrivacyType } from './enums';
|
||||
export interface SeedingRule {
|
||||
id?: string;
|
||||
name: string;
|
||||
categories: string[];
|
||||
trackerPatterns: string[];
|
||||
tagsAny?: string[];
|
||||
tagsAll?: string[];
|
||||
priority: number;
|
||||
privacyType: TorrentPrivacyType;
|
||||
maxRatio: number;
|
||||
minSeedTime: number;
|
||||
@@ -40,6 +45,11 @@ export interface DownloadCleanerConfig {
|
||||
export function createDefaultSeedingRule(): SeedingRule {
|
||||
return {
|
||||
name: '',
|
||||
categories: [],
|
||||
trackerPatterns: [],
|
||||
tagsAny: [],
|
||||
tagsAll: [],
|
||||
priority: 0,
|
||||
privacyType: TorrentPrivacyType.Public,
|
||||
maxRatio: -1,
|
||||
minSeedTime: 0,
|
||||
|
||||
Reference in New Issue
Block a user