Improve seeding rule customization (#553)

This commit is contained in:
Flaminel
2026-04-11 17:13:41 +03:00
committed by GitHub
parent 53fc5eff3b
commit 4e9d20db0a
85 changed files with 7364 additions and 2509 deletions

View File

@@ -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}`);

View File

@@ -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',

View File

@@ -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>

View File

@@ -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;

View File

@@ -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({

View File

@@ -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,