Upgrade frontend to Angular v22 (#644)

This commit is contained in:
Flaminel authored and GitHub committed 2026-07-01 21:16:26 +03:00
1 parent 9fd99bd5f5
commit be3db1616e
91 files changed
+5073 -3974

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '26'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 26.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 26
- name: Install E2E dependencies
working-directory: e2e
+11 -5
View File
@@ -46,11 +46,13 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -69,7 +71,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── frontend/ # Angular 22 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -98,6 +100,8 @@ Cleanuparr/
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
@@ -183,7 +187,9 @@ make migrate-users name=YourMigrationName
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
+1 -1
View File
@@ -27,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Node.js 26+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
+1 -1
View File
@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Shouldly;
using Xunit;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.MalwareBlocker;
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
+37
View File
@@ -0,0 +1,37 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'public/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
prettier,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
},
},
{
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {},
},
);
+1626 -1469
View File
File diff suppressed because it is too large. Load diff
+25 -20
View File
@@ -5,7 +5,8 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development"
"watch": "ng build --watch --configuration development",
"lint": "eslint ."
},
"prettier": {
"printWidth": 100,
@@ -21,19 +22,26 @@
},
"private": true,
"packageManager": "npm@11.6.2",
"engines": {
"node": ">=26"
},
"overrides": {
"angularx-qrcode": {
"@angular/common": "$@angular/common",
"@angular/core": "$@angular/core"
}
},
"dependencies": {
"@angular/animations": "^21.1.3",
"@angular/cdk": "^21.1.3",
"@angular/common": "^21.1.0",
"@angular/compiler": "^21.1.0",
"@angular/core": "^21.1.0",
"@angular/forms": "^21.1.0",
"@angular/platform-browser": "^21.1.0",
"@angular/router": "^21.1.0",
"@angular/cdk": "^22.0.2",
"@angular/common": "^22.0.4",
"@angular/compiler": "^22.0.4",
"@angular/core": "^22.0.4",
"@angular/forms": "^22.0.4",
"@angular/platform-browser": "^22.0.4",
"@angular/router": "^22.0.4",
"@microsoft/signalr": "^10.0.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/tabler-icons": "^33.0.0",
"@ngrx/signals": "^21.0.1",
"@tailwindcss/postcss": "^4.1.18",
"angularx-qrcode": "^21.0.4",
"postcss": "^8.5.6",
@@ -42,18 +50,15 @@
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular-eslint/builder": "^21.2.0",
"@angular-eslint/eslint-plugin": "^21.2.0",
"@angular-eslint/eslint-plugin-template": "^21.2.0",
"@angular-eslint/template-parser": "^21.2.0",
"@angular/build": "^21.1.3",
"@angular/cli": "^21.1.3",
"@angular/compiler-cli": "^21.1.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@angular/build": "^22.0.4",
"@angular/cli": "^22.0.4",
"@angular/compiler-cli": "^22.0.4",
"@eslint/js": "^9.39.4",
"angular-eslint": "^22.0.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"typescript": "~5.9.2"
"typescript": "~6.0.3",
"typescript-eslint": "^8.62.1"
}
}
+3 -3
View File
@@ -1,7 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideIcons } from '@ng-icons/core';
import {
tablerLayoutDashboard,
@@ -65,8 +64,9 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
provideHttpClient(
withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor]),
),
provideIcons({
tablerLayoutDashboard,
tablerFileText,
+2 -1
View File
@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from '@core/services/theme.service';
import { AuthService } from '@core/auth/auth.service';
@@ -8,6 +8,7 @@ import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ToastContainerComponent, ConfirmDialogComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<router-outlet />
<app-toast-container />
@@ -11,7 +11,7 @@ import { Directive, ElementRef, OnDestroy, OnInit, inject } from '@angular/core'
* drops below 1, and the class flips on.
*/
@Directive({
selector: '[stickyAware]',
selector: '[appStickyAware]',
standalone: true,
})
export class StickyAwareDirective implements OnInit, OnDestroy {
@@ -101,8 +101,12 @@ export abstract class HubService implements OnDestroy {
return this.connection.invoke(method, ...args);
}
protected onConnected(): void {}
protected onReconnected(): void {}
protected onConnected(): void {
// Optional hook for subclasses.
}
protected onReconnected(): void {
// Optional hook for subclasses.
}
ngOnDestroy(): void {
this.stop();
@@ -7,7 +7,7 @@ export class ApplicationPathService {
if (isDevMode()) {
return 'http://localhost:5000';
}
return (window as any)['_server_base_path'] || '/';
return (window as unknown as { _server_base_path?: string })._server_base_path || '/';
}
getDocumentationBaseUrl(): string {
@@ -1,9 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { ApplicationPathService } from './base-path.service';
interface FieldMappings {
[section: string]: { [field: string]: string };
}
type FieldMappings = Record<string, Record<string, string>>;
@Injectable({ providedIn: 'root' })
export class DocumentationService {
@@ -0,0 +1,52 @@
import { Injectable, signal, effect, inject, DestroyRef, Signal } from '@angular/core';
/**
* Tracks the stack of currently-open overlays (modals, drawers, confirm dialog,
* mobile menu) in open order, so a single Escape press dismisses only the
* top-most overlay instead of every open one closing at once.
*/
@Injectable({ providedIn: 'root' })
export class OverlayStackService {
private readonly stack = signal<number[]>([]);
private counter = 0;
register(): number {
const id = ++this.counter;
this.stack.update((s) => [...s, id]);
return id;
}
unregister(id: number): void {
this.stack.update((s) => s.filter((x) => x !== id));
}
isTopmost(id: number): boolean {
const s = this.stack();
return s.length > 0 && s[s.length - 1] === id;
}
}
/**
* Registers an overlay in the shared stack while `isOpen` is true and unregisters
* it when it closes or the host is destroyed. Returns a predicate that reports
* whether this overlay is currently top-most (for Escape handling).
* Must be called in an injection context.
*/
export function registerOverlayEffect(isOpen: Signal<unknown>): () => boolean {
const overlays = inject(OverlayStackService);
let overlayId: number | null = null;
effect(() => {
if (isOpen()) {
overlayId ??= overlays.register();
} else if (overlayId !== null) {
overlays.unregister(overlayId);
overlayId = null;
}
});
inject(DestroyRef).onDestroy(() => {
if (overlayId !== null) {
overlays.unregister(overlayId);
}
});
return () => overlayId !== null && overlays.isTopmost(overlayId);
}
@@ -27,7 +27,6 @@ export class PaginationService {
key: string,
pageSize: WritableSignal<number>,
currentPage: WritableSignal<number>,
reload: () => void,
): (size: number) => void {
return (size: number) => {
if (!this.isValidPageSize(size)) {
@@ -36,7 +35,6 @@ export class PaginationService {
this.setPageSize(key, size);
pageSize.set(size);
currentPage.set(1);
reload();
};
}
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, computed, signal, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { Router, RouterLink } from '@angular/router';
import { DatePipe, JsonPipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
@@ -9,9 +11,8 @@ import { AppHubService } from '@core/realtime/app-hub.service';
import { EventsApi } from '@core/api/events.api';
import { JobsApi } from '@core/api/jobs.api';
import { GeneralConfigApi } from '@core/api/general-config.api';
import { CfScoreApi, CfScoreStats, CfScoreUpgrade } from '@core/api/cf-score.api';
import { CfScoreApi, CfScoreStats, CfScoreUpgradesResponse } from '@core/api/cf-score.api';
import { ToastService } from '@core/services/toast.service';
import { LogEntry } from '@core/models/signalr.models';
import { ManualEvent } from '@core/models/event.models';
import { JobType } from '@shared/models/enums';
@@ -40,7 +41,7 @@ type DashboardRowId = typeof DEFAULT_ROW_ORDER[number];
styleUrl: './dashboard.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent implements OnInit {
export class DashboardComponent {
readonly JobType = JobType;
private readonly hub = inject(AppHubService);
@@ -53,9 +54,24 @@ export class DashboardComponent implements OnInit {
readonly connected = this.hub.isConnected;
readonly jobs = this.hub.jobs;
readonly showSupportSection = signal(false);
readonly cfScoreStats = signal<CfScoreStats | null>(null);
readonly cfScoreUpgrades = signal<CfScoreUpgrade[]>([]);
private readonly generalConfigResource = rxResource({
stream: () => this.generalConfigApi.get(),
});
private readonly cfScoreStatsResource = rxResource({
stream: (): Observable<CfScoreStats | null> => this.cfScoreApi.getStats(),
defaultValue: null,
});
private readonly cfScoreUpgradesResource = rxResource({
stream: () => this.cfScoreApi.getRecentUpgrades({ page: 1, pageSize: 5 }),
defaultValue: { items: [], page: 1, pageSize: 5, totalCount: 0, totalPages: 0 } as CfScoreUpgradesResponse,
});
readonly showSupportSection = computed(() =>
this.generalConfigResource.hasValue() ? this.generalConfigResource.value().displaySupportBanner : false,
);
readonly cfScoreStats = computed(() => this.cfScoreStatsResource.value());
readonly cfScoreUpgrades = computed(() => this.cfScoreUpgradesResource.value().items);
readonly rowOrder = signal<DashboardRowId[]>(this.loadOrder());
readonly visibleRowOrder = computed(() => {
@@ -84,22 +100,6 @@ export class DashboardComponent implements OnInit {
this.manualEventIndex() < this.unresolvedManualEvents().length - 1
);
ngOnInit(): void {
this.generalConfigApi.get().subscribe({
next: (config) => this.showSupportSection.set(config.displaySupportBanner),
});
this.loadCfScoreData();
}
private loadCfScoreData(): void {
this.cfScoreApi.getStats().subscribe({
next: (stats) => this.cfScoreStats.set(stats),
});
this.cfScoreApi.getRecentUpgrades({ page: 1, pageSize: 5 }).subscribe({
next: (res) => this.cfScoreUpgrades.set(res.items),
});
}
// Manual event navigation
prevManualEvent(): void {
if (this.canNavigatePrev()) {
@@ -5,7 +5,7 @@
<div class="page-content">
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-select
placeholder="All Severities"
@@ -84,9 +84,13 @@
<div
class="event-row__main"
[class.event-row__main--expandable]="isExpandable(event)"
[attr.role]="isExpandable(event) ? 'button' : null"
[attr.tabindex]="isExpandable(event) ? 0 : null"
(click)="isExpandable(event) ? toggleExpand(event.id) : null"
(keydown.enter)="isExpandable(event) ? toggleExpand(event.id) : null"
(keydown.space)="isExpandable(event) ? toggleExpand(event.id) : null"
>
<button class="event-row__copy" (click)="copyEvent(event); $event.stopPropagation()" title="Copy event">
<button class="event-row__copy" (click)="copyEvent(event); $event.stopPropagation()" (keydown.enter)="$event.stopPropagation()" (keydown.space)="$event.stopPropagation()" title="Copy event">
<ng-icon name="tablerCopy" />
</button>
<span class="event-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</span>
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
@@ -14,6 +15,7 @@ import { PaginationService } from '@core/services/pagination.service';
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
import { AppEvent, EventFilter } from '@core/models/event.models';
import { PaginatedResult } from '@core/models/pagination.model';
@Component({
selector: 'app-events',
@@ -47,9 +49,6 @@ export class EventsComponent implements OnInit, OnDestroy {
private readonly pagination = inject(PaginationService);
private pollTimer: ReturnType<typeof setInterval> | null = null;
readonly events = signal<AppEvent[]>([]);
readonly totalRecords = signal(0);
readonly loading = signal(false);
readonly expandedId = signal<string | null>(null);
readonly showExportMenu = signal(false);
readonly selectedJobRunId = signal<string | null>(null);
@@ -62,22 +61,7 @@ export class EventsComponent implements OnInit, OnDestroy {
readonly fromDate = signal('');
readonly toDate = signal('');
readonly severityOptions = signal<SelectOption[]>([{ label: 'All Severities', value: '' }]);
readonly typeOptions = signal<SelectOption[]>([{ label: 'All Types', value: '' }]);
ngOnInit(): void {
this.loadFilterOptions();
this.loadEvents();
this.pollTimer = setInterval(() => this.loadEvents(), 10_000);
}
ngOnDestroy(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
}
}
loadEvents(): void {
private readonly eventFilter = computed<EventFilter>(() => {
const filter: EventFilter = {
page: this.currentPage(),
pageSize: this.pageSize(),
@@ -87,64 +71,86 @@ export class EventsComponent implements OnInit, OnDestroy {
const search = this.searchQuery();
const from = this.fromDate();
const to = this.toDate();
const jobRunId = this.selectedJobRunId();
if (severity) filter.severity = severity;
if (type) filter.eventType = type;
if (search) filter.search = search;
if (from) filter.fromDate = from;
if (to) filter.toDate = to;
if (jobRunId) filter.jobRunId = jobRunId;
if (severity) {
filter.severity = severity;
}
if (type) {
filter.eventType = type;
}
if (search) {
filter.search = search;
}
if (from) {
filter.fromDate = from;
}
if (to) {
filter.toDate = to;
}
if (jobRunId) {
filter.jobRunId = jobRunId;
}
return filter;
});
this.loading.set(true);
this.eventsApi.getEvents(filter).subscribe({
next: (result) => {
this.events.set(result.items);
this.totalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
private readonly eventsResource = rxResource({
params: () => this.eventFilter(),
stream: ({ params }) => this.eventsApi.getEvents(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as PaginatedResult<AppEvent>,
});
private readonly severitiesResource = rxResource({
stream: () => this.eventsApi.getSeverities(),
defaultValue: [] as string[],
});
private readonly eventTypesResource = rxResource({
stream: () => this.eventsApi.getEventTypes(),
defaultValue: [] as string[],
});
readonly events = computed(() => this.eventsResource.value().items);
readonly totalRecords = computed(() => this.eventsResource.value().totalCount);
readonly severityOptions = computed<SelectOption[]>(() => [
{ label: 'All Severities', value: '' },
...this.severitiesResource.value().map((s) => ({ label: s, value: s })),
]);
readonly typeOptions = computed<SelectOption[]>(() => [
{ label: 'All Types', value: '' },
...this.eventTypesResource.value().map((t) => ({ label: this.formatEventType(t), value: t })),
]);
constructor() {
effect(() => {
if (this.eventsResource.error()) {
this.toast.error('Failed to load events');
},
}
});
}
private loadFilterOptions(): void {
this.eventsApi.getSeverities().subscribe({
next: (severities) => {
this.severityOptions.set([
{ label: 'All Severities', value: '' },
...severities.map((s) => ({ label: s, value: s })),
]);
},
});
this.eventsApi.getEventTypes().subscribe({
next: (types) => {
this.typeOptions.set([
{ label: 'All Types', value: '' },
...types.map((t) => ({ label: this.formatEventType(t), value: t })),
]);
},
});
ngOnInit(): void {
this.pollTimer = setInterval(() => this.eventsResource.reload(), 10_000);
}
ngOnDestroy(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
}
}
onFilterChange(): void {
this.currentPage.set(1);
this.loadEvents();
}
onPageChange(page: number): void {
this.currentPage.set(page);
this.loadEvents();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
EventsComponent.PAGE_SIZE_KEY,
this.pageSize,
this.currentPage,
() => this.loadEvents(),
);
isExpandable(event: AppEvent): boolean {
@@ -162,19 +168,17 @@ export class EventsComponent implements OnInit, OnDestroy {
}
refresh(): void {
this.loadEvents();
this.eventsResource.reload();
}
filterByJobRunId(runId: string): void {
this.selectedJobRunId.set(runId);
this.currentPage.set(1);
this.loadEvents();
}
clearJobRunFilter(): void {
this.selectedJobRunId.set(null);
this.currentPage.set(1);
this.loadEvents();
}
viewLogsForJobRun(runId: string): void {
@@ -5,7 +5,7 @@
<div class="page-content">
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-select
placeholder="All Levels"
@@ -83,9 +83,13 @@
<div
class="log-entry__row"
[class.log-entry__row--expandable]="isExpandable(log)"
[attr.role]="isExpandable(log) ? 'button' : null"
[attr.tabindex]="isExpandable(log) ? 0 : null"
(click)="isExpandable(log) ? toggleExpand($index) : null"
(keydown.enter)="isExpandable(log) ? toggleExpand($index) : null"
(keydown.space)="isExpandable(log) ? toggleExpand($index) : null"
>
<button class="log-entry__copy" (click)="copyLog(log); $event.stopPropagation()" title="Copy log">
<button class="log-entry__copy" (click)="copyLog(log); $event.stopPropagation()" (keydown.enter)="$event.stopPropagation()" (keydown.space)="$event.stopPropagation()" title="Copy log">
<ng-icon name="tablerCopy" />
</button>
<span class="log-entry__time">{{ log.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</span>
@@ -1,5 +1,5 @@
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -74,7 +74,11 @@
>
<div
class="score-row__main"
role="button"
tabindex="0"
(click)="toggleExpand(item)"
(keydown.enter)="toggleExpand(item)"
(keydown.space)="toggleExpand(item)"
>
<ng-icon name="tablerChartBar" class="score-row__icon" />
<span class="score-row__scores">
@@ -98,7 +102,14 @@
class="score-row__chevron"
/>
</div>
<div class="score-row__title" (click)="toggleExpand(item)">
<div
class="score-row__title"
role="button"
tabindex="0"
(click)="toggleExpand(item)"
(keydown.enter)="toggleExpand(item)"
(keydown.space)="toggleExpand(item)"
>
{{ item.title }}
</div>
@@ -176,8 +187,8 @@
<!-- Filter drawer -->
<app-drawer title="Filter quality scores" [(visible)]="drawerOpen">
<div class="filter-group">
<label class="filter-group__label">Instance</label>
<app-select
label="Instance"
[value]="draft().instanceId"
[options]="instanceOptions()"
(valueChange)="updateDraft('instanceId', $any($event))"
@@ -185,8 +196,8 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Quality profile</label>
<app-select
label="Quality profile"
[value]="draft().qualityProfile"
[options]="qualityProfileOptions()"
(valueChange)="updateDraft('qualityProfile', $any($event))"
@@ -194,8 +205,8 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Cutoff status</label>
<app-select
label="Cutoff status"
[value]="draft().cutoffFilter"
[options]="cutoffOptions"
(valueChange)="updateDraft('cutoffFilter', $any($event))"
@@ -203,8 +214,8 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Monitored</label>
<app-select
label="Monitored"
[value]="draft().monitoredFilter"
[options]="monitoredOptions"
(valueChange)="updateDraft('monitoredFilter', $any($event))"
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -10,6 +12,7 @@ import type { SelectOption } from '@ui';
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
import {
CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry, CfScoreInstance,
CfScoreEntriesResponse, CfScoresQuery,
CutoffFilter, MonitoredFilter, CfScoresSortBy, SortDirection,
} from '@core/api/cf-score.api';
import { AppHubService } from '@core/realtime/app-hub.service';
@@ -56,7 +59,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
styleUrl: './quality-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QualityTabComponent implements OnInit {
export class QualityTabComponent {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-quality';
private readonly api = inject(CfScoreApi);
@@ -64,19 +67,11 @@ export class QualityTabComponent implements OnInit {
private readonly toast = inject(ToastService);
private readonly pagination = inject(PaginationService);
private initialLoad = true;
private latestLoadToken = 0;
readonly items = signal<CfScoreEntry[]>([]);
readonly stats = signal<CfScoreStats | null>(null);
readonly totalRecords = signal(0);
readonly loading = signal(false);
readonly currentPage = signal(1);
readonly pageSize = signal(this.pagination.getPageSize(QualityTabComponent.PAGE_SIZE_KEY, 50));
readonly searchQuery = signal('');
readonly selectedInstanceId = signal<string>('');
readonly instances = signal<CfScoreInstance[]>([]);
readonly instanceOptions = signal<SelectOption[]>([]);
readonly sortBy = signal<CfScoresSortBy>(DEFAULT_SORT_BY);
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
@@ -99,6 +94,46 @@ export class QualityTabComponent implements OnInit {
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
readonly drawerOpen = signal(false);
private readonly scoresParams = computed<CfScoresQuery>(() => {
const a = this.applied();
return {
page: this.currentPage(),
pageSize: this.pageSize(),
search: this.searchQuery() || undefined,
instanceId: this.selectedInstanceId() || undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
qualityProfile: a.qualityProfile || undefined,
cutoffFilter: a.cutoffFilter,
monitoredFilter: a.monitoredFilter,
};
});
private readonly scoresResource = rxResource({
params: () => this.scoresParams(),
stream: ({ params }) => this.api.getScores(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as CfScoreEntriesResponse,
});
private readonly statsResource = rxResource({
stream: (): Observable<CfScoreStats | null> => this.api.getStats(),
defaultValue: null,
});
private readonly instancesResource = rxResource({
stream: () => this.api.getInstances(),
defaultValue: { instances: [] as CfScoreInstance[] },
});
readonly items = computed(() => this.scoresResource.value().items);
readonly totalRecords = computed(() => this.scoresResource.value().totalCount);
readonly stats = computed(() => this.statsResource.value());
readonly instances = computed(() => this.instancesResource.value().instances);
readonly instanceOptions = computed<SelectOption[]>(() => [
{ label: 'All Instances', value: '' },
...this.instancesResource.value().instances.map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })),
]);
readonly displayStats = computed(() => {
const s = this.stats();
if (!s) return null;
@@ -160,98 +195,48 @@ export class QualityTabComponent implements OnInit {
this.initialLoad = false;
return;
}
untracked(() => {
this.loadScores();
this.loadStats();
});
this.scoresResource.reload();
this.statsResource.reload();
});
}
ngOnInit(): void {
this.loadInstances();
this.loadScores();
this.loadStats();
}
loadScores(): void {
this.loading.set(true);
const loadToken = ++this.latestLoadToken;
const a = this.applied();
this.api.getScores({
page: this.currentPage(),
pageSize: this.pageSize(),
search: this.searchQuery() || undefined,
instanceId: this.selectedInstanceId() || undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
qualityProfile: a.qualityProfile || undefined,
cutoffFilter: a.cutoffFilter,
monitoredFilter: a.monitoredFilter,
}).subscribe({
next: (result) => {
if (loadToken !== this.latestLoadToken) return;
this.items.set(result.items);
this.totalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
if (loadToken !== this.latestLoadToken) return;
this.loading.set(false);
effect(() => {
if (this.scoresResource.error()) {
this.toast.error('Failed to load CF scores');
},
}
});
}
private loadInstances(): void {
this.api.getInstances().subscribe({
next: (result) => {
this.instances.set(result.instances);
this.instanceOptions.set([
{ label: 'All Instances', value: '' },
...result.instances.map(i => ({
label: `${i.name} (${i.itemType})`,
value: i.id,
})),
]);
},
error: () => this.toast.error('Failed to load instances'),
effect(() => {
if (this.statsResource.error()) {
this.toast.error('Failed to load CF score stats');
}
});
}
private loadStats(): void {
this.api.getStats().subscribe({
next: (stats) => this.stats.set(stats),
error: () => this.toast.error('Failed to load CF score stats'),
effect(() => {
if (this.instancesResource.error()) {
this.toast.error('Failed to load instances');
}
});
}
onFilterChange(): void {
this.currentPage.set(1);
this.loadScores();
}
onSortByChange(value: CfScoresSortBy): void {
this.sortBy.set(value);
this.currentPage.set(1);
this.loadScores();
}
onSortOrderChange(value: SortDirection): void {
this.sortDirection.set(value);
this.currentPage.set(1);
this.loadScores();
}
onPageChange(page: number): void {
this.currentPage.set(page);
this.loadScores();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
QualityTabComponent.PAGE_SIZE_KEY,
this.pageSize,
this.currentPage,
() => this.loadScores(),
);
openFilters(): void {
@@ -277,7 +262,6 @@ export class QualityTabComponent implements OnInit {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.currentPage.set(1);
this.loadScores();
}
private collectProfilesFor(instanceId: string): Set<string> {
@@ -296,8 +280,8 @@ export class QualityTabComponent implements OnInit {
}
refresh(): void {
this.loadScores();
this.loadStats();
this.scoresResource.reload();
this.statsResource.reload();
}
toggleExpand(item: CfScoreEntry): void {
@@ -101,7 +101,7 @@
}
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -200,7 +200,7 @@
<!-- Filter drawer -->
<app-drawer title="Filter searches" [(visible)]="drawerOpen">
<div class="filter-group">
<label class="filter-group__label">Instance</label>
<span class="filter-group__label">Instance</span>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -209,7 +209,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Cycle</label>
<span class="filter-group__label">Cycle</span>
<app-select
[value]="draft().cycleFilter"
[options]="cycleFilterOptions"
@@ -222,7 +222,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<span class="filter-group__label">Status</span>
<div class="chip-group">
@for (opt of statusOptions; track opt.value) {
<button
@@ -237,7 +237,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Search type</label>
<span class="filter-group__label">Search type</span>
<app-select
[value]="draft().searchType"
[options]="searchTypeOptions"
@@ -246,7 +246,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Search reason</label>
<span class="filter-group__label">Search reason</span>
<app-select
[value]="draft().searchReason"
[options]="searchReasonOptions"
@@ -255,7 +255,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Grabbed</label>
<span class="filter-group__label">Grabbed</span>
<app-select
[value]="draft().grabbed"
[options]="triStateOptions"
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -10,7 +12,9 @@ import type { SelectOption } from '@ui';
import type { BadgeSeverity } from '@ui/badge/badge.component';
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
import { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api';
import type { SearchEventsQuery } from '@core/api/search-stats.api';
import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models';
import type { PaginatedResult } from '@core/models/pagination.model';
import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models';
import { AppHubService } from '@core/realtime/app-hub.service';
import { ToastService } from '@core/services/toast.service';
@@ -41,7 +45,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
grabbed: 'any',
};
const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string }> = [
const STATUS_OPTIONS: readonly { value: SearchCommandStatus; label: string }[] = [
{ value: SearchCommandStatus.Started, label: 'Started' },
{ value: SearchCommandStatus.Completed, label: 'Completed' },
{ value: SearchCommandStatus.Failed, label: 'Failed' },
@@ -70,7 +74,7 @@ const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string
styleUrl: './searches-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchesTabComponent implements OnInit {
export class SearchesTabComponent {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-searches';
private readonly api = inject(SearchStatsApi);
@@ -78,10 +82,13 @@ export class SearchesTabComponent implements OnInit {
private readonly toast = inject(ToastService);
private readonly pagination = inject(PaginationService);
private initialLoad = true;
private latestLoadToken = 0;
readonly summary = signal<SearchStatsSummary | null>(null);
readonly loading = signal(false);
private readonly summaryResource = rxResource({
stream: (): Observable<SearchStatsSummary | null> => this.api.getSummary(),
defaultValue: null,
});
readonly summary = computed(() => this.summaryResource.value());
readonly sortedInstanceStats = computed(() =>
[...(this.summary()?.perInstanceStats ?? [])].sort((a, b) => {
@@ -91,7 +98,12 @@ export class SearchesTabComponent implements OnInit {
);
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = signal<SelectOption[]>([]);
readonly instanceOptions = computed<SelectOption[]>(() => {
return [
{ label: 'All Instances', value: '' },
...(this.summaryResource.value()?.perInstanceStats ?? []).map((st) => ({ label: st.instanceName, value: st.instanceId })),
];
});
readonly searchQuery = signal('');
@@ -103,11 +115,46 @@ export class SearchesTabComponent implements OnInit {
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
readonly drawerOpen = signal(false);
readonly events = signal<SearchEvent[]>([]);
readonly eventsTotalRecords = signal(0);
readonly eventsPage = signal(1);
readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50));
private readonly eventsParams = computed<SearchEventsQuery>(() => {
const instanceId = this.selectedInstanceId() || undefined;
const search = this.searchQuery() || undefined;
const a = this.applied();
let cycleId: string | undefined;
if (a.cycleFilter === 'current' && instanceId) {
const instance = this.summaryResource.value()?.perInstanceStats.find((s) => s.instanceId === instanceId);
cycleId = instance?.currentCycleId ?? undefined;
}
const triToBool = (v: TriState): boolean | undefined => (v === 'any' ? undefined : v === 'true');
return {
page: this.eventsPage(),
pageSize: this.pageSize(),
instanceId,
cycleId,
search,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
searchStatus: a.statuses.length ? a.statuses : undefined,
searchType: a.searchType || undefined,
searchReason: a.searchReason || undefined,
grabbed: triToBool(a.grabbed),
};
});
private readonly eventsResource = rxResource({
params: () => this.eventsParams(),
stream: ({ params }) => this.api.getEvents(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as PaginatedResult<SearchEvent>,
});
readonly events = computed(() => this.eventsResource.value().items);
readonly eventsTotalRecords = computed(() => this.eventsResource.value().totalCount);
readonly sortOptions: SelectOption[] = [
{ label: 'Timestamp', value: SearchEventsSortBy.Timestamp },
{ label: 'Title', value: SearchEventsSortBy.Title },
@@ -166,45 +213,43 @@ export class SearchesTabComponent implements OnInit {
this.initialLoad = false;
return;
}
untracked(() => {
this.loadSummary();
this.loadEvents();
});
this.summaryResource.reload();
this.eventsResource.reload();
});
effect(() => {
if (this.summaryResource.error()) {
this.toast.error('Failed to load search stats');
}
});
effect(() => {
if (this.eventsResource.error()) {
this.toast.error('Failed to load search events');
}
});
}
ngOnInit(): void {
this.loadSummary();
this.loadEvents();
}
onSearchFilterChange(): void {
this.eventsPage.set(1);
this.loadEvents();
}
onEventsPageChange(page: number): void {
this.eventsPage.set(page);
this.loadEvents();
}
onSortByChange(value: SearchEventsSortBy): void {
this.sortBy.set(value);
this.eventsPage.set(1);
this.loadEvents();
}
onSortOrderChange(value: SortDirection): void {
this.sortDirection.set(value);
this.eventsPage.set(1);
this.loadEvents();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
SearchesTabComponent.PAGE_SIZE_KEY,
this.pageSize,
this.eventsPage,
() => this.loadEvents(),
);
openFilters(): void {
@@ -222,7 +267,6 @@ export class SearchesTabComponent implements OnInit {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.eventsPage.set(1);
this.loadEvents();
}
toggleStatus(value: SearchCommandStatus): void {
@@ -249,8 +293,8 @@ export class SearchesTabComponent implements OnInit {
}
refresh(): void {
this.loadSummary();
this.loadEvents();
this.summaryResource.reload();
this.eventsResource.reload();
}
searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' {
@@ -325,62 +369,4 @@ export class SearchesTabComponent implements OnInit {
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${diffMinutes}m`;
}
private loadSummary(): void {
this.api.getSummary().subscribe({
next: (summary) => {
this.summary.set(summary);
this.instanceOptions.set([
{ label: 'All Instances', value: '' },
...summary.perInstanceStats.map(s => ({
label: s.instanceName,
value: s.instanceId,
})),
]);
},
error: () => this.toast.error('Failed to load search stats'),
});
}
private loadEvents(): void {
this.loading.set(true);
const loadToken = ++this.latestLoadToken;
const instanceId = this.selectedInstanceId() || undefined;
const search = this.searchQuery() || undefined;
const a = this.applied();
let cycleId: string | undefined;
if (a.cycleFilter === 'current' && instanceId) {
const instance = this.summary()?.perInstanceStats.find(s => s.instanceId === instanceId);
cycleId = instance?.currentCycleId ?? undefined;
}
const triToBool = (v: TriState): boolean | undefined => v === 'any' ? undefined : v === 'true';
this.api.getEvents({
page: this.eventsPage(),
pageSize: this.pageSize(),
instanceId,
cycleId,
search,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
searchStatus: a.statuses.length ? a.statuses : undefined,
searchType: a.searchType || undefined,
searchReason: a.searchReason || undefined,
grabbed: triToBool(a.grabbed),
}).subscribe({
next: (result) => {
if (loadToken !== this.latestLoadToken) return;
this.events.set(result.items);
this.eventsTotalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
if (loadToken !== this.latestLoadToken) return;
this.loading.set(false);
this.toast.error('Failed to load search events');
},
});
}
}
@@ -7,8 +7,6 @@ import { SearchesTabComponent } from './searches-tab/searches-tab.component';
import { QualityTabComponent } from './quality-tab/quality-tab.component';
import { UpgradesTabComponent } from './upgrades-tab/upgrades-tab.component';
type SeekerTab = 'searches' | 'quality' | 'upgrades';
@Component({
selector: 'app-seeker-stats',
standalone: true,
@@ -1,5 +1,5 @@
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -86,7 +86,7 @@
<!-- Filter drawer -->
<app-drawer title="Filter upgrades" [(visible)]="drawerOpen">
<div class="filter-group">
<label class="filter-group__label">Instance</label>
<span class="filter-group__label">Instance</span>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -95,7 +95,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Time range</label>
<span class="filter-group__label">Time range</span>
<app-select
[value]="draft().timeRange"
[options]="timeRangeOptions"
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -8,7 +9,10 @@ import {
} from '@ui';
import type { SelectOption } from '@ui';
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
import { CfScoreApi, CfScoreUpgrade, CfUpgradesSortBy, SortDirection } from '@core/api/cf-score.api';
import {
CfScoreApi, CfScoreUpgradesResponse, CfScoreUpgradesQuery,
CfScoreInstance, CfUpgradesSortBy, SortDirection,
} from '@core/api/cf-score.api';
import { AppHubService } from '@core/realtime/app-hub.service';
import { ToastService } from '@core/services/toast.service';
import { PaginationService } from '@core/services/pagination.service';
@@ -48,7 +52,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
styleUrl: './upgrades-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UpgradesTabComponent implements OnInit {
export class UpgradesTabComponent {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-upgrades';
private readonly api = inject(CfScoreApi);
@@ -56,17 +60,12 @@ export class UpgradesTabComponent implements OnInit {
private readonly toast = inject(ToastService);
private readonly pagination = inject(PaginationService);
private initialLoad = true;
private latestLoadToken = 0;
readonly upgrades = signal<CfScoreUpgrade[]>([]);
readonly totalRecords = signal(0);
readonly currentPage = signal(1);
readonly pageSize = signal(this.pagination.getPageSize(UpgradesTabComponent.PAGE_SIZE_KEY, 50));
readonly loading = signal(false);
readonly searchQuery = signal('');
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = signal<SelectOption[]>([]);
readonly sortBy = signal<CfUpgradesSortBy>(DEFAULT_SORT_BY);
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
@@ -75,6 +74,38 @@ export class UpgradesTabComponent implements OnInit {
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
readonly drawerOpen = signal(false);
private readonly upgradesParams = computed<CfScoreUpgradesQuery>(() => {
const a = this.applied();
const days = parseInt(a.timeRange, 10);
return {
page: this.currentPage(),
pageSize: this.pageSize(),
instanceId: this.selectedInstanceId() || undefined,
days: Number.isFinite(days) ? days : undefined,
search: this.searchQuery() || undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
};
});
private readonly upgradesResource = rxResource({
params: () => this.upgradesParams(),
stream: ({ params }) => this.api.getRecentUpgrades(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as CfScoreUpgradesResponse,
});
private readonly instancesResource = rxResource({
stream: () => this.api.getInstances(),
defaultValue: { instances: [] as CfScoreInstance[] },
});
readonly upgrades = computed(() => this.upgradesResource.value().items);
readonly totalRecords = computed(() => this.upgradesResource.value().totalCount);
readonly instanceOptions = computed<SelectOption[]>(() => [
{ label: 'All Instances', value: '' },
...this.instancesResource.value().instances.map((i) => ({ label: `${i.name} (${i.itemType})`, value: i.id })),
]);
readonly sortOptions: SelectOption[] = [
{ label: 'Upgraded At', value: CfUpgradesSortBy.UpgradedAt },
{ label: 'Title', value: CfUpgradesSortBy.Title },
@@ -111,44 +142,42 @@ export class UpgradesTabComponent implements OnInit {
this.initialLoad = false;
return;
}
untracked(() => {
this.loadUpgrades();
});
this.upgradesResource.reload();
});
effect(() => {
if (this.upgradesResource.error()) {
this.toast.error('Failed to load upgrades');
}
});
effect(() => {
if (this.instancesResource.error()) {
this.toast.error('Failed to load instances');
}
});
}
ngOnInit(): void {
this.loadInstances();
this.loadUpgrades();
}
onSearchFilterChange(): void {
this.currentPage.set(1);
this.loadUpgrades();
}
onSortByChange(value: CfUpgradesSortBy): void {
this.sortBy.set(value);
this.currentPage.set(1);
this.loadUpgrades();
}
onSortOrderChange(value: SortDirection): void {
this.sortDirection.set(value);
this.currentPage.set(1);
this.loadUpgrades();
}
onPageChange(page: number): void {
this.currentPage.set(page);
this.loadUpgrades();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
UpgradesTabComponent.PAGE_SIZE_KEY,
this.pageSize,
this.currentPage,
() => this.loadUpgrades(),
);
openFilters(): void {
@@ -166,7 +195,6 @@ export class UpgradesTabComponent implements OnInit {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.currentPage.set(1);
this.loadUpgrades();
}
updateDraft<K extends keyof AdvancedFilters>(key: K, value: AdvancedFilters[K]): void {
@@ -174,55 +202,10 @@ export class UpgradesTabComponent implements OnInit {
}
refresh(): void {
this.loadUpgrades();
this.upgradesResource.reload();
}
itemTypeSeverity(itemType: string): 'info' | 'default' {
return itemType === 'Radarr' || itemType === 'Sonarr' ? 'info' : 'default';
}
private loadInstances(): void {
this.api.getInstances().subscribe({
next: (result) => {
this.instanceOptions.set([
{ label: 'All Instances', value: '' },
...result.instances.map(i => ({
label: `${i.name} (${i.itemType})`,
value: i.id,
})),
]);
},
error: () => this.toast.error('Failed to load instances'),
});
}
private loadUpgrades(): void {
this.loading.set(true);
const loadToken = ++this.latestLoadToken;
const a = this.applied();
const days = parseInt(a.timeRange, 10);
const instanceId = this.selectedInstanceId() || undefined;
this.api.getRecentUpgrades({
page: this.currentPage(),
pageSize: this.pageSize(),
instanceId,
days: Number.isFinite(days) ? days : undefined,
search: this.searchQuery() || undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
}).subscribe({
next: (result) => {
if (loadToken !== this.latestLoadToken) return;
this.upgrades.set(result.items);
this.totalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
if (loadToken !== this.latestLoadToken) return;
this.loading.set(false);
this.toast.error('Failed to load upgrades');
},
});
}
}
@@ -335,29 +335,29 @@
<!-- OIDC / SSO -->
<app-card header="OIDC / SSO">
<div class="form-stack">
<app-toggle label="Enable OIDC" [(checked)]="oidcEnabled"
<app-toggle label="Enable OIDC" [formField]="oidcForm.enabled"
helpKey="account:oidcEnabled"
hint="Allow signing in with an external identity provider (Authentik, Authelia, Keycloak, etc.)" />
@if (oidcEnabled()) {
<app-input label="Provider Name" [(value)]="oidcProviderName"
@if (oidcForm.enabled().value()) {
<app-input label="Provider Name" [formField]="oidcForm.providerName"
helpKey="account:oidcProviderName"
hint="Display name shown on the login button (e.g. Authentik, Keycloak)" />
<app-input label="Issuer URL" [(value)]="oidcIssuerUrl"
<app-input label="Issuer URL" [formField]="oidcForm.issuerUrl"
helpKey="account:oidcIssuerUrl"
placeholder="https://auth.example.com/application/o/cleanuparr/"
hint="The OpenID Connect issuer URL from your identity provider. Must use HTTPS." />
<div class="form-row">
<app-input label="Client ID" [(value)]="oidcClientId"
<app-input label="Client ID" [formField]="oidcForm.clientId"
helpKey="account:oidcClientId"
hint="The client ID assigned by your identity provider" />
<app-input label="Client Secret" [(value)]="oidcClientSecret" type="password" [revealable]="false"
<app-input label="Client Secret" [formField]="oidcForm.clientSecret" type="password" [revealable]="false"
helpKey="account:oidcClientSecret"
hint="Optional. Required for confidential clients." />
</div>
<app-input label="Scopes" [(value)]="oidcScopes"
<app-input label="Scopes" [formField]="oidcForm.scopes"
helpKey="account:oidcScopes"
hint="Space-separated list of OIDC scopes to request (default: openid profile email)" />
<app-input label="Redirect URL" [(value)]="oidcRedirectUrl"
<app-input label="Redirect URL" [formField]="oidcForm.redirectUrl"
helpKey="account:oidcRedirectUrl"
placeholder="https://cleanuparr.example.com"
hint="The base URL where Cleanuparr is accessible. Callback paths are appended automatically. Leave empty to auto-detect." />
@@ -390,7 +390,7 @@
</div>
<div class="form-divider"></div>
<app-toggle label="Exclusive Mode" [(checked)]="oidcExclusiveMode"
<app-toggle label="Exclusive Mode" [formField]="oidcForm.exclusiveMode"
helpKey="account:oidcExclusiveMode"
hint="When enabled, only OIDC login is allowed. Username/password and Plex login will be disabled." />
}
@@ -398,7 +398,7 @@
<div class="form-divider"></div>
<div class="form-actions">
<app-button variant="primary" [loading]="oidcSaving()" [disabled]="oidcSaving() || oidcSaved()" (clicked)="saveOidcConfig()">
<app-button variant="primary" [loading]="oidcSaving()" [disabled]="oidcSaving() || oidcSaved() || oidcForm().invalid()" (clicked)="saveOidcConfig()">
{{ oidcSaved() ? 'Saved!' : 'Save OIDC Settings' }}
</app-button>
</div>
@@ -1,26 +1,40 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit, OnDestroy } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, FormField } from '@angular/forms/signals';
import { ActivatedRoute } from '@angular/router';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, SpinnerComponent,
AccordionComponent, ToggleComponent, LabelComponent,
ToggleComponent, LabelComponent,
EmptyStateComponent, LoadingStateComponent,
} from '@ui';
import { forkJoin } from 'rxjs';
import { AccountApi, AccountInfo } from '@core/api/account.api';
import { AccountApi } from '@core/api/account.api';
import { AuthService } from '@core/auth/auth.service';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import { DeferredLoader } from '@shared/utils/loading.util';
import { QRCodeComponent } from 'angularx-qrcode';
interface OidcFormModel {
enabled: boolean;
issuerUrl: string;
clientId: string;
clientSecret: string;
scopes: string;
providerName: string;
redirectUrl: string;
authorizedSubject: string;
exclusiveMode: boolean;
}
@Component({
selector: 'app-account-settings',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
SpinnerComponent, ToggleComponent,
EmptyStateComponent, LoadingStateComponent, QRCodeComponent, LabelComponent,
EmptyStateComponent, LoadingStateComponent, QRCodeComponent, LabelComponent, FormField,
],
templateUrl: './account-settings.component.html',
styleUrl: './account-settings.component.scss',
@@ -33,9 +47,13 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
private readonly confirmService = inject(ConfirmService);
private readonly route = inject(ActivatedRoute);
private readonly accountResource = rxResource({
stream: () => forkJoin([this.api.getInfo(), this.api.getOidcConfig()]),
});
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly account = signal<AccountInfo | null>(null);
readonly loadError = computed(() => !!this.accountResource.error());
readonly account = computed(() => this.accountResource.hasValue() ? this.accountResource.value()[0] : null);
// Change password
readonly currentPassword = signal('');
@@ -86,26 +104,72 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
// OIDC
readonly oidcEnabled = signal(false);
readonly oidcIssuerUrl = signal('');
readonly oidcClientId = signal('');
readonly oidcClientSecret = signal('');
readonly oidcScopes = signal('openid profile email');
readonly oidcProviderName = signal('OIDC');
readonly oidcRedirectUrl = signal('');
readonly oidcAuthorizedSubject = signal('');
private readonly oidcModel = signal<OidcFormModel>({
enabled: false,
issuerUrl: '',
clientId: '',
clientSecret: '',
scopes: 'openid profile email',
providerName: 'OIDC',
redirectUrl: '',
authorizedSubject: '',
exclusiveMode: false,
});
readonly oidcForm = form(this.oidcModel, (p) => {
required(p.issuerUrl, { when: ({ valueOf }) => valueOf(p.enabled), message: 'Issuer URL is required' });
required(p.clientId, { when: ({ valueOf }) => valueOf(p.enabled), message: 'Client ID is required' });
});
readonly oidcExclusiveMode = computed(() => this.oidcModel().exclusiveMode);
readonly oidcAuthorizedSubject = computed(() => this.oidcModel().authorizedSubject);
readonly oidcExpanded = signal(false);
readonly oidcExclusiveMode = signal(false);
readonly oidcLinking = signal(false);
readonly oidcUnlinking = signal(false);
readonly oidcSaving = signal(false);
readonly oidcSaved = signal(false);
constructor() {
// Reset exclusive mode when OIDC is toggled off
// Reset exclusive mode when OIDC is toggled off. Guard on exclusiveMode too:
// the write flips it false, so the effect settles instead of writing a fresh
// object every run (which would loop forever while enabled stays false).
effect(() => {
if (!this.oidcEnabled()) {
this.oidcExclusiveMode.set(false);
const m = this.oidcModel();
if (!m.enabled && m.exclusiveMode) {
untracked(() => this.oidcModel.update(mm => ({ ...mm, exclusiveMode: false })));
}
});
effect(() => {
const data = this.accountResource.hasValue() ? this.accountResource.value() : undefined;
if (!data) {
return;
}
const oidc = data[1];
untracked(() => {
this.oidcModel.set({
enabled: oidc.enabled,
issuerUrl: oidc.issuerUrl,
clientId: oidc.clientId,
clientSecret: oidc.clientSecret,
scopes: oidc.scopes || 'openid profile email',
providerName: oidc.providerName || 'OIDC',
redirectUrl: oidc.redirectUrl || '',
authorizedSubject: oidc.authorizedSubject,
exclusiveMode: oidc.exclusiveMode,
});
});
});
effect(() => {
if (this.accountResource.error()) {
this.toast.error('Failed to load account information');
}
});
effect(() => {
if (this.accountResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
}
});
}
@@ -119,7 +183,6 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.toast.error('Failed to link OIDC account');
this.oidcExpanded.set(true);
}
this.loadAccount();
}
ngOnDestroy(): void {
@@ -128,33 +191,8 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
}
}
private loadAccount(): void {
this.loader.start();
forkJoin([this.api.getInfo(), this.api.getOidcConfig()]).subscribe({
next: ([info, oidc]) => {
this.account.set(info);
this.oidcEnabled.set(oidc.enabled);
this.oidcIssuerUrl.set(oidc.issuerUrl);
this.oidcClientId.set(oidc.clientId);
this.oidcClientSecret.set(oidc.clientSecret);
this.oidcScopes.set(oidc.scopes || 'openid profile email');
this.oidcProviderName.set(oidc.providerName || 'OIDC');
this.oidcRedirectUrl.set(oidc.redirectUrl || '');
this.oidcAuthorizedSubject.set(oidc.authorizedSubject);
this.oidcExclusiveMode.set(oidc.exclusiveMode);
this.loader.stop();
},
error: () => {
this.toast.error('Failed to load account information');
this.loader.stop();
this.loadError.set(true);
},
});
}
retry(): void {
this.loadError.set(false);
this.loadAccount();
this.accountResource.reload();
}
// Change password
@@ -255,7 +293,7 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.toast.success('Two-factor authentication enabled');
this.cancelEnable2fa();
this.enabling2fa.set(false);
this.loadAccount();
this.accountResource.reload();
},
error: () => {
this.toast.error('Invalid verification code');
@@ -290,7 +328,7 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.twoFaPassword.set('');
this.twoFaCode.set('');
this.disabling2fa.set(false);
this.loadAccount();
this.accountResource.reload();
},
error: () => {
this.toast.error('Failed to disable 2FA. Check your password and code.');
@@ -377,7 +415,7 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
clearInterval(this.plexPollTimer!);
this.plexLinking.set(false);
this.toast.success('Plex account linked');
this.loadAccount();
this.accountResource.reload();
}
},
error: () => {
@@ -403,7 +441,7 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
next: () => {
this.toast.success('Plex account unlinked');
this.plexUnlinking.set(false);
this.loadAccount();
this.accountResource.reload();
},
error: () => {
this.toast.error('Failed to unlink Plex account');
@@ -414,7 +452,8 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
// OIDC
async saveOidcConfig(): Promise<void> {
if (this.oidcEnabled() && !this.oidcAuthorizedSubject()) {
const m = this.oidcModel();
if (m.enabled && !m.authorizedSubject) {
const confirmed = await this.confirmService.confirm({
title: 'Enable OIDC without a linked account',
message:
@@ -434,15 +473,15 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.oidcSaving.set(true);
this.api.updateOidcConfig({
enabled: this.oidcEnabled(),
issuerUrl: this.oidcIssuerUrl(),
clientId: this.oidcClientId(),
clientSecret: this.oidcClientSecret(),
scopes: this.oidcScopes(),
authorizedSubject: this.oidcAuthorizedSubject(),
providerName: this.oidcProviderName(),
redirectUrl: this.oidcRedirectUrl(),
exclusiveMode: this.oidcExclusiveMode(),
enabled: m.enabled,
issuerUrl: m.issuerUrl,
clientId: m.clientId,
clientSecret: m.clientSecret,
scopes: m.scopes,
authorizedSubject: m.authorizedSubject,
providerName: m.providerName,
redirectUrl: m.redirectUrl,
exclusiveMode: m.exclusiveMode,
}).subscribe({
next: () => {
this.toast.success('OIDC settings saved');
@@ -482,8 +521,7 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
this.oidcUnlinking.set(true);
this.api.unlinkOidc().subscribe({
next: () => {
this.oidcAuthorizedSubject.set('');
this.oidcExclusiveMode.set(false);
this.oidcModel.update(m => ({ ...m, authorizedSubject: '', exclusiveMode: false }));
this.toast.success('OIDC account unlinked');
this.oidcUnlinking.set(false);
},
@@ -55,25 +55,25 @@
[(visible)]="modalVisible"
>
<div class="modal-form">
<app-toggle label="Enabled" [(checked)]="modalEnabled"
<app-toggle label="Enabled" [formField]="instanceForm.enabled"
hint="Enable or disable this instance"
helpKey="arr:enabled" />
<app-input label="Name" placeholder="My Instance" [(value)]="modalName"
<app-input label="Name" placeholder="My Instance" [formField]="instanceForm.name"
hint="A unique name to identify this instance"
[error]="modalNameError()"
[error]="instanceForm.name().errors()[0]?.message"
helpKey="arr:name" />
<app-input label="URL" placeholder="http://localhost:8989" type="url" [(value)]="modalUrl"
<app-input label="URL" placeholder="http://localhost:8989" type="url" [formField]="instanceForm.url"
hint="Full URL including protocol and port"
[error]="modalUrlError()"
[error]="instanceForm.url().errors()[0]?.message"
helpKey="arr:url" />
<app-input label="External URL" placeholder="https://sonarr.example.com" type="url" [(value)]="modalExternalUrl"
<app-input label="External URL" placeholder="https://sonarr.example.com" type="url" [formField]="instanceForm.externalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
helpKey="arr:externalUrl" />
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="instanceForm.apiKey"
hint="API key from your arr application's Settings > General"
[error]="modalApiKeyError()"
[error]="instanceForm.apiKey().errors()[0]?.message"
helpKey="arr:apiKey" />
<app-select label="API Version" [options]="versionOptions()" [(value)]="modalVersion"
<app-select label="API Version" [options]="versionOptions()" [formField]="instanceForm.version"
hint="API version for this application"
helpKey="arr:version" />
</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, input, computed, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -8,7 +10,7 @@ import {
import { ArrApi } from '@core/api/arr.api';
import { ToastService } from '@core/services/toast.service';
import { ConfirmService } from '@core/services/confirm.service';
import { ArrConfig, ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model';
import { ArrInstance, CreateArrInstanceDto, TestArrInstanceRequest } from '@shared/models/arr-config.model';
import { ArrType } from '@shared/models/enums';
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
@@ -21,13 +23,22 @@ const ARR_VERSION_OPTIONS: Record<string, SelectOption[]> = {
whisparr: [{ label: 'v2', value: 2 }, { label: 'v3', value: 3 }],
};
interface ArrInstanceFormModel {
name: string;
url: string;
externalUrl: string;
apiKey: string;
version: number;
enabled: boolean;
}
@Component({
selector: 'app-arr-settings',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ModalComponent, EmptyStateComponent,
BadgeComponent, LoadingStateComponent,
BadgeComponent, LoadingStateComponent, FormField,
],
templateUrl: './arr-settings.component.html',
styleUrl: './arr-settings.component.scss',
@@ -38,118 +49,102 @@ export class ArrSettingsComponent implements HasPendingChanges {
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
readonly arrType = input.required<string>({ alias: 'type' });
readonly type = input.required<string>();
readonly displayName = computed(() => {
const t = this.arrType();
const t = this.type();
return t.charAt(0).toUpperCase() + t.slice(1);
});
readonly versionOptions = computed(() => ARR_VERSION_OPTIONS[this.arrType()] ?? []);
readonly versionOptions = computed(() => ARR_VERSION_OPTIONS[this.type()] ?? []);
private readonly configResource = rxResource({
params: () => this.type(),
stream: ({ params }) => this.api.getConfig(params as ArrType),
});
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly instances = signal<ArrInstance[]>([]);
readonly instances = computed(() =>
this.configResource.hasValue() ? (this.configResource.value().instances ?? []) : [],
);
// Modal state
readonly modalVisible = signal(false);
readonly editingInstance = signal<ArrInstance | null>(null);
readonly modalName = signal('');
readonly modalUrl = signal('');
readonly modalExternalUrl = signal('');
readonly modalApiKey = signal('');
readonly modalVersion = signal<unknown>(3);
readonly modalEnabled = signal(true);
readonly testing = signal(false);
// Modal validation
readonly modalNameError = computed(() => {
if (!this.modalName().trim()) return 'Name is required';
return undefined;
readonly instanceModel = signal<ArrInstanceFormModel>({
name: '', url: '', externalUrl: '', apiKey: '', version: 3, enabled: true,
});
readonly modalUrlError = computed(() => {
if (!this.modalUrl().trim()) return 'URL is required';
return undefined;
readonly instanceForm = form(this.instanceModel, (p) => {
required(p.name, { message: 'Name is required' });
required(p.url, { message: 'URL is required' });
required(p.apiKey, { message: 'API key is required' });
});
readonly modalApiKeyError = computed(() => {
if (!this.modalApiKey().trim()) return 'API key is required';
return undefined;
});
readonly hasModalErrors = computed(() => !!(
this.modalNameError() || this.modalUrlError() || this.modalApiKeyError()
));
readonly hasModalErrors = computed(() => this.instanceForm().invalid());
constructor() {
effect(() => {
const type = this.arrType();
if (type) {
untracked(() => {
this.instances.set([]);
this.loadError.set(false);
this.loadConfig();
});
const options = this.versionOptions();
if (options.length > 0) {
untracked(() => this.instanceModel.update(m => ({ ...m, version: options[0].value as number })));
}
});
effect(() => {
const options = this.versionOptions();
if (options.length > 0) {
untracked(() => this.modalVersion.set(options[0].value));
if (this.configResource.error()) {
this.toast.error(`Failed to load ${this.displayName()} settings`);
}
});
}
private loadConfig(): void {
this.loader.start();
this.api.getConfig(this.arrType() as ArrType).subscribe({
next: (config) => {
this.instances.set(config.instances ?? []);
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
},
error: () => {
this.toast.error(`Failed to load ${this.displayName()} settings`);
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
openAddModal(): void {
this.editingInstance.set(null);
this.modalName.set('');
this.modalUrl.set('');
this.modalExternalUrl.set('');
this.modalApiKey.set('');
const options = this.versionOptions();
this.modalVersion.set(options.length > 0 ? options[0].value : 3);
this.modalEnabled.set(true);
this.instanceModel.set({
name: '', url: '', externalUrl: '', apiKey: '',
version: options.length > 0 ? (options[0].value as number) : 3,
enabled: true,
});
this.modalVisible.set(true);
}
openEditModal(instance: ArrInstance): void {
this.editingInstance.set(instance);
this.modalName.set(instance.name);
this.modalUrl.set(instance.url);
this.modalExternalUrl.set(instance.externalUrl ?? '');
this.modalApiKey.set(instance.apiKey);
this.modalVersion.set(instance.version);
this.modalEnabled.set(instance.enabled);
this.instanceModel.set({
name: instance.name,
url: instance.url,
externalUrl: instance.externalUrl ?? '',
apiKey: instance.apiKey,
version: instance.version,
enabled: instance.enabled,
});
this.modalVisible.set(true);
}
testConnection(): void {
const m = this.instanceModel();
const request: TestArrInstanceRequest = {
url: this.modalUrl(),
apiKey: this.modalApiKey(),
version: (this.modalVersion() as number) ?? 3,
url: m.url,
apiKey: m.apiKey,
version: m.version ?? 3,
instanceId: this.editingInstance()?.id,
};
this.testing.set(true);
this.api.testInstance(this.arrType() as ArrType, request).subscribe({
this.api.testInstance(this.type() as ArrType, request).subscribe({
next: (result) => {
this.toast.success(result.message || 'Connection successful');
this.testing.set(false);
@@ -162,28 +157,31 @@ export class ArrSettingsComponent implements HasPendingChanges {
}
saveInstance(): void {
if (this.hasModalErrors()) return;
if (this.instanceForm().invalid()) {
return;
}
const m = this.instanceModel();
const dto: CreateArrInstanceDto = {
name: this.modalName(),
url: this.modalUrl(),
externalUrl: this.modalExternalUrl() || undefined,
apiKey: this.modalApiKey(),
version: (this.modalVersion() as number) ?? 3,
enabled: this.modalEnabled(),
name: m.name,
url: m.url,
externalUrl: m.externalUrl || undefined,
apiKey: m.apiKey,
version: m.version ?? 3,
enabled: m.enabled,
};
this.saving.set(true);
const editing = this.editingInstance();
const obs = editing?.id
? this.api.updateInstance(this.arrType() as ArrType, editing.id, dto)
: this.api.createInstance(this.arrType() as ArrType, dto);
? this.api.updateInstance(this.type() as ArrType, editing.id, dto)
: this.api.createInstance(this.type() as ArrType, dto);
obs.subscribe({
next: () => {
this.toast.success(editing ? 'Instance updated' : 'Instance added');
this.modalVisible.set(false);
this.saving.set(false);
this.loadConfig();
this.configResource.reload();
},
error: () => {
this.toast.error('Failed to save instance');
@@ -202,10 +200,10 @@ export class ArrSettingsComponent implements HasPendingChanges {
});
if (!confirmed) return;
this.api.deleteInstance(this.arrType() as ArrType, instance.id).subscribe({
this.api.deleteInstance(this.type() as ArrType, instance.id).subscribe({
next: () => {
this.toast.success('Instance deleted');
this.loadConfig();
this.configResource.reload();
},
error: () => this.toast.error('Failed to delete instance'),
});
@@ -19,16 +19,16 @@
<div class="settings-form">
<app-card header="Configuration">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="bsForm.enabled"
hint="When enabled, blacklist patterns will be synchronized to enabled qBittorrent clients hourly"
helpKey="blacklist-sync:enabled" />
@if (enabled()) {
@if (bsForm.enabled().value()) {
<app-input
label="Blacklist File Path"
placeholder="Local file path or URL"
hint="Path to blacklist file or HTTP(S) URL containing blacklist patterns"
[error]="blacklistPathError()"
[(value)]="blacklistPath"
[error]="bsForm.blacklistPath().errors()[0]?.message"
[formField]="bsForm.blacklistPath"
helpKey="blacklist-sync:blacklistPath"
/>
}
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import { CardComponent, ButtonComponent, InputComponent, ToggleComponent, EmptyStateComponent, LoadingStateComponent } from '@ui';
import { BlacklistSyncApi } from '@core/api/blacklist-sync.api';
@@ -7,70 +9,84 @@ import { BlacklistSyncConfig } from '@shared/models/blacklist-sync-config.model'
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
interface BlacklistSyncFormModel {
enabled: boolean;
blacklistPath: string;
}
@Component({
selector: 'app-blacklist-sync',
standalone: true,
imports: [PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, ToggleComponent, EmptyStateComponent, LoadingStateComponent],
imports: [PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, ToggleComponent, EmptyStateComponent, LoadingStateComponent, FormField],
templateUrl: './blacklist-sync.component.html',
styleUrl: './blacklist-sync.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BlacklistSyncComponent implements OnInit, HasPendingChanges {
export class BlacklistSyncComponent implements HasPendingChanges {
private readonly api = inject(BlacklistSyncApi);
private readonly toast = inject(ToastService);
private readonly savedSnapshot = signal('');
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
readonly enabled = signal(false);
readonly blacklistPath = signal('');
private readonly model = signal<BlacklistSyncFormModel>({ enabled: false, blacklistPath: '' });
private configId = '';
readonly blacklistPathError = computed(() => {
if (this.enabled() && !this.blacklistPath().trim()) {
return 'This field is required when blacklist sync is enabled';
}
return undefined;
readonly bsForm = form(this.model, (p) => {
required(p.blacklistPath, {
when: () => this.model().enabled,
message: 'This field is required when blacklist sync is enabled',
});
});
readonly hasErrors = computed(() => !!this.blacklistPathError());
readonly hasErrors = computed(() => this.bsForm().invalid());
ngOnInit(): void {
this.loadConfig();
}
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
constructor() {
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.configId = config.id;
this.enabled.set(config.enabled);
this.blacklistPath.set(config.blacklistPath ?? '');
this.loader.stop();
this.model.set({ enabled: config.enabled, blacklistPath: config.blacklistPath ?? '' });
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load blacklist sync settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
save(): void {
const m = this.model();
const config: BlacklistSyncConfig = {
id: this.configId,
enabled: this.enabled(),
blacklistPath: this.blacklistPath() || undefined,
enabled: m.enabled,
blacklistPath: m.blacklistPath || undefined,
};
this.saving.set(true);
@@ -90,10 +106,7 @@ export class BlacklistSyncComponent implements OnInit, HasPendingChanges {
}
private buildSnapshot(): string {
return JSON.stringify({
enabled: this.enabled(),
blacklistPath: this.blacklistPath(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -19,26 +19,26 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="dcForm.enabled"
hint="When enabled, the download cleaner will run according to the schedule"
helpKey="download-cleaner:enabled" />
@if (enabled()) {
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
@if (dcForm.enabled().value()) {
<app-toggle label="Advanced Scheduling" [formField]="dcForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="download-cleaner:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (dcForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="dcForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="dcForm.cronExpression().errors()[0]?.message"
helpKey="download-cleaner:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="dcForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="download-cleaner:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="dcForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="dcForm.scheduleEvery().errors()[0]?.message"
helpKey="download-cleaner:scheduleEvery" />
</div>
}
@@ -49,7 +49,7 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="dcForm.ignoredDownloads"
helpKey="download-cleaner:ignoredDownloads"
/>
}
@@ -61,7 +61,7 @@
</div>
</app-card>
@if (enabled()) {
@if (dcForm.enabled().value()) {
@if (clientOptions().length > 0) {
<app-card [header]="selectedClient()?.downloadClientName ?? 'Download Client'">
<div class="form-stack">
@@ -163,18 +163,15 @@
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.unlinkedConfig?.enabled ?? false"
(checkedChange)="updateUnlinkedField('enabled', $event)"
<app-toggle label="Enabled" [formField]="unlinkedForm.enabled"
hint="Enable management of downloads that have no hardlinks"
helpKey="download-cleaner:unlinkedEnabled" />
@if (client.unlinkedConfig?.enabled) {
<app-input label="Target Category" placeholder="unlinked" [value]="client.unlinkedConfig?.targetCategory ?? ''"
(valueChange)="updateUnlinkedField('targetCategory', $event)"
@if (unlinkedForm.enabled().value()) {
<app-input label="Target Category" placeholder="unlinked" [formField]="unlinkedForm.targetCategory"
hint="Category to move unlinked downloads to. You have to create a seeding rule for this category if you want to remove the downloads."
helpKey="download-cleaner:unlinkedTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.unlinkedConfig?.useTag ?? false"
(checkedChange)="updateUnlinkedField('useTag', $event)"
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [formField]="unlinkedForm.useTag"
[featureId]="isSelectedClientTransmission() ? 'unlinked-transmission-label': ''"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, adds a tag instead of changing the category'"
helpKey="download-cleaner:unlinkedUseTag" />
@@ -183,19 +180,17 @@
<div class="form-divider"></div>
<app-chip-input label="Ignored Root Directories" placeholder="Add directory path..."
[items]="client.unlinkedConfig?.ignoredRootDirs ?? []"
(itemsChange)="updateUnlinkedField('ignoredRootDirs', $event)"
[formField]="unlinkedForm.ignoredRootDirs"
hint="Root directories to ignore when checking for unlinked downloads (used for cross-seed)"
helpKey="download-cleaner:unlinkedIgnoredRootDir" />
<app-chip-input label="Unlinked Categories" placeholder="Add category..."
[items]="client.unlinkedConfig?.categories ?? []"
(itemsChange)="updateUnlinkedField('categories', $event)"
[formField]="unlinkedForm.categories"
hint="Categories to check for unlinked downloads"
[error]="unlinkedCategoriesError()"
[error]="unlinkedForm.categories().errors()[0]?.message"
helpKey="download-cleaner:unlinkedCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || !!unlinkedCategoriesError()" (clicked)="saveUnlinkedConfig()">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || unlinkedForm.categories().invalid()" (clicked)="saveUnlinkedConfig()">
{{ unlinkedSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
@@ -205,38 +200,33 @@
@if (isDeadTorrentCapableClient()) {
<app-accordion header="Dead Torrents" subtitle="Triage torrents with no seeders" featureId="dead-torrent" [(expanded)]="deadTorrentExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.deadTorrentConfig?.enabled ?? false"
(checkedChange)="updateDeadTorrentField('enabled', $event)"
<app-toggle label="Enabled" [formField]="deadTorrentForm.enabled"
hint="When enabled, torrents reporting no seeders (or whose tracker is unreachable) for a number of consecutive runs are moved to a target category/tag"
helpKey="download-cleaner:deadTorrentEnabled" />
@if (client.deadTorrentConfig?.enabled) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [value]="client.deadTorrentConfig?.targetCategory ?? ''"
(valueChange)="updateDeadTorrentField('targetCategory', $event)"
@if (deadTorrentForm.enabled().value()) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [formField]="deadTorrentForm.targetCategory"
hint="Category/tag dead torrents are moved to. Create a seeding rule for this category to control what happens next."
helpKey="download-cleaner:deadTorrentTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.deadTorrentConfig?.useTag ?? false"
(checkedChange)="updateDeadTorrentField('useTag', $event)"
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [formField]="deadTorrentForm.useTag"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, uses a tag instead of category'"
helpKey="download-cleaner:deadTorrentUseTag" />
}
<app-number-input label="Strikes" [value]="client.deadTorrentConfig?.maxStrikes ?? null" [min]="3" [step]="1"
(valueChange)="updateDeadTorrentField('maxStrikes', $event ?? 0)"
[error]="deadTorrentStrikesError()"
<app-number-input label="Strikes" [formField]="deadTorrentForm.maxStrikes" [step]="1"
[error]="deadTorrentForm.maxStrikes().errors()[0]?.message"
hint="Consecutive runs with no seeders before moving the torrent (minimum 3). Set this high enough to ride out tracker downtime — e.g. with an hourly cleaner, 168 ≈ 1 week. Strikes reset once seeders are found again."
helpKey="download-cleaner:deadTorrentStrikes" />
<div class="form-divider"></div>
<app-chip-input label="Categories" placeholder="Add category..."
[items]="client.deadTorrentConfig?.categories ?? []"
(itemsChange)="updateDeadTorrentField('categories', $event)"
[formField]="deadTorrentForm.categories"
hint="Categories to scan for dead torrents"
[error]="deadTorrentCategoriesError()"
[error]="deadTorrentForm.categories().errors()[0]?.message"
helpKey="download-cleaner:deadTorrentCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || !!deadTorrentCategoriesError() || !!deadTorrentStrikesError()" (clicked)="saveDeadTorrentConfig()">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || deadTorrentForm.categories().invalid() || deadTorrentForm.maxStrikes().invalid()" (clicked)="saveDeadTorrentConfig()">
{{ deadTorrentSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
@@ -248,55 +238,47 @@
<div class="form-stack">
<app-toggle
label="Enabled"
[checked]="client.orphanedFilesConfig?.enabled ?? false"
(checkedChange)="updateOrphanedFilesField('enabled', $event)"
[formField]="orphanedFilesForm.enabled"
hint="Enable orphaned files scanning for this download client"
helpKey="download-cleaner:orphanedFilesEnabled"
/>
@if (client.orphanedFilesConfig?.enabled) {
@if (orphanedFilesForm.enabled().value()) {
<div class="form-divider"></div>
<app-chip-input
label="Scan Directories"
placeholder="Add directory path..."
hint="Absolute paths to scan for orphaned files. Each top-level entry is checked against your active torrents."
[items]="client.orphanedFilesConfig?.scanDirectories ?? []"
(itemsChange)="updateOrphanedFilesField('scanDirectories', $event)"
[error]="orphanedFilesScanDirsError()"
[formField]="orphanedFilesForm.scanDirectories"
[error]="orphanedFilesForm.scanDirectories().errors()[0]?.message"
helpKey="download-cleaner:orphanedFilesScanDirectories"
/>
<app-input
label="Orphaned Directory"
placeholder="/mnt/data/orphaned"
hint="Where orphaned files are moved."
[value]="client.orphanedFilesConfig?.orphanedDirectory ?? ''"
(valueChange)="updateOrphanedFilesField('orphanedDirectory', $event)"
[error]="orphanedFilesOrphanedDirError()"
[formField]="orphanedFilesForm.orphanedDirectory"
[error]="orphanedFilesForm.orphanedDirectory().errors()[0]?.message"
helpKey="download-cleaner:orphanedFilesOrphanedDirectory"
/>
<app-chip-input
label="Exclude Patterns"
placeholder="Add glob pattern (e.g. *.nfo)..."
hint="File or directory names matching these patterns are never considered orphaned (e.g. *.nfo, .DS_Store)"
[items]="client.orphanedFilesConfig?.excludePatterns ?? []"
(itemsChange)="updateOrphanedFilesField('excludePatterns', $event)"
[formField]="orphanedFilesForm.excludePatterns"
helpKey="download-cleaner:orphanedFilesExcludePatterns"
/>
<app-number-input
label="Min File Age"
suffix="hours"
[min]="0"
hint="Skip files or folders modified less than this many hours ago. Protects active downloads. Set to 0 to disable the age check."
[value]="client.orphanedFilesConfig?.minFileAgeHours ?? 24"
(valueChange)="updateOrphanedFilesField('minFileAgeHours', $event ?? 24)"
[formField]="orphanedFilesForm.minFileAgeHours"
helpKey="download-cleaner:orphanedFilesMinFileAgeHours"
/>
<app-number-input
label="Purge Orphaned After"
suffix="hours"
[min]="1"
hint="Permanently delete entries from the Orphaned Directory after this many hours. Leave empty to keep them indefinitely."
[value]="client.orphanedFilesConfig?.purgeAfterHours ?? null"
(valueChange)="updateOrphanedFilesField('purgeAfterHours', $event ?? undefined)"
[formField]="orphanedFilesForm.purgeAfterHours"
helpKey="download-cleaner:orphanedFilesPurgeAfterHours"
/>
}
@@ -305,7 +287,7 @@
variant="primary"
[glowing]="orphanedFilesDirty()"
[loading]="orphanedFilesSaving()"
[disabled]="orphanedFilesSaving() || orphanedFilesSaved() || !orphanedFilesDirty() || !!orphanedFilesScanDirsError() || !!orphanedFilesOrphanedDirError()"
[disabled]="orphanedFilesSaving() || orphanedFilesSaved() || !orphanedFilesDirty() || orphanedFilesForm.scanDirectories().invalid() || orphanedFilesForm.orphanedDirectory().invalid()"
(clicked)="saveOrphanedFilesConfig()"
>
{{ orphanedFilesSaved() ? 'Saved!' : 'Save' }}
@@ -330,56 +312,56 @@
<!-- Seeding Rule Modal -->
<app-modal [title]="editingRule() ? 'Edit Seeding Rule' : 'Add Seeding Rule'" [(visible)]="ruleModalVisible" size="lg">
<div class="form-grid">
<app-input label="Rule Name" placeholder="My TV rule" [(value)]="ruleName"
[error]="ruleNameError()"
<app-input label="Rule Name" placeholder="My TV rule" [formField]="ruleForm.name"
[error]="ruleForm.name().errors()[0]?.message"
hint="A descriptive label for this rule"
helpKey="download-cleaner:name" />
<app-select label="Privacy Type" [options]="privacyTypeOptions" [(value)]="rulePrivacyType"
<app-select label="Privacy Type" [options]="privacyTypeOptions" [formField]="ruleForm.privacyType"
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()"
[formField]="ruleForm.categories"
[error]="ruleForm.categories().errors()[0]?.message"
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"
[formField]="ruleForm.trackerPatterns"
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"
[formField]="ruleForm.tagsAny"
[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"
[formField]="ruleForm.tagsAll"
[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"
<app-number-input label="Max Ratio" [formField]="ruleForm.maxRatio" [step]="0.1"
hint="Maximum ratio to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxRatio" />
<app-number-input label="Min Seed Time" [(value)]="ruleMinSeedTime" suffix="hours" [min]="0"
<app-number-input label="Min Seed Time" [formField]="ruleForm.minSeedTime" suffix="hours"
hint="Minimum time to seed before removing a download that has reached the max ratio (0 means disabled)"
helpKey="download-cleaner:minSeedTime" />
<app-number-input label="Max Seed Time" [(value)]="ruleMaxSeedTime" suffix="hours" [min]="-1"
<app-number-input label="Max Seed Time" [formField]="ruleForm.maxSeedTime" suffix="hours"
hint="Maximum time to seed before removing (-1 means disabled)"
helpKey="download-cleaner:maxSeedTime" />
@if (isSeedersFilterableClient()) {
<app-number-input label="Min Seeders" [(value)]="ruleMinSeeders" [min]="0" [step]="1" featureId="min-seeders"
<app-number-input label="Min Seeders" [formField]="ruleForm.minSeeders" [step]="1" featureId="min-seeders"
hint="Minimum number of seeders required before removing (0 means disabled; unavailable counts keep the download)"
helpKey="download-cleaner:minSeeders" />
}
@if (ruleDisabledError()) {
<div class="category-error">{{ ruleDisabledError() }}</div>
}
<app-toggle class="full-width" label="Delete Source Files" [(checked)]="ruleDeleteSourceFiles"
<app-toggle class="full-width" label="Delete Source Files" [formField]="ruleForm.deleteSourceFiles"
hint="When enabled, the source files will be deleted when the download is removed"
helpKey="download-cleaner:deleteSourceFiles" />
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="ruleModalVisible.set(false)">Cancel</app-button>
<app-button variant="primary" [disabled]="!!ruleNameError() || !!ruleCategoriesError() || !!ruleDisabledError() || ruleHasUncommittedInputs()" (clicked)="saveRule()">
<app-button variant="primary" [disabled]="ruleForm().invalid() || ruleHasUncommittedInputs()" (clicked)="saveRule()">
{{ editingRule() ? 'Update' : 'Create' }}
</app-button>
</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChildren, effect, untracked, linkedSignal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, min, minLength, validate, FormField } from '@angular/forms/signals';
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';
@@ -36,6 +38,54 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
{ label: 'Both', value: TorrentPrivacyType.Both },
];
interface DownloadCleanerGlobalFormModel {
enabled: boolean;
useAdvancedScheduling: boolean;
cronExpression: string;
scheduleEvery: number;
scheduleUnit: ScheduleUnit;
ignoredDownloads: string[];
}
interface SeedingRuleFormModel {
name: string;
categories: string[];
trackerPatterns: string[];
tagsAny: string[];
tagsAll: string[];
privacyType: TorrentPrivacyType;
maxRatio: number | null;
minSeedTime: number | null;
maxSeedTime: number | null;
minSeeders: number | null;
deleteSourceFiles: boolean;
}
interface UnlinkedFormModel {
enabled: boolean;
targetCategory: string;
useTag: boolean;
ignoredRootDirs: string[];
categories: string[];
}
interface DeadTorrentFormModel {
enabled: boolean;
targetCategory: string;
useTag: boolean;
maxStrikes: number | null;
categories: string[];
}
interface OrphanedFilesFormModel {
enabled: boolean;
scanDirectories: string[];
orphanedDirectory: string;
excludePatterns: string[];
minFileAgeHours: number | null;
purgeAfterHours: number | null;
}
@Component({
selector: 'app-download-cleaner',
standalone: true,
@@ -45,13 +95,13 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
TooltipComponent,
TooltipComponent, FormField,
],
templateUrl: './download-cleaner.component.html',
styleUrl: './download-cleaner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
export class DownloadCleanerComponent implements HasPendingChanges {
private readonly api = inject(DownloadCleanerApi);
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
@@ -67,8 +117,13 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
readonly unlinkedSaving = signal(false);
@@ -82,12 +137,29 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
private readonly deadTorrentSnapshots = signal<Record<string, string>>({});
// Global settings
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[]>([]);
private readonly model = signal<DownloadCleanerGlobalFormModel>({
enabled: false,
useAdvancedScheduling: false,
cronExpression: '',
scheduleEvery: 5,
scheduleUnit: ScheduleUnit.Minutes,
ignoredDownloads: [],
});
readonly dcForm = form(this.model, (p) => {
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;
});
});
// Per-client settings
readonly clientConfigs = signal<ClientCleanerConfig[]>([]);
@@ -145,193 +217,158 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
// Seeding rule modal
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);
readonly ruleMaxSeedTime = signal<number | null>(-1);
readonly ruleMinSeeders = signal<number | null>(0);
readonly ruleDeleteSourceFiles = signal(true);
private readonly ruleDefaults: SeedingRuleFormModel = {
name: '', categories: [], trackerPatterns: [], tagsAny: [], tagsAll: [],
privacyType: TorrentPrivacyType.Public, maxRatio: -1, minSeedTime: 0,
maxSeedTime: -1, minSeeders: 0, deleteSourceFiles: true,
};
readonly ruleModel = signal<SeedingRuleFormModel>({ ...this.ruleDefaults });
readonly ruleForm = form(this.ruleModel, (p) => {
required(p.name, { message: 'Name is required' });
minLength(p.categories, 1, { message: 'At least one category is required' });
min(p.maxRatio, -1);
min(p.minSeedTime, 0);
min(p.maxSeedTime, -1);
min(p.minSeeders, 0);
validate(p.maxSeedTime, () => {
const m = this.ruleModel();
return (m.maxRatio ?? -1) < 0 && (m.maxSeedTime ?? -1) < 0
? { kind: 'disabled', message: 'Both max ratio and max seed time cannot be disabled at the same time' }
: undefined;
});
});
readonly ruleDisabledError = computed(() =>
this.ruleForm.maxSeedTime().errors().find(e => e.kind === 'disabled')?.message
);
readonly unlinkedModel = linkedSignal<string | null, UnlinkedFormModel>({
source: this.selectedClientId,
computation: (id) => {
const snap = id ? untracked(() => this.unlinkedSnapshots())[id] : undefined;
return snap ? JSON.parse(snap) as UnlinkedFormModel : this.toUnlinkedModel(null);
},
});
readonly unlinkedForm = form(this.unlinkedModel, (p) => {
validate(p.categories, () => {
const m = this.unlinkedModel();
return m.enabled && m.categories.length === 0
? { kind: 'required', message: 'At least one category is required' }
: undefined;
});
});
readonly deadTorrentModel = linkedSignal<string | null, DeadTorrentFormModel>({
source: this.selectedClientId,
computation: (id) => {
const snap = id ? untracked(() => this.deadTorrentSnapshots())[id] : undefined;
return snap ? JSON.parse(snap) as DeadTorrentFormModel : this.toDeadTorrentModel(null);
},
});
readonly deadTorrentForm = form(this.deadTorrentModel, (p) => {
validate(p.categories, () => {
const m = this.deadTorrentModel();
return m.enabled && m.categories.length === 0
? { kind: 'required', message: 'At least one category is required' }
: undefined;
});
validate(p.maxStrikes, () => {
const m = this.deadTorrentModel();
return m.enabled && (m.maxStrikes ?? 0) < 3
? { kind: 'min', message: 'Strikes must be at least 3' }
: undefined;
});
});
readonly orphanedFilesModel = linkedSignal<string | null, OrphanedFilesFormModel>({
source: this.selectedClientId,
computation: (id) => {
const snap = id ? untracked(() => this.orphanedFilesSnapshots())[id] : undefined;
return snap ? JSON.parse(snap) as OrphanedFilesFormModel : this.toOrphanedFilesModel(null);
},
});
readonly orphanedFilesForm = form(this.orphanedFilesModel, (p) => {
validate(p.scanDirectories, () => {
const m = this.orphanedFilesModel();
return m.enabled && m.scanDirectories.length === 0
? { kind: 'required', message: 'At least one scan directory is required' }
: undefined;
});
validate(p.orphanedDirectory, () => {
const m = this.orphanedFilesModel();
return m.enabled && !m.orphanedDirectory.trim()
? { kind: 'required', message: 'Orphaned directory is required' }
: undefined;
});
min(p.minFileAgeHours, 0);
min(p.purgeAfterHours, 1);
});
private toUnlinkedModel(c: UnlinkedConfigModel | null): UnlinkedFormModel {
const d = c ?? createDefaultUnlinkedConfig();
return {
enabled: d.enabled,
targetCategory: d.targetCategory,
useTag: d.useTag,
ignoredRootDirs: [...(d.ignoredRootDirs ?? [])],
categories: [...(d.categories ?? [])],
};
}
private toDeadTorrentModel(c: DeadTorrentConfigModel | null): DeadTorrentFormModel {
const d = c ?? createDefaultDeadTorrentConfig();
return {
enabled: d.enabled,
targetCategory: d.targetCategory,
useTag: d.useTag,
maxStrikes: d.maxStrikes,
categories: [...(d.categories ?? [])],
};
}
private toOrphanedFilesModel(c: OrphanedFilesConfig | null): OrphanedFilesFormModel {
const d = c ?? createDefaultOrphanedFilesConfig();
return {
enabled: d.enabled,
scanDirectories: [...(d.scanDirectories ?? [])],
orphanedDirectory: d.orphanedDirectory,
excludePatterns: [...(d.excludePatterns ?? [])],
minFileAgeHours: d.minFileAgeHours,
purgeAfterHours: d.purgeAfterHours ?? null,
};
}
readonly scheduleIntervalOptions = computed(() => {
const unit = this.scheduleUnit() as ScheduleUnit;
const values = ScheduleOptions[unit] ?? [];
const values = ScheduleOptions[this.model().scheduleUnit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
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]));
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] })));
}
});
}
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 ruleNameError = computed(() => {
if (!this.ruleName().trim()) {
return 'Name is required';
}
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';
}
return undefined;
});
readonly unlinkedCategoriesError = computed(() => {
const client = this.selectedClient();
if (!client?.unlinkedConfig?.enabled) {
return undefined;
}
if ((client.unlinkedConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
readonly deadTorrentCategoriesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.categories ?? []).length === 0) {
return 'At least one category is required';
}
return undefined;
});
readonly deadTorrentStrikesError = computed(() => {
const client = this.selectedClient();
if (!client?.deadTorrentConfig?.enabled) {
return undefined;
}
if ((client.deadTorrentConfig.maxStrikes ?? 0) < 3) {
return 'Strikes must be at least 3';
}
return undefined;
});
readonly orphanedFilesScanDirsError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
return undefined;
}
if ((client.orphanedFilesConfig.scanDirectories ?? []).length === 0) {
return 'At least one scan directory is required';
}
return undefined;
});
readonly orphanedFilesOrphanedDirError = computed(() => {
const client = this.selectedClient();
if (!client?.orphanedFilesConfig?.enabled) {
return undefined;
}
if (!client.orphanedFilesConfig.orphanedDirectory?.trim()) {
return 'Orphaned directory is required';
}
return undefined;
});
readonly unlinkedDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.unlinkedSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultUnlinkedConfig());
return saved !== JSON.stringify(client.unlinkedConfig);
});
readonly deadTorrentDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.deadTorrentSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultDeadTorrentConfig());
return saved !== JSON.stringify(client.deadTorrentConfig);
});
readonly orphanedFilesDirty = computed(() => {
const client = this.selectedClient();
if (!client) {
return false;
}
const saved = this.orphanedFilesSnapshots()[client.downloadClientId]
?? JSON.stringify(createDefaultOrphanedFilesConfig());
return saved !== JSON.stringify(client.orphanedFilesConfig);
});
readonly hasGlobalErrors = computed(() => {
if (this.scheduleEveryError()) {
return true;
}
if (this.cronError()) {
return true;
}
if (this.chipInputs().some(c => c.hasUncommittedInput())) {
return true;
}
return false;
});
private config: DownloadCleanerConfig | null = null;
ngOnInit(): void {
this.loadConfig();
}
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (dc) => {
effect(() => {
const dc = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!dc) {
return;
}
untracked(() => {
this.config = dc;
this.enabled.set(dc.enabled);
this.useAdvancedScheduling.set(dc.useAdvancedScheduling);
this.cronExpression.set(dc.cronExpression);
const parsed = parseCronToJobSchedule(dc.cronExpression);
if (parsed) {
this.scheduleEvery.set(parsed.every);
this.scheduleUnit.set(parsed.type);
}
this.ignoredDownloads.set(dc.ignoredDownloads ?? []);
this.model.set({
enabled: dc.enabled,
useAdvancedScheduling: dc.useAdvancedScheduling,
cronExpression: dc.cronExpression,
scheduleEvery: parsed?.every ?? 5,
scheduleUnit: parsed?.type ?? ScheduleUnit.Minutes,
ignoredDownloads: dc.ignoredDownloads ?? [],
});
this.clientConfigs.set((dc.clients ?? []).map(c => ({
...c,
@@ -341,39 +378,80 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
orphanedFilesConfig: c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig(),
})));
if (dc.clients?.length > 0) {
this.selectedClientId.set(dc.clients[0].downloadClientId);
}
const unlinkedSnapshots: Record<string, string> = {};
const deadTorrentSnapshots: Record<string, string> = {};
const orphanedFilesSnapshots: Record<string, string> = {};
for (const c of dc.clients ?? []) {
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(c.unlinkedConfig ?? createDefaultUnlinkedConfig());
deadTorrentSnapshots[c.downloadClientId] = JSON.stringify(c.deadTorrentConfig ?? createDefaultDeadTorrentConfig());
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(c.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig());
unlinkedSnapshots[c.downloadClientId] = JSON.stringify(this.toUnlinkedModel(c.unlinkedConfig ?? null));
deadTorrentSnapshots[c.downloadClientId] = JSON.stringify(this.toDeadTorrentModel(c.deadTorrentConfig ?? null));
orphanedFilesSnapshots[c.downloadClientId] = JSON.stringify(this.toOrphanedFilesModel(c.orphanedFilesConfig ?? null));
}
this.unlinkedSnapshots.set(unlinkedSnapshots);
this.deadTorrentSnapshots.set(deadTorrentSnapshots);
this.orphanedFilesSnapshots.set(orphanedFilesSnapshots);
this.loader.stop();
// Set selection after snapshots so the sub-config linkedSignals hydrate from saved state.
if (dc.clients?.length > 0) {
this.selectedClientId.set(dc.clients[0].downloadClientId);
}
// Defer snapshot so constructor effects (e.g. schedule unit clamping) settle first
queueMicrotask(() => {
this.savedSnapshot.set(this.buildSnapshot());
});
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load download cleaner settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
readonly unlinkedDirty = computed(() => {
const id = this.selectedClientId();
if (!id) {
return false;
}
const saved = this.unlinkedSnapshots()[id] ?? JSON.stringify(this.toUnlinkedModel(null));
return saved !== JSON.stringify(this.unlinkedModel());
});
readonly deadTorrentDirty = computed(() => {
const id = this.selectedClientId();
if (!id) {
return false;
}
const saved = this.deadTorrentSnapshots()[id] ?? JSON.stringify(this.toDeadTorrentModel(null));
return saved !== JSON.stringify(this.deadTorrentModel());
});
readonly orphanedFilesDirty = computed(() => {
const id = this.selectedClientId();
if (!id) {
return false;
}
const saved = this.orphanedFilesSnapshots()[id] ?? JSON.stringify(this.toOrphanedFilesModel(null));
return saved !== JSON.stringify(this.orphanedFilesModel());
});
readonly hasGlobalErrors = computed(() =>
this.dcForm().invalid() || this.chipInputs().some(c => c.hasUncommittedInput())
);
private config: DownloadCleanerConfig | null = null;
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
// --- Seeding rule modal CRUD ---
@@ -381,35 +459,27 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
openRuleModal(rule?: SeedingRule): void {
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);
this.ruleMaxSeedTime.set(rule.maxSeedTime);
this.ruleMinSeeders.set(rule.minSeeders ?? 0);
this.ruleDeleteSourceFiles.set(rule.deleteSourceFiles);
this.ruleModel.set({
name: rule.name,
categories: [...(rule.categories ?? [])],
trackerPatterns: [...(rule.trackerPatterns ?? [])],
tagsAny: [...(rule.tagsAny ?? [])],
tagsAll: [...(rule.tagsAll ?? [])],
privacyType: rule.privacyType,
maxRatio: rule.maxRatio,
minSeedTime: rule.minSeedTime,
maxSeedTime: rule.maxSeedTime,
minSeeders: rule.minSeeders ?? 0,
deleteSourceFiles: 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);
this.ruleMaxSeedTime.set(-1);
this.ruleMinSeeders.set(0);
this.ruleDeleteSourceFiles.set(true);
this.ruleModel.set({ ...this.ruleDefaults });
}
this.ruleModalVisible.set(true);
}
saveRule(): void {
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) {
if (this.ruleForm().invalid() || this.ruleHasUncommittedInputs()) {
return;
}
const clientId = this.selectedClientId();
@@ -417,20 +487,21 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return;
}
const m = this.ruleModel();
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,
maxSeedTime: this.ruleMaxSeedTime() ?? -1,
minSeeders: this.ruleMinSeeders() ?? 0,
deleteSourceFiles: this.ruleDeleteSourceFiles(),
name: m.name.trim(),
categories: sanitize(m.categories),
trackerPatterns: sanitize(m.trackerPatterns),
tagsAny: sanitize(m.tagsAny),
tagsAll: sanitize(m.tagsAll),
privacyType: m.privacyType,
maxRatio: m.maxRatio ?? -1,
minSeedTime: m.minSeedTime ?? 0,
maxSeedTime: m.maxSeedTime ?? -1,
minSeeders: m.minSeeders ?? 0,
deleteSourceFiles: m.deleteSourceFiles,
};
const editing = this.editingRule();
@@ -521,62 +592,36 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
if (!confirmed) {
return;
}
const currentId = this.selectedClientId();
if (currentId) {
this.restoreClientEditsFromSnapshot(currentId);
}
}
// Changing the selection resets the per-client linkedSignal models to the newly
// selected client's saved snapshot, discarding any edits on the previous client.
this.selectedClientId.set(newClientId as string | null);
}
/** Reverts the client's unlinked/dead-torrent/orphaned edits back to their saved snapshots. */
private restoreClientEditsFromSnapshot(clientId: string): void {
this.clientConfigs.update(configs => configs.map(c => {
if (c.downloadClientId !== clientId) {
return c;
}
const unlinked = this.unlinkedSnapshots()[clientId];
const deadTorrent = this.deadTorrentSnapshots()[clientId];
const orphaned = this.orphanedFilesSnapshots()[clientId];
return {
...c,
unlinkedConfig: unlinked ? JSON.parse(unlinked) : c.unlinkedConfig,
deadTorrentConfig: deadTorrent ? JSON.parse(deadTorrent) : c.deadTorrentConfig,
orphanedFilesConfig: orphaned ? JSON.parse(orphaned) : c.orphanedFilesConfig,
};
}));
}
// --- Unlinked config ---
updateUnlinkedField<K extends keyof UnlinkedConfigModel>(field: K, value: UnlinkedConfigModel[K]): void {
this.updateSelectedClient(client => ({
...client,
unlinkedConfig: {
...(client.unlinkedConfig ?? createDefaultUnlinkedConfig()),
[field]: value,
},
}));
}
saveUnlinkedConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.unlinkedConfig) {
if (!clientId) {
return;
}
const m = this.unlinkedModel();
const dto: UnlinkedConfigModel = {
enabled: m.enabled,
targetCategory: m.targetCategory,
useTag: m.useTag,
ignoredRootDirs: m.ignoredRootDirs,
categories: m.categories,
};
this.unlinkedSaving.set(true);
this.api.updateUnlinkedConfig(clientId, client.unlinkedConfig).subscribe({
this.api.updateUnlinkedConfig(clientId, dto).subscribe({
next: () => {
this.toast.success('Unlinked config saved');
this.unlinkedSaving.set(false);
this.unlinkedSaved.set(true);
setTimeout(() => this.unlinkedSaved.set(false), 1500);
this.unlinkedSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.unlinkedConfig),
}));
this.unlinkedSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) }));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save unlinked config');
@@ -587,34 +632,28 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
// --- Dead torrent per-client config ---
updateDeadTorrentField<K extends keyof DeadTorrentConfigModel>(field: K, value: DeadTorrentConfigModel[K]): void {
this.updateSelectedClient(client => ({
...client,
deadTorrentConfig: {
...(client.deadTorrentConfig ?? createDefaultDeadTorrentConfig()),
[field]: value,
},
}));
}
saveDeadTorrentConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.deadTorrentConfig) {
if (!clientId) {
return;
}
const m = this.deadTorrentModel();
const dto: DeadTorrentConfigModel = {
enabled: m.enabled,
targetCategory: m.targetCategory,
useTag: m.useTag,
maxStrikes: m.maxStrikes ?? 0,
categories: m.categories,
};
this.deadTorrentSaving.set(true);
this.api.updateDeadTorrentConfig(clientId, client.deadTorrentConfig).subscribe({
this.api.updateDeadTorrentConfig(clientId, dto).subscribe({
next: () => {
this.toast.success('Dead torrent config saved');
this.deadTorrentSaving.set(false);
this.deadTorrentSaved.set(true);
setTimeout(() => this.deadTorrentSaved.set(false), 1500);
this.deadTorrentSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.deadTorrentConfig),
}));
this.deadTorrentSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) }));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save dead torrent config');
@@ -625,33 +664,29 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
// --- Orphaned files per-client config ---
updateOrphanedFilesField<K extends keyof OrphanedFilesConfig>(field: K, value: OrphanedFilesConfig[K]): void {
this.updateSelectedClient(client => ({
...client,
orphanedFilesConfig: {
...(client.orphanedFilesConfig ?? createDefaultOrphanedFilesConfig()),
[field]: value,
},
}));
}
saveOrphanedFilesConfig(): void {
const clientId = this.selectedClientId();
const client = this.selectedClient();
if (!clientId || !client?.orphanedFilesConfig) {
if (!clientId) {
return;
}
const m = this.orphanedFilesModel();
const dto: OrphanedFilesConfig = {
enabled: m.enabled,
scanDirectories: m.scanDirectories,
orphanedDirectory: m.orphanedDirectory,
excludePatterns: m.excludePatterns,
minFileAgeHours: m.minFileAgeHours ?? 24,
purgeAfterHours: m.purgeAfterHours ?? undefined,
};
this.orphanedFilesSaving.set(true);
this.api.updateOrphanedFilesConfig(clientId, client.orphanedFilesConfig).subscribe({
this.api.updateOrphanedFilesConfig(clientId, dto).subscribe({
next: () => {
this.toast.success('Orphaned files settings saved');
this.orphanedFilesSaving.set(false);
this.orphanedFilesSaved.set(true);
setTimeout(() => this.orphanedFilesSaved.set(false), 1500);
this.orphanedFilesSnapshots.update(s => ({
...s,
[clientId]: JSON.stringify(client.orphanedFilesConfig),
}));
this.orphanedFilesSnapshots.update(s => ({ ...s, [clientId]: JSON.stringify(m) }));
},
error: (err: ApiError) => {
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save orphaned files settings');
@@ -660,16 +695,6 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
});
}
private updateSelectedClient(updater: (client: ClientCleanerConfig) => ClientCleanerConfig): void {
const id = this.selectedClientId();
if (!id) {
return;
}
this.clientConfigs.update(configs =>
configs.map(c => c.downloadClientId === id ? updater(c) : c)
);
}
// --- Global config save ---
save(): void {
@@ -677,16 +702,17 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
return;
}
const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit };
const cronExpression = this.useAdvancedScheduling()
? this.cronExpression()
const m = this.model();
const jobSchedule = { every: m.scheduleEvery ?? 5, type: m.scheduleUnit };
const cronExpression = m.useAdvancedScheduling
? m.cronExpression
: generateCronExpression(jobSchedule);
const config = {
enabled: this.enabled(),
enabled: m.enabled,
cronExpression,
useAdvancedScheduling: this.useAdvancedScheduling(),
ignoredDownloads: this.ignoredDownloads(),
useAdvancedScheduling: m.useAdvancedScheduling,
ignoredDownloads: m.ignoredDownloads,
};
this.saving.set(true);
@@ -708,14 +734,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
}
private buildSnapshot(): string {
return JSON.stringify({
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression: this.cronExpression(),
scheduleEvery: this.scheduleEvery(),
scheduleUnit: this.scheduleUnit(),
ignoredDownloads: this.ignoredDownloads(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -56,41 +56,41 @@
[(visible)]="modalVisible"
>
<div class="modal-form">
<app-toggle label="Enabled" [(checked)]="modalEnabled"
<app-toggle label="Enabled" [formField]="clientForm.enabled"
hint="Enable or disable this client"
helpKey="download-client:enabled" />
<app-input label="Name" placeholder="My qBittorrent" [(value)]="modalName"
<app-input label="Name" placeholder="My qBittorrent" [formField]="clientForm.name"
hint="A unique name to identify this client"
[error]="modalNameError()"
[error]="clientForm.name().errors()[0]?.message"
helpKey="download-client:name" />
<app-select label="Client Type" [options]="typeOptions" [value]="modalTypeName()"
(valueChange)="onClientTypeChange($event)"
<app-select label="Client Type" [options]="typeOptions" [formField]="clientForm.typeName"
(valueChange)="onClientTypeChange()"
hint="The type of download client"
helpKey="download-client:typeName" />
<app-input label="Host" placeholder="http://localhost:8080" [(value)]="modalHost"
<app-input label="Host" placeholder="http://localhost:8080" [formField]="clientForm.host"
hint="Full URL including protocol and port (e.g., http://localhost:8080)"
[error]="modalHostError()"
[error]="clientForm.host().errors()[0]?.message"
helpKey="download-client:host" />
@if (showUsernameField()) {
<app-input label="Username" placeholder="admin" [(value)]="modalUsername"
<app-input label="Username" placeholder="admin" [formField]="clientForm.username"
[hint]="usernameHint()"
helpKey="download-client:username" />
}
@if (showPasswordField()) {
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [formField]="clientForm.password"
[hint]="passwordHint()"
helpKey="download-client:password" />
}
<app-input label="URL Base" placeholder="/api/v2" [(value)]="modalUrlBase"
<app-input label="URL Base" placeholder="/api/v2" [formField]="clientForm.urlBase"
[hint]="urlBaseHint()"
helpKey="download-client:urlBase" />
<app-input label="External URL" placeholder="https://qbit.example.com" type="url" [(value)]="modalExternalUrl"
<app-input label="External URL" placeholder="https://qbit.example.com" type="url" [formField]="clientForm.externalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"
helpKey="download-client:externalUrl" />
<app-input label="Download Directory Source" placeholder="/downloads" [(value)]="modalDownloadDirectorySource"
<app-input label="Download Directory Source" placeholder="/downloads" [formField]="clientForm.downloadDirectorySource"
hint="The path your download client reports (e.g. /downloads). Leave blank if Cleanuparr sees the files at the same path."
helpKey="download-client:downloadDirectorySource" />
<app-input label="Download Directory Target" placeholder="/mnt/data/downloads" [(value)]="modalDownloadDirectoryTarget"
<app-input label="Download Directory Target" placeholder="/mnt/data/downloads" [formField]="clientForm.downloadDirectoryTarget"
hint="Where Cleanuparr actually finds those files (e.g. /mnt/data/downloads). Cleanuparr replaces the source path with this one."
helpKey="download-client:downloadDirectoryTarget" />
</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -23,163 +25,167 @@ const TYPE_OPTIONS: SelectOption[] = [
{ label: 'rTorrent', value: DownloadClientTypeName.rTorrent },
];
interface DownloadClientFormModel {
enabled: boolean;
name: string;
typeName: DownloadClientTypeName;
host: string;
username: string;
password: string;
urlBase: string;
externalUrl: string;
downloadDirectorySource: string;
downloadDirectoryTarget: string;
}
@Component({
selector: 'app-download-clients',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ModalComponent, EmptyStateComponent,
BadgeComponent, LoadingStateComponent,
BadgeComponent, LoadingStateComponent, FormField,
],
templateUrl: './download-clients.component.html',
styleUrl: './download-clients.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadClientsComponent implements OnInit, HasPendingChanges {
export class DownloadClientsComponent implements HasPendingChanges {
private readonly api = inject(DownloadClientApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
private readonly clientsResource = rxResource({
stream: () => this.api.getConfig(),
});
readonly typeOptions = TYPE_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.clientsResource.error());
readonly saving = signal(false);
readonly clients = signal<ClientConfig[]>([]);
readonly clients = computed(() =>
this.clientsResource.hasValue() ? (this.clientsResource.value().clients ?? []) : [],
);
// Modal
readonly modalVisible = signal(false);
readonly editingClient = signal<ClientConfig | null>(null);
readonly modalEnabled = signal(true);
readonly modalName = signal('');
readonly modalTypeName = signal<unknown>(DownloadClientTypeName.qBittorrent);
readonly modalHost = signal('');
readonly modalUsername = signal('');
readonly modalPassword = signal('');
readonly modalUrlBase = signal('');
readonly modalExternalUrl = signal('');
readonly modalDownloadDirectorySource = signal('');
readonly modalDownloadDirectoryTarget = signal('');
readonly testing = signal(false);
// Modal validation
readonly modalNameError = computed(() => {
if (!this.modalName().trim()) {
return 'Name is required';
}
return undefined;
readonly clientModel = signal<DownloadClientFormModel>({
enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent,
host: '', username: '', password: '', urlBase: '', externalUrl: '',
downloadDirectorySource: '', downloadDirectoryTarget: '',
});
readonly modalHostError = computed(() => {
if (!this.modalHost().trim()) {
return 'Host is required';
}
return undefined;
readonly clientForm = form(this.clientModel, (p) => {
required(p.name, { message: 'Name is required' });
required(p.host, { message: 'Host is required' });
});
readonly hasModalErrors = computed(() => !!(
this.modalNameError() || this.modalHostError()
));
readonly hasModalErrors = computed(() => this.clientForm().invalid());
readonly showUsernameField = computed(() => {
return this.modalTypeName() !== DownloadClientTypeName.Deluge;
return this.clientModel().typeName !== DownloadClientTypeName.Deluge;
});
readonly showPasswordField = computed(() => true);
readonly usernameHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Username for HTTP Basic Auth';
}
return 'Username for authentication';
});
readonly passwordHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Password for HTTP Basic Auth';
}
return 'Password for authentication';
});
readonly urlBaseHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Path to the XMLRPC endpoint. Usually RPC2 for rTorrent or plugins/httprpc/action.php for ruTorrent.';
}
return 'Optional URL base path, leave blank for default';
});
onClientTypeChange(value: unknown): void {
this.modalTypeName.set(value);
if (value === DownloadClientTypeName.Deluge) {
this.modalUsername.set('');
// typeName is owned by [formField]; here we only apply type-specific defaults,
// guarded so they never clobber values already loaded when editing a client.
onClientTypeChange(): void {
const m = this.clientModel();
const patch: Partial<DownloadClientFormModel> = {};
if (m.typeName === DownloadClientTypeName.Deluge && m.username !== '') {
patch.username = '';
}
if (value === DownloadClientTypeName.Transmission) {
this.modalUrlBase.set('transmission');
if (m.typeName === DownloadClientTypeName.Transmission && !m.urlBase) {
patch.urlBase = 'transmission';
}
if (value === DownloadClientTypeName.rTorrent) {
this.modalUrlBase.set('plugins/httprpc/action.php');
if (m.typeName === DownloadClientTypeName.rTorrent && !m.urlBase) {
patch.urlBase = 'plugins/httprpc/action.php';
}
if (Object.keys(patch).length > 0) {
this.clientModel.update((mm) => ({ ...mm, ...patch }));
}
}
ngOnInit(): void {
this.loadClients();
}
private loadClients(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
this.clients.set(config.clients ?? []);
this.loader.stop();
},
error: () => {
constructor() {
effect(() => {
if (this.clientsResource.error()) {
this.toast.error('Failed to load download clients');
}
});
effect(() => {
if (this.clientsResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadClients();
this.clientsResource.reload();
}
openAddModal(): void {
this.editingClient.set(null);
this.modalEnabled.set(true);
this.modalName.set('');
this.modalTypeName.set(DownloadClientTypeName.qBittorrent);
this.modalHost.set('');
this.modalUsername.set('');
this.modalPassword.set('');
this.modalUrlBase.set('');
this.modalExternalUrl.set('');
this.modalDownloadDirectorySource.set('');
this.modalDownloadDirectoryTarget.set('');
this.clientModel.set({
enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent,
host: '', username: '', password: '', urlBase: '', externalUrl: '',
downloadDirectorySource: '', downloadDirectoryTarget: '',
});
this.modalVisible.set(true);
}
openEditModal(client: ClientConfig): void {
this.editingClient.set(client);
this.modalEnabled.set(client.enabled);
this.modalName.set(client.name);
this.modalTypeName.set(client.typeName);
this.modalHost.set(client.host);
this.modalUsername.set(client.username);
this.modalPassword.set(client.password ?? '');
this.modalUrlBase.set(client.urlBase);
this.modalExternalUrl.set(client.externalUrl ?? '');
this.modalDownloadDirectorySource.set(client.downloadDirectorySource ?? '');
this.modalDownloadDirectoryTarget.set(client.downloadDirectoryTarget ?? '');
this.clientModel.set({
enabled: client.enabled,
name: client.name,
typeName: client.typeName,
host: client.host,
username: client.username,
password: client.password ?? '',
urlBase: client.urlBase,
externalUrl: client.externalUrl ?? '',
downloadDirectorySource: client.downloadDirectorySource ?? '',
downloadDirectoryTarget: client.downloadDirectoryTarget ?? '',
});
this.modalVisible.set(true);
}
testConnection(): void {
const m = this.clientModel();
const request: TestDownloadClientRequest = {
typeName: this.modalTypeName() as DownloadClientTypeName,
typeName: m.typeName,
type: DownloadClientType.Torrent,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
host: m.host,
username: m.username,
password: m.password,
urlBase: m.urlBase,
clientId: this.editingClient()?.id,
};
this.testing.set(true);
@@ -196,32 +202,33 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
}
saveClient(): void {
if (this.hasModalErrors()) {
if (this.clientForm().invalid()) {
return;
}
const editing = this.editingClient();
const m = this.clientModel();
this.saving.set(true);
if (editing) {
const client: ClientConfig = {
...editing,
enabled: this.modalEnabled(),
name: this.modalName(),
typeName: this.modalTypeName() as DownloadClientTypeName,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword() || undefined,
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
enabled: m.enabled,
name: m.name,
typeName: m.typeName,
host: m.host,
username: m.username,
password: m.password || undefined,
urlBase: m.urlBase,
externalUrl: m.externalUrl || undefined,
downloadDirectorySource: m.downloadDirectorySource || null,
downloadDirectoryTarget: m.downloadDirectoryTarget || null,
};
this.api.update(editing.id, client).subscribe({
next: () => {
this.toast.success('Client updated');
this.modalVisible.set(false);
this.saving.set(false);
this.loadClients();
this.clientsResource.reload();
},
error: () => {
this.toast.error('Failed to update client');
@@ -230,24 +237,24 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
});
} else {
const dto: CreateDownloadClientDto = {
enabled: this.modalEnabled(),
name: this.modalName(),
enabled: m.enabled,
name: m.name,
type: DownloadClientType.Torrent,
typeName: this.modalTypeName() as DownloadClientTypeName,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
typeName: m.typeName,
host: m.host,
username: m.username,
password: m.password,
urlBase: m.urlBase,
externalUrl: m.externalUrl || undefined,
downloadDirectorySource: m.downloadDirectorySource || null,
downloadDirectoryTarget: m.downloadDirectoryTarget || null,
};
this.api.create(dto).subscribe({
next: () => {
this.toast.success('Client added');
this.modalVisible.set(false);
this.saving.set(false);
this.loadClients();
this.clientsResource.reload();
},
error: () => {
this.toast.error('Failed to add client');
@@ -271,7 +278,7 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
this.api.delete(client.id).subscribe({
next: () => {
this.toast.success('Client deleted');
this.loadClients();
this.clientsResource.reload();
},
error: () => this.toast.error('Failed to delete client'),
});
@@ -19,13 +19,13 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Dry Run Mode" [(checked)]="dryRun"
<app-toggle label="Dry Run Mode" [formField]="genForm.dryRun"
hint="When enabled, actions will be logged without being executed (e.g. download removal)"
helpKey="general:dryRun" />
<app-toggle label="Display Support Banner" [(checked)]="displaySupportBanner"
<app-toggle label="Display Support Banner" [formField]="genForm.displaySupportBanner"
hint="Show the support section on the dashboard with links to GitHub and sponsors"
helpKey="general:displaySupportBanner" />
<app-toggle label="Status Check" [(checked)]="statusCheckEnabled"
<app-toggle label="Status Check" [formField]="genForm.statusCheckEnabled"
hint="When enabled, Cleanuparr will periodically check for new versions. Disable this if your environment has restricted outbound network access."
helpKey="general:statusCheckEnabled" />
@@ -35,7 +35,7 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="genForm.ignoredDownloads"
helpKey="general:ignoredDownloads"
/>
</div>
@@ -43,18 +43,18 @@
<app-card header="Authentication">
<div class="form-stack">
<app-toggle label="Disable Authentication for Local Addresses" [(checked)]="authDisableLocalAuth"
<app-toggle label="Disable Authentication for Local Addresses" [formField]="genForm.authDisableLocalAuth"
hint="When enabled, requests from local network addresses (localhost, 192.168.x.x, 10.x.x.x, 172.16-31.x.x) will bypass authentication"
helpKey="general:auth.disableLocalAuth" />
@if (authDisableLocalAuth()) {
<app-toggle label="Trust Forwarded Headers" [(checked)]="authTrustForwardedHeaders"
@if (genForm.authDisableLocalAuth().value()) {
<app-toggle label="Trust Forwarded Headers" [formField]="genForm.authTrustForwardedHeaders"
hint="When behind a reverse proxy, trust X-Forwarded-For and X-Real-IP headers to determine the client's real IP address. Only enable this if you are using a reverse proxy."
helpKey="general:auth.trustForwardedHeaders" />
<app-chip-input
label="Additional Trusted Networks"
placeholder="e.g. 192.168.1.0/24"
hint="Add custom IP addresses or CIDR ranges that should bypass authentication"
[(items)]="authTrustedNetworks"
[formField]="genForm.authTrustedNetworks"
helpKey="general:auth.trustedNetworks"
/>
}
@@ -64,15 +64,15 @@
<app-card header="HTTP Settings">
<div class="form-stack">
<div class="form-row">
<app-number-input label="Max Retries" [(value)]="httpMaxRetries" [min]="0" [max]="5"
<app-number-input label="Max Retries" [formField]="genForm.httpMaxRetries"
hint="Number of retry attempts for failed HTTPS requests"
[error]="httpMaxRetriesError()"
[error]="genForm.httpMaxRetries().errors()[0]?.message"
helpKey="general:httpMaxRetries" />
<app-number-input label="Timeout" [(value)]="httpTimeout" [min]="5" [max]="100" suffix="seconds"
<app-number-input label="Timeout" [formField]="genForm.httpTimeout" suffix="seconds"
hint="Timeout duration for HTTP requests in seconds"
[error]="httpTimeoutError()"
[error]="genForm.httpTimeout().errors()[0]?.message"
helpKey="general:httpTimeout" />
<app-select label="Certificate Validation" [options]="certOptions" [(value)]="httpCertificateValidation"
<app-select label="Certificate Validation" [options]="certOptions" [formField]="genForm.httpCertificateValidation"
hint="Enable or disable certificate validation for HTTPS requests"
helpKey="general:httpCertificateValidation" />
</div>
@@ -81,9 +81,9 @@
<app-card header="State Management">
<div class="form-stack">
<app-number-input label="Strike Inactivity Window" [(value)]="strikeInactivityWindowHours" [min]="1" [max]="168" suffix="hours"
<app-number-input label="Strike Inactivity Window" [formField]="genForm.strikeInactivityWindowHours" suffix="hours"
hint="Strikes for a download are cleared after it goes this many hours without receiving a new strike. As long as new strikes keep occurring, all strikes for that download are retained."
[error]="strikeInactivityWindowHoursError()"
[error]="genForm.strikeInactivityWindowHours().errors()[0]?.message"
helpKey="general:strikeInactivityWindowHours" />
<div class="form-divider"></div>
@@ -102,41 +102,41 @@
<app-accordion header="Logging" subtitle="Log file settings" [(expanded)]="logExpanded">
<div class="form-stack">
<app-select label="Log Level" [options]="logLevelOptions" [(value)]="logLevel"
<app-select label="Log Level" [options]="logLevelOptions" [formField]="genForm.logLevel"
hint="Select the minimum log level to display"
helpKey="general:log.level" />
<div class="form-divider"></div>
<div class="form-row">
<app-number-input label="Rolling Size" [(value)]="logRollingSizeMB" [min]="0" [max]="100" suffix="MB"
<app-number-input label="Rolling Size" [formField]="genForm.logRollingSizeMB" suffix="MB"
hint="Maximum size of each log file in megabytes (0 = disabled)"
[error]="logRollingSizeError()"
[error]="genForm.logRollingSizeMB().errors()[0]?.message"
helpKey="general:log.rollingSizeMB" />
<app-number-input label="Retained Files" [(value)]="logRetainedFileCount" [min]="0" [max]="50"
<app-number-input label="Retained Files" [formField]="genForm.logRetainedFileCount"
hint="Number of old log files to retain (0 = unlimited)"
[error]="logRetainedFileCountError()"
[error]="genForm.logRetainedFileCount().errors()[0]?.message"
helpKey="general:log.retainedFileCount" />
<app-number-input label="Time Limit" [(value)]="logTimeLimitHours" [min]="0" [max]="1440" suffix="hours"
<app-number-input label="Time Limit" [formField]="genForm.logTimeLimitHours" suffix="hours"
hint="Maximum age of old log files in hours (0 = unlimited)"
[error]="logTimeLimitError()"
[error]="genForm.logTimeLimitHours().errors()[0]?.message"
helpKey="general:log.timeLimitHours" />
</div>
<div class="form-divider"></div>
<app-toggle label="Archive Enabled" [(checked)]="logArchiveEnabled"
<app-toggle label="Archive Enabled" [formField]="genForm.logArchiveEnabled"
hint="Enable archiving of old log files"
helpKey="general:log.archiveEnabled" />
@if (logArchiveEnabled()) {
@if (genForm.logArchiveEnabled().value()) {
<div class="form-row">
<app-number-input label="Archive Retained Count" [(value)]="logArchiveRetainedCount" [min]="0" [max]="100"
<app-number-input label="Archive Retained Count" [formField]="genForm.logArchiveRetainedCount"
hint="Number of archive files to retain (0 = unlimited)"
[error]="logArchiveRetainedError()"
[error]="genForm.logArchiveRetainedCount().errors()[0]?.message"
helpKey="general:log.archiveRetainedCount" />
<app-number-input label="Archive Time Limit" [(value)]="logArchiveTimeLimitHours" [min]="0" [max]="1440" suffix="hours"
<app-number-input label="Archive Time Limit" [formField]="genForm.logArchiveTimeLimitHours" suffix="hours"
hint="Maximum age of archive files in hours (0 = unlimited)"
[error]="logArchiveTimeLimitError()"
[error]="genForm.logArchiveTimeLimitHours().errors()[0]?.message"
helpKey="general:log.archiveTimeLimitHours" />
</div>
}
@@ -1,7 +1,9 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, viewChildren } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, min, max, validate, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, ToggleComponent, InputComponent,
CardComponent, ButtonComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
type SelectOption,
@@ -29,19 +31,40 @@ const LOG_LEVEL_OPTIONS: SelectOption[] = [
{ label: 'Fatal', value: LogEventLevel.Fatal },
];
interface GeneralSettingsFormModel {
displaySupportBanner: boolean;
dryRun: boolean;
httpMaxRetries: number | null;
httpTimeout: number | null;
httpCertificateValidation: CertificateValidationType;
statusCheckEnabled: boolean;
ignoredDownloads: string[];
strikeInactivityWindowHours: number | null;
authDisableLocalAuth: boolean;
authTrustForwardedHeaders: boolean;
authTrustedNetworks: string[];
logLevel: LogEventLevel;
logRollingSizeMB: number | null;
logRetainedFileCount: number | null;
logTimeLimitHours: number | null;
logArchiveEnabled: boolean;
logArchiveRetainedCount: number | null;
logArchiveTimeLimitHours: number | null;
}
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, EmptyStateComponent, LoadingStateComponent,
AccordionComponent, EmptyStateComponent, LoadingStateComponent, FormField,
],
templateUrl: './general-settings.component.html',
styleUrl: './general-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
export class GeneralSettingsComponent implements HasPendingChanges {
private readonly api = inject(GeneralConfigApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
@@ -49,190 +72,163 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
private readonly savedSnapshot = signal('');
private readonly configResource = rxResource({
stream: () => this.api.get(),
});
readonly certOptions = CERT_OPTIONS;
readonly logLevelOptions = LOG_LEVEL_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
// Form state
readonly displaySupportBanner = signal(true);
readonly dryRun = signal(false);
readonly httpMaxRetries = signal<number | null>(3);
readonly httpTimeout = signal<number | null>(30);
readonly httpCertificateValidation = signal<unknown>(CertificateValidationType.Enabled);
readonly statusCheckEnabled = signal(true);
readonly ignoredDownloads = signal<string[]>([]);
readonly strikeInactivityWindowHours = signal<number | null>(24);
// UI-only state (not part of the form model)
readonly purgingStrikes = signal(false);
// Auth
readonly authDisableLocalAuth = signal(false);
readonly authTrustForwardedHeaders = signal(false);
readonly authTrustedNetworks = signal<string[]>([]);
// Logging
readonly logLevel = signal<unknown>(LogEventLevel.Information);
readonly logRollingSizeMB = signal<number | null>(10);
readonly logRetainedFileCount = signal<number | null>(5);
readonly logTimeLimitHours = signal<number | null>(168);
readonly logArchiveEnabled = signal(false);
readonly logArchiveRetainedCount = signal<number | null>(3);
readonly logArchiveTimeLimitHours = signal<number | null>(720);
readonly logExpanded = signal(false);
readonly httpMaxRetriesError = computed(() => {
const v = this.httpMaxRetries();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 5) return 'Maximum value is 5';
return undefined;
private readonly model = signal<GeneralSettingsFormModel>({
displaySupportBanner: true,
dryRun: false,
httpMaxRetries: 3,
httpTimeout: 30,
httpCertificateValidation: CertificateValidationType.Enabled,
statusCheckEnabled: true,
ignoredDownloads: [],
strikeInactivityWindowHours: 24,
authDisableLocalAuth: false,
authTrustForwardedHeaders: false,
authTrustedNetworks: [],
logLevel: LogEventLevel.Information,
logRollingSizeMB: 10,
logRetainedFileCount: 5,
logTimeLimitHours: 168,
logArchiveEnabled: false,
logArchiveRetainedCount: 3,
logArchiveTimeLimitHours: 720,
});
readonly httpTimeoutError = computed(() => {
const v = this.httpTimeout();
if (v == null) return 'This field is required';
if (v < 1) return 'Minimum value is 1';
if (v > 100) return 'Maximum value is 100';
return undefined;
readonly genForm = form(this.model, (p) => {
required(p.httpMaxRetries, { message: 'This field is required' });
min(p.httpMaxRetries, 0, { message: 'Minimum value is 0' });
max(p.httpMaxRetries, 5, { message: 'Maximum value is 5' });
required(p.httpTimeout, { message: 'This field is required' });
min(p.httpTimeout, 5, { message: 'Minimum value is 5' });
max(p.httpTimeout, 100, { message: 'Maximum value is 100' });
required(p.strikeInactivityWindowHours, { message: 'This field is required' });
min(p.strikeInactivityWindowHours, 1, { message: 'Minimum value is 1' });
max(p.strikeInactivityWindowHours, 168, { message: 'Maximum value is 168 hours (7 days)' });
required(p.logRollingSizeMB, { message: 'This field is required' });
min(p.logRollingSizeMB, 0, { message: 'Minimum value is 0' });
max(p.logRollingSizeMB, 100, { message: 'Maximum value is 100 MB' });
required(p.logRetainedFileCount, { message: 'This field is required' });
min(p.logRetainedFileCount, 0, { message: 'Minimum value is 0' });
max(p.logRetainedFileCount, 50, { message: 'Maximum value is 50' });
required(p.logTimeLimitHours, { message: 'This field is required' });
min(p.logTimeLimitHours, 0, { message: 'Minimum value is 0' });
max(p.logTimeLimitHours, 1440, { message: 'Maximum value is 1440 hours (60 days)' });
required(p.logArchiveRetainedCount, { message: 'This field is required' });
min(p.logArchiveRetainedCount, 0, { message: 'Minimum value is 0' });
max(p.logArchiveRetainedCount, 100, { message: 'Maximum value is 100' });
validate(p.logArchiveRetainedCount, () => this.bothZeroError());
required(p.logArchiveTimeLimitHours, { message: 'This field is required' });
min(p.logArchiveTimeLimitHours, 0, { message: 'Minimum value is 0' });
max(p.logArchiveTimeLimitHours, 1440, { message: 'Maximum value is 1440 hours (60 days)' });
validate(p.logArchiveTimeLimitHours, () => this.bothZeroError());
});
readonly logRollingSizeError = computed(() => {
const v = this.logRollingSizeMB();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 100) return 'Maximum value is 100 MB';
return undefined;
});
readonly logRetainedFileCountError = computed(() => {
const v = this.logRetainedFileCount();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 50) return 'Maximum value is 50';
return undefined;
});
readonly logTimeLimitError = computed(() => {
const v = this.logTimeLimitHours();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 1440) return 'Maximum value is 1440 hours (60 days)';
return undefined;
});
readonly logArchiveRetentionBothZeroError = computed(() =>
this.logArchiveEnabled() && this.logArchiveRetainedCount() === 0 && this.logArchiveTimeLimitHours() === 0
? 'Retained count and time limit cannot both be 0 when archiving is enabled'
: undefined
);
readonly logArchiveRetainedError = computed(() => {
const v = this.logArchiveRetainedCount();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 100) return 'Maximum value is 100';
return this.logArchiveRetentionBothZeroError();
});
readonly logArchiveTimeLimitError = computed(() => {
const v = this.logArchiveTimeLimitHours();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 1440) return 'Maximum value is 1440 hours (60 days)';
return this.logArchiveRetentionBothZeroError();
});
readonly strikeInactivityWindowHoursError = computed(() => {
const v = this.strikeInactivityWindowHours();
if (v == null) return 'This field is required';
if (v < 1) return 'Minimum value is 1';
if (v > 168) return 'Maximum value is 168 hours (7 days)';
return undefined;
});
readonly hasErrors = computed(() => !!(
this.strikeInactivityWindowHoursError() ||
this.httpMaxRetriesError() ||
this.httpTimeoutError() ||
this.logRollingSizeError() ||
this.logRetainedFileCountError() ||
this.logTimeLimitError() ||
this.logArchiveRetainedError() ||
this.logArchiveTimeLimitError() ||
this.chipInputs().some(c => c.hasUncommittedInput())
));
ngOnInit(): void {
this.loadConfig();
private bothZeroError() {
const m = this.model();
return m.logArchiveEnabled && m.logArchiveRetainedCount === 0 && m.logArchiveTimeLimitHours === 0
? { kind: 'bothZero', message: 'Retained count and time limit cannot both be 0 when archiving is enabled' }
: undefined;
}
private loadConfig(): void {
this.loader.start();
this.api.get().subscribe({
next: (config) => {
this.displaySupportBanner.set(config.displaySupportBanner);
this.dryRun.set(config.dryRun);
this.httpMaxRetries.set(config.httpMaxRetries);
this.httpTimeout.set(config.httpTimeout);
this.httpCertificateValidation.set(config.httpCertificateValidation);
this.statusCheckEnabled.set(config.statusCheckEnabled);
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours);
if (config.auth) {
this.authDisableLocalAuth.set(config.auth.disableAuthForLocalAddresses);
this.authTrustForwardedHeaders.set(config.auth.trustForwardedHeaders);
this.authTrustedNetworks.set(config.auth.trustedNetworks ?? []);
}
if (config.log) {
this.logLevel.set(config.log.level);
this.logRollingSizeMB.set(config.log.rollingSizeMB);
this.logRetainedFileCount.set(config.log.retainedFileCount);
this.logTimeLimitHours.set(config.log.timeLimitHours);
this.logArchiveEnabled.set(config.log.archiveEnabled);
this.logArchiveRetainedCount.set(config.log.archiveRetainedCount);
this.logArchiveTimeLimitHours.set(config.log.archiveTimeLimitHours);
}
this.loader.stop();
readonly hasErrors = computed(() =>
this.genForm().invalid() || this.chipInputs().some(c => c.hasUncommittedInput())
);
constructor() {
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.model.set({
displaySupportBanner: config.displaySupportBanner,
dryRun: config.dryRun,
httpMaxRetries: config.httpMaxRetries,
httpTimeout: config.httpTimeout,
httpCertificateValidation: config.httpCertificateValidation,
statusCheckEnabled: config.statusCheckEnabled,
ignoredDownloads: config.ignoredDownloads ?? [],
strikeInactivityWindowHours: config.strikeInactivityWindowHours,
authDisableLocalAuth: config.auth?.disableAuthForLocalAddresses ?? false,
authTrustForwardedHeaders: config.auth?.trustForwardedHeaders ?? false,
authTrustedNetworks: config.auth?.trustedNetworks ?? [],
logLevel: config.log?.level ?? LogEventLevel.Information,
logRollingSizeMB: config.log?.rollingSizeMB ?? 10,
logRetainedFileCount: config.log?.retainedFileCount ?? 5,
logTimeLimitHours: config.log?.timeLimitHours ?? 168,
logArchiveEnabled: config.log?.archiveEnabled ?? false,
logArchiveRetainedCount: config.log?.archiveRetainedCount ?? 3,
logArchiveTimeLimitHours: config.log?.archiveTimeLimitHours ?? 720,
});
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load general settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
save(): void {
const m = this.model();
const config: GeneralConfig = {
displaySupportBanner: this.displaySupportBanner(),
dryRun: this.dryRun(),
httpMaxRetries: this.httpMaxRetries() ?? 3,
httpTimeout: this.httpTimeout() ?? 30,
httpCertificateValidation: this.httpCertificateValidation() as CertificateValidationType,
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24,
ignoredDownloads: this.ignoredDownloads(),
displaySupportBanner: m.displaySupportBanner,
dryRun: m.dryRun,
httpMaxRetries: m.httpMaxRetries ?? 3,
httpTimeout: m.httpTimeout ?? 30,
httpCertificateValidation: m.httpCertificateValidation as CertificateValidationType,
statusCheckEnabled: m.statusCheckEnabled,
strikeInactivityWindowHours: m.strikeInactivityWindowHours ?? 24,
ignoredDownloads: m.ignoredDownloads,
auth: {
disableAuthForLocalAddresses: this.authDisableLocalAuth(),
trustForwardedHeaders: this.authTrustForwardedHeaders(),
trustedNetworks: this.authTrustedNetworks(),
disableAuthForLocalAddresses: m.authDisableLocalAuth,
trustForwardedHeaders: m.authTrustForwardedHeaders,
trustedNetworks: m.authTrustedNetworks,
},
log: {
level: this.logLevel() as LogEventLevel,
rollingSizeMB: this.logRollingSizeMB() ?? 10,
retainedFileCount: this.logRetainedFileCount() ?? 5,
timeLimitHours: this.logTimeLimitHours() ?? 168,
archiveEnabled: this.logArchiveEnabled(),
archiveRetainedCount: this.logArchiveRetainedCount() ?? 3,
archiveTimeLimitHours: this.logArchiveTimeLimitHours() ?? 720,
level: m.logLevel as LogEventLevel,
rollingSizeMB: m.logRollingSizeMB ?? 10,
retainedFileCount: m.logRetainedFileCount ?? 5,
timeLimitHours: m.logTimeLimitHours ?? 168,
archiveEnabled: m.logArchiveEnabled,
archiveRetainedCount: m.logArchiveRetainedCount ?? 3,
archiveTimeLimitHours: m.logArchiveTimeLimitHours ?? 720,
},
};
@@ -253,26 +249,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
}
private buildSnapshot(): string {
return JSON.stringify({
displaySupportBanner: this.displaySupportBanner(),
dryRun: this.dryRun(),
httpMaxRetries: this.httpMaxRetries(),
httpTimeout: this.httpTimeout(),
httpCertificateValidation: this.httpCertificateValidation(),
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours(),
ignoredDownloads: this.ignoredDownloads(),
authDisableLocalAuth: this.authDisableLocalAuth(),
authTrustForwardedHeaders: this.authTrustForwardedHeaders(),
authTrustedNetworks: this.authTrustedNetworks(),
logLevel: this.logLevel(),
logRollingSizeMB: this.logRollingSizeMB(),
logRetainedFileCount: this.logRetainedFileCount(),
logTimeLimitHours: this.logTimeLimitHours(),
logArchiveEnabled: this.logArchiveEnabled(),
logArchiveRetainedCount: this.logArchiveRetainedCount(),
logArchiveTimeLimitHours: this.logArchiveTimeLimitHours(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -19,42 +19,41 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="mbForm.enabled"
hint="When enabled, the Malware blocker will run according to the schedule"
helpKey="malware-blocker:enabled" />
@if (enabled()) {
<app-toggle label="Ignore Private Torrents" [(checked)]="ignorePrivate"
@if (mbForm.enabled().value()) {
<app-toggle label="Ignore Private Torrents" [formField]="mbForm.ignorePrivate"
hint="When enabled, private torrents will not be processed"
helpKey="malware-blocker:ignorePrivate" />
<app-toggle label="Delete Private from Client" [(checked)]="deletePrivate"
[disabled]="deletePrivateDisabled()"
<app-toggle label="Delete Private from Client" [formField]="mbForm.deletePrivate" [forceDisabled]="mbForm.deletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="malware-blocker:deletePrivate" />
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
<app-toggle label="Process downloads with no content ID" [formField]="mbForm.processNoContentId"
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
helpKey="malware-blocker:processNoContentId" />
<app-toggle label="Delete if any file is blocked" featureId="delete-if-any-malware" [(checked)]="deleteIfAnyFileBlocked"
<app-toggle label="Delete if any file is blocked" featureId="delete-if-any-malware" [formField]="mbForm.deleteIfAnyFileBlocked"
hint="When enabled, the entire download will be removed if any file in it matches the blocklist. When disabled, the download is only removed when all of its files match."
helpKey="malware-blocker:deleteIfAnyFileBlocked" />
<div class="form-divider"></div>
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
<app-toggle label="Advanced Scheduling" [formField]="mbForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="malware-blocker:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (mbForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="mbForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="mbForm.cronExpression().errors()[0]?.message"
helpKey="malware-blocker:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="mbForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="malware-blocker:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="mbForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="mbForm.scheduleEvery().errors()[0]?.message"
helpKey="malware-blocker:scheduleEvery" />
</div>
}
@@ -65,39 +64,36 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="mbForm.ignoredDownloads"
helpKey="malware-blocker:ignoredDownloads"
/>
}
</div>
</app-card>
@if (enabled()) {
@if (mbForm.enabled().value()) {
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
@for (name of arrNames; track name) {
@for (arr of arrFields; track arr.label) {
<div class="arr-blocklist">
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
<h4 class="arr-blocklist__title">{{ arr.label }}</h4>
<div class="form-stack">
<app-toggle
label="Enabled"
[checked]="arrBlocklists()[name].enabled"
(checkedChange)="updateArrBlocklist(name, 'enabled', $event)"
[hint]="'When enabled, the ' + capitalize(name) + ' blocklist will be used for content filtering'"
[formField]="arr.field.enabled"
[hint]="'When enabled, the ' + arr.label + ' blocklist will be used for content filtering'"
/>
@if (arrBlocklists()[name].enabled) {
@if (arr.field.enabled().value()) {
<app-input
label="Blocklist Path"
[value]="arrBlocklists()[name].blocklistPath"
(valueChange)="updateArrBlocklist(name, 'blocklistPath', $event)"
[formField]="arr.field.blocklistPath"
placeholder="Local file path or URL"
hint="Path to the blocklist file or URL"
[error]="blocklistPathError(name)"
[error]="arr.field.blocklistPath().errors()[0]?.message"
/>
<app-select
label="Blocklist Type"
[options]="blocklistTypeOptions"
[value]="arrBlocklists()[name].blocklistType"
(valueChange)="updateArrBlocklist(name, 'blocklistType', $event)"
[formField]="arr.field.blocklistType"
hint="Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)"
/>
}
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, computed, viewChildren, effect, untracked } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChildren, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, validate, disabled, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -27,53 +29,72 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
const ARR_NAMES = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'] as const;
interface ArrBlocklistFormModel {
enabled: boolean;
blocklistPath: string;
blocklistType: BlocklistType;
}
interface MalwareBlockerFormModel {
enabled: boolean;
ignorePrivate: boolean;
deletePrivate: boolean;
processNoContentId: boolean;
deleteIfAnyFileBlocked: boolean;
useAdvancedScheduling: boolean;
cronExpression: string;
scheduleEvery: number;
scheduleUnit: ScheduleUnit;
ignoredDownloads: string[];
sonarr: ArrBlocklistFormModel;
radarr: ArrBlocklistFormModel;
lidarr: ArrBlocklistFormModel;
readarr: ArrBlocklistFormModel;
whisparr: ArrBlocklistFormModel;
}
@Component({
selector: 'app-malware-blocker',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
EmptyStateComponent, LoadingStateComponent, FormField,
],
templateUrl: './malware-blocker.component.html',
styleUrl: './malware-blocker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
export class MalwareBlockerComponent implements 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 processNoContentId = signal(false);
readonly deleteIfAnyFileBlocked = 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 }));
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
// Per-arr blocklist settings
readonly arrBlocklists = signal<Record<string, { enabled: boolean; blocklistPath: string; blocklistType: unknown }>>({
readonly blocklistTypeOptions = BLOCKLIST_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
readonly arrExpanded = signal(false);
private readonly model = signal<MalwareBlockerFormModel>({
enabled: false,
ignorePrivate: false,
deletePrivate: false,
processNoContentId: false,
deleteIfAnyFileBlocked: false,
useAdvancedScheduling: false,
cronExpression: '',
scheduleEvery: 5,
scheduleUnit: ScheduleUnit.Seconds,
ignoredDownloads: [],
sonarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
radarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
lidarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
@@ -81,154 +102,158 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
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]));
readonly mbForm = form(this.model, (p) => {
validate(p.scheduleEvery, ({ value, valueOf }) => {
if (!valueOf(p.enabled) || valueOf(p.useAdvancedScheduling)) {
return undefined;
}
const options = MalwareScheduleOptions[valueOf(p.scheduleUnit)] ?? [];
return options.includes(value()) ? undefined : { kind: 'schedule', message: 'Please select a value' };
});
effect(() => {
const ignorePrivate = this.ignorePrivate();
if (ignorePrivate) {
untracked(() => this.deletePrivate.set(false));
}
validate(p.cronExpression, ({ value, valueOf }) => {
return valueOf(p.enabled) && valueOf(p.useAdvancedScheduling) && !value().trim()
? { kind: 'required', message: 'Cron expression is required' }
: undefined;
});
}
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;
disabled(p.deletePrivate, () => this.model().ignorePrivate);
required(p.sonarr.blocklistPath, { when: () => this.model().sonarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.radarr.blocklistPath, { when: () => this.model().radarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.lidarr.blocklistPath, { when: () => this.model().lidarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.readarr.blocklistPath, { when: () => this.model().readarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.whisparr.blocklistPath, { when: () => this.model().whisparr.enabled, message: 'Path is required when blocklist is enabled' });
validate(p.enabled, () => {
const m = this.model();
if (!m.enabled) {
return undefined;
}
const hasAnyEnabled = ARR_NAMES.some((name) => m[name].enabled);
return hasAnyEnabled ? undefined : { kind: 'noBlocklist', message: 'At least one blocklist must be configured' };
});
});
readonly cronError = computed(() => {
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required';
return undefined;
readonly arrFields = [
{ label: 'Sonarr', field: this.mbForm.sonarr },
{ label: 'Radarr', field: this.mbForm.radarr },
{ label: 'Lidarr', field: this.mbForm.lidarr },
{ label: 'Readarr', field: this.mbForm.readarr },
{ label: 'Whisparr', field: this.mbForm.whisparr },
];
readonly scheduleIntervalOptions = computed(() => {
const values = MalwareScheduleOptions[this.model().scheduleUnit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
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(() =>
this.mbForm.enabled().errors().find((e) => e.kind === 'noBlocklist')?.message
);
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;
});
readonly hasErrors = computed(() =>
this.mbForm().invalid() || this.chipInputs().some((c) => c.hasUncommittedInput())
);
private config: MalwareBlockerConfig | null = null;
ngOnInit(): void {
this.loadConfig();
}
constructor() {
effect(() => {
const unit = this.model().scheduleUnit;
const options = MalwareScheduleOptions[unit] ?? [];
const current = this.model().scheduleEvery;
if (options.length > 0 && !options.includes(current)) {
untracked(() => this.model.update(m => ({ ...m, scheduleEvery: options[0] })));
}
});
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
effect(() => {
const m = this.model();
// Guard on the current value: model.update always creates a new object, so writing unconditionally re-triggers this effect forever (infinite loop / page freeze).
if (m.ignorePrivate && m.deletePrivate) {
untracked(() => this.model.update(mm => ({ ...mm, deletePrivate: false })));
}
});
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
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);
this.processNoContentId.set(config.processNoContentId);
this.deleteIfAnyFileBlocked.set(config.deleteIfAnyFileBlocked);
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.model.set({
enabled: config.enabled,
ignorePrivate: config.ignorePrivate,
deletePrivate: config.deletePrivate,
processNoContentId: config.processNoContentId,
deleteIfAnyFileBlocked: config.deleteIfAnyFileBlocked,
useAdvancedScheduling: config.useAdvancedScheduling,
cronExpression: config.cronExpression,
scheduleEvery: parsed?.every ?? 5,
scheduleUnit: parsed?.type ?? ScheduleUnit.Seconds,
ignoredDownloads: config.ignoredDownloads ?? [],
sonarr: this.toArrModel(config.sonarr),
radarr: this.toArrModel(config.radarr),
lidarr: this.toArrModel(config.lidarr),
readarr: this.toArrModel(config.readarr),
whisparr: this.toArrModel(config.whisparr),
});
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load malware blocker settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
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);
private toArrModel(b: BlocklistSettings): ArrBlocklistFormModel {
return { enabled: b.enabled, blocklistPath: b.blocklistPath, blocklistType: b.blocklistType };
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
save(): void {
if (!this.config) return;
if (!this.config) {
return;
}
const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit };
const cronExpression = this.useAdvancedScheduling()
? this.cronExpression()
const m = this.model();
const jobSchedule = { every: m.scheduleEvery ?? 5, type: m.scheduleUnit };
const cronExpression = m.useAdvancedScheduling
? m.cronExpression
: generateCronExpression(jobSchedule);
const blocklists = this.arrBlocklists();
const config: MalwareBlockerConfig = {
...this.config,
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
enabled: m.enabled,
useAdvancedScheduling: m.useAdvancedScheduling,
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
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 },
ignoredDownloads: m.ignoredDownloads,
ignorePrivate: m.ignorePrivate,
deletePrivate: m.deletePrivate,
processNoContentId: m.processNoContentId,
deleteIfAnyFileBlocked: m.deleteIfAnyFileBlocked,
sonarr: { ...m.sonarr },
radarr: { ...m.radarr },
lidarr: { ...m.lidarr },
readarr: { ...m.readarr },
whisparr: { ...m.whisparr },
};
this.saving.set(true);
@@ -250,19 +275,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
}
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(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
arrBlocklists: this.arrBlocklists(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -101,163 +101,165 @@
size="lg"
>
<div class="modal-form">
<app-toggle label="Enabled" [(checked)]="modalEnabled"
<app-toggle label="Enabled" [formField]="modalForm.enabled"
hint="Enable or disable this provider" />
<app-input label="Name" placeholder="My Provider" [(value)]="modalName"
<app-input label="Name" placeholder="My Provider" [formField]="modalForm.name"
hint="A unique name to identify this provider"
[error]="modalNameError()" />
[error]="modalForm.name().errors()[0]?.message" />
<!-- Discord Fields -->
@if (modalType() === 'Discord') {
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [(value)]="modalWebhookUrl"
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [formField]="modalForm.webhookUrl"
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
[error]="discordWebhookError()"
[error]="modalForm.webhookUrl().errors()[0]?.message"
helpKey="notifications/discord:webhookUrl" />
<app-input label="Username" placeholder="Cleanuparr" [(value)]="modalUsername"
<app-input label="Username" placeholder="Cleanuparr" [formField]="modalForm.username"
hint="Override the default webhook username. Leave empty to use the webhook's default name."
helpKey="notifications/discord:username" />
<app-input label="Avatar URL" placeholder="https://example.com/avatar.png" type="url" [(value)]="modalAvatarUrl"
<app-input label="Avatar URL" placeholder="https://example.com/avatar.png" type="url" [formField]="modalForm.avatarUrl"
hint="Override the default webhook avatar. Leave empty to use the webhook's default avatar."
helpKey="notifications/discord:avatarUrl" />
}
<!-- Telegram Fields -->
@if (modalType() === 'Telegram') {
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [revealable]="false" [(value)]="modalBotToken"
<app-input label="Bot Token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" type="password" [revealable]="false" [formField]="modalForm.botToken"
hint="Create a bot with BotFather and paste the API token"
[error]="telegramBotTokenError()"
[error]="modalForm.botToken().errors()[0]?.message"
helpKey="notifications/telegram:botToken" />
<app-input label="Chat ID" placeholder="-1001234567890" [(value)]="modalChatId"
<app-input label="Chat ID" placeholder="-1001234567890" [formField]="modalForm.chatId"
hint="Start a conversation with the bot or add it to your group to get the chat ID"
[error]="telegramChatIdError()"
[error]="modalForm.chatId().errors()[0]?.message"
helpKey="notifications/telegram:chatId" />
<app-input label="Topic ID" placeholder="Optional" [(value)]="modalTopicId"
<app-input label="Topic ID" placeholder="Optional" [formField]="modalForm.topicId"
hint="Specify a Topic ID to send to a specific thread (supergroups only)"
helpKey="notifications/telegram:topicId" />
<app-toggle label="Send Silently" [(checked)]="modalSendSilently"
<app-toggle label="Send Silently" [formField]="modalForm.sendSilently"
hint="Deliver without sound for recipients"
helpKey="notifications/telegram:sendSilently" />
}
<!-- Notifiarr Fields -->
@if (modalType() === 'Notifiarr') {
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [(value)]="modalApiKey"
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="modalForm.apiKey"
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
[error]="notifiarrApiKeyError()"
[error]="modalForm.apiKey().errors()[0]?.message"
helpKey="notifications/notifiarr:apiKey" />
<app-input label="Channel ID" placeholder="Enter Discord channel ID" [(value)]="modalChannelId"
<app-input label="Channel ID" placeholder="Enter Discord channel ID" [formField]="modalForm.channelId"
hint="The Discord channel ID where notifications will be sent."
helpKey="notifications/notifiarr:channelId" />
}
<!-- Apprise Fields -->
@if (modalType() === 'Apprise') {
<app-select label="Mode" [options]="appriseOptions" [(value)]="modalAppriseMode"
<app-select label="Mode" [options]="appriseOptions" [formField]="modalForm.appriseMode"
hint="API mode requires an external Apprise container. CLI mode uses the Apprise CLI directly."
helpKey="notifications/apprise:mode" />
@if (modalAppriseMode() === 'Api') {
<app-input label="Server URL" placeholder="http://localhost:8000" [(value)]="modalAppriseUrl"
@if (modalForm.appriseMode().value() === 'Api') {
<app-input label="Server URL" placeholder="http://localhost:8000" [formField]="modalForm.appriseUrl"
hint="The URL of your Apprise server where notifications will be sent."
[error]="appriseUrlError()"
[error]="modalForm.appriseUrl().errors()[0]?.message"
helpKey="notifications/apprise:url" />
<app-input label="Configuration Key" placeholder="Enter key" [(value)]="modalAppriseKey"
<app-input label="Configuration Key" placeholder="Enter key" [formField]="modalForm.appriseKey"
hint="The key that identifies your Apprise configuration on the server."
[error]="appriseKeyError()"
[error]="modalForm.appriseKey().errors()[0]?.message"
helpKey="notifications/apprise:key" />
}
@if (modalAppriseMode() === 'Cli') {
<app-chip-input label="Service URLs" placeholder="discord://webhook_id/token" [(items)]="modalAppriseServiceUrls"
@if (modalForm.appriseMode().value() === 'Cli') {
<app-chip-input label="Service URLs" placeholder="discord://webhook_id/token" [formField]="modalForm.appriseServiceUrls"
hint="Add Apprise service URLs. Example: discord://webhook_id/token."
[error]="appriseServiceUrlsError()"
[error]="modalForm.appriseServiceUrls().errors()[0]?.message"
helpKey="notifications/apprise:serviceUrls" />
}
<app-input label="Tags" placeholder="all" [(value)]="modalAppriseTags"
<app-input label="Tags" placeholder="all" [formField]="modalForm.appriseTags"
hint="Optional tags to filter notifications. Use comma (,) to OR tags and space ( ) to AND them."
helpKey="notifications/apprise:tags" />
}
<!-- Ntfy Fields -->
@if (modalType() === 'Ntfy') {
<app-input label="Server URL" placeholder="https://ntfy.sh" [(value)]="modalNtfyServerUrl"
<app-input label="Server URL" placeholder="https://ntfy.sh" [formField]="modalForm.ntfyServerUrl"
hint="The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance."
[error]="ntfyServerUrlError()"
[error]="modalForm.ntfyServerUrl().errors()[0]?.message"
helpKey="notifications/ntfy:serverUrl" />
<app-chip-input label="Topics" placeholder="cleanuparr" [(items)]="modalNtfyTopics"
<app-chip-input label="Topics" placeholder="cleanuparr" [formField]="modalForm.ntfyTopics"
hint="Enter the ntfy topics you want to publish to."
[error]="ntfyTopicsError()"
[error]="modalForm.ntfyTopics().errors()[0]?.message"
helpKey="notifications/ntfy:topics" />
<app-select label="Authentication" [options]="ntfyAuthOptions" [(value)]="modalNtfyAuthType"
<app-select label="Authentication" [options]="ntfyAuthOptions" [formField]="modalForm.ntfyAuthType"
hint="Choose how to authenticate with the ntfy server."
helpKey="notifications/ntfy:authenticationType" />
@if (modalNtfyAuthType() === 'BasicAuth') {
<app-input label="Username" placeholder="Enter username" [(value)]="modalNtfyUsername"
@if (modalForm.ntfyAuthType().value() === 'BasicAuth') {
<app-input label="Username" placeholder="Enter username" [formField]="modalForm.ntfyUsername"
hint="Your username for basic authentication."
helpKey="notifications/ntfy:username" />
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalNtfyPassword"
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [formField]="modalForm.ntfyPassword"
hint="Your password for basic authentication."
helpKey="notifications/ntfy:password" />
}
@if (modalNtfyAuthType() === 'AccessToken') {
<app-input label="Access Token" placeholder="Enter access token" type="password" [revealable]="false" [(value)]="modalNtfyAccessToken"
@if (modalForm.ntfyAuthType().value() === 'AccessToken') {
<app-input label="Access Token" placeholder="Enter access token" type="password" [revealable]="false" [formField]="modalForm.ntfyAccessToken"
hint="Your access token for bearer token authentication."
helpKey="notifications/ntfy:accessToken" />
}
<app-select label="Priority" [options]="ntfyPriorityOptions" [(value)]="modalNtfyPriority"
<app-select label="Priority" [options]="ntfyPriorityOptions" [formField]="modalForm.ntfyPriority"
hint="The priority level for notifications."
helpKey="notifications/ntfy:priority" />
<app-chip-input label="Tags" placeholder="warning" [(items)]="modalNtfyTags"
<app-chip-input label="Tags" placeholder="warning" [formField]="modalForm.ntfyTags"
hint="Optional tags to add to notifications (e.g., warning, alert)."
helpKey="notifications/ntfy:tags" />
}
<!-- Pushover Fields -->
@if (modalType() === 'Pushover') {
<app-input label="API Token" placeholder="Enter API token" type="password" [revealable]="false" [(value)]="modalPushoverApiToken"
<app-input label="API Token" placeholder="Enter API token" type="password" [revealable]="false" [formField]="modalForm.pushoverApiToken"
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
[error]="pushoverApiTokenError()"
[error]="modalForm.pushoverApiToken().errors()[0]?.message"
helpKey="notifications/pushover:apiToken" />
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [(value)]="modalPushoverUserKey"
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [formField]="modalForm.pushoverUserKey"
hint="Your user/group key from your Pushover dashboard."
[error]="pushoverUserKeyError()"
[error]="modalForm.pushoverUserKey().errors()[0]?.message"
helpKey="notifications/pushover:userKey" />
<app-chip-input label="Devices" placeholder="myphone" [(items)]="modalPushoverDevices"
<app-chip-input label="Devices" placeholder="myphone" [formField]="modalForm.pushoverDevices"
hint="Leave empty to send to all devices, or enter specific device names."
helpKey="notifications/pushover:devices" />
<app-select label="Priority" [options]="pushoverPriorityOptions" [(value)]="modalPushoverPriority"
<app-select label="Priority" [options]="pushoverPriorityOptions" [formField]="modalForm.pushoverPriority"
hint="The priority level for notifications. Emergency priority will repeat until acknowledged."
helpKey="notifications/pushover:priority" />
@if (modalPushoverPriority() === 'Emergency') {
<app-number-input label="Retry (seconds)" [(value)]="modalPushoverRetry" [min]="30" [step]="30"
@if (modalForm.pushoverPriority().value() === 'Emergency') {
<app-number-input label="Retry (seconds)" [formField]="modalForm.pushoverRetry" [step]="30"
[error]="modalForm.pushoverRetry().errors()[0]?.message"
hint="How often (in seconds) the notification will be resent until acknowledged. Minimum 30 seconds."
helpKey="notifications/pushover:retry" />
<app-number-input label="Expire (seconds)" [(value)]="modalPushoverExpire" [min]="1" [max]="10800"
<app-number-input label="Expire (seconds)" [formField]="modalForm.pushoverExpire"
[error]="modalForm.pushoverExpire().errors()[0]?.message"
hint="How long (in seconds) the notification will continue to be retried. Maximum 10800 seconds (3 hours)."
helpKey="notifications/pushover:expire" />
}
<app-select label="Sound" [options]="pushoverSoundOptions" [(value)]="modalPushoverSound"
<app-select label="Sound" [options]="pushoverSoundOptions" [formField]="modalForm.pushoverSound"
hint="Choose a notification sound, or select Custom to enter your own."
helpKey="notifications/pushover:sound" />
@if (modalPushoverSound() === '__custom__') {
<app-input label="Custom Sound" placeholder="Enter custom sound name" [(value)]="modalPushoverCustomSound"
@if (modalForm.pushoverSound().value() === '__custom__') {
<app-input label="Custom Sound" placeholder="Enter custom sound name" [formField]="modalForm.pushoverCustomSound"
hint="Enter the name of a custom sound you've uploaded to Pushover."
helpKey="notifications/pushover:customSound" />
}
<app-chip-input label="Tags" placeholder="tag1" [(items)]="modalPushoverTags"
<app-chip-input label="Tags" placeholder="tag1" [formField]="modalForm.pushoverTags"
hint="Tags for receipt tracking and batch cancellation of emergency notifications."
helpKey="notifications/pushover:tags" />
}
<!-- Gotify Fields -->
@if (modalType() === 'Gotify') {
<app-input label="Server URL" placeholder="https://gotify.example.com" [(value)]="modalGotifyServerUrl"
<app-input label="Server URL" placeholder="https://gotify.example.com" [formField]="modalForm.gotifyServerUrl"
hint="The base URL of your Gotify server instance."
[error]="gotifyServerUrlError()"
[error]="modalForm.gotifyServerUrl().errors()[0]?.message"
helpKey="notifications/gotify:serverUrl" />
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [(value)]="modalGotifyApplicationToken"
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [formField]="modalForm.gotifyApplicationToken"
hint="The application token from your Gotify server. Create one under Apps in the Gotify web UI."
[error]="gotifyApplicationTokenError()"
[error]="modalForm.gotifyApplicationToken().errors()[0]?.message"
helpKey="notifications/gotify:applicationToken" />
<app-select label="Priority" [options]="gotifyPriorityOptions" [(value)]="modalGotifyPriority"
<app-select label="Priority" [options]="gotifyPriorityOptions" [formField]="modalForm.gotifyPriority"
hint="Message priority. Higher priority may trigger more intrusive notifications on clients."
helpKey="notifications/gotify:priority" />
}
@@ -265,28 +267,28 @@
<div class="event-flags">
<h4 class="event-flags__title">Events</h4>
<div class="event-flags__grid">
<app-toggle label="Failed Import Strike" [(checked)]="onFailedImportStrike"
<app-toggle label="Failed Import Strike" [formField]="modalForm.onFailedImportStrike"
hint="Notify when a failed import strike occurs"
helpKey="notifications:onFailedImportStrike" />
<app-toggle label="Stalled Strike" [(checked)]="onStalledStrike"
<app-toggle label="Stalled Strike" [formField]="modalForm.onStalledStrike"
hint="Notify when a stalled download strike occurs"
helpKey="notifications:onStalledStrike" />
<app-toggle label="Slow Strike" [(checked)]="onSlowStrike"
<app-toggle label="Slow Strike" [formField]="modalForm.onSlowStrike"
hint="Notify when a slow download strike occurs"
helpKey="notifications:onSlowStrike" />
<app-toggle label="Queue Item Deleted" [(checked)]="onQueueItemDeleted"
<app-toggle label="Queue Item Deleted" [formField]="modalForm.onQueueItemDeleted"
hint="Notify when a queue item is deleted"
helpKey="notifications:onQueueItemDeleted" />
<app-toggle label="Download Cleaned" [(checked)]="onDownloadCleaned"
<app-toggle label="Download Cleaned" [formField]="modalForm.onDownloadCleaned"
hint="Notify when a download is cleaned"
helpKey="notifications:onDownloadCleaned" />
<app-toggle label="Category Changed" [(checked)]="onCategoryChanged"
<app-toggle label="Category Changed" [formField]="modalForm.onCategoryChanged"
hint="Notify when a download's category is changed"
helpKey="notifications:onCategoryChanged" />
<app-toggle label="Search Triggered" [(checked)]="onSearchTriggered"
<app-toggle label="Search Triggered" [formField]="modalForm.onSearchTriggered"
hint="Notify when a search is triggered by the Seeker"
helpKey="notifications:onSearchTriggered" />
<app-toggle label="Search Item Grabbed" [(checked)]="onSearchItemGrabbed"
<app-toggle label="Search Item Grabbed" [formField]="modalForm.onSearchItemGrabbed"
hint="Notify when a search results in downloads being grabbed"
helpKey="notifications:onSearchItemGrabbed" />
</div>
@@ -296,7 +298,7 @@
<app-button variant="secondary" size="sm" [loading]="testing()" (clicked)="testNotification()">
Test
</app-button>
<app-button variant="primary" size="sm" [loading]="saving()" [disabled]="hasModalErrors()" (clicked)="saveProvider()">
<app-button variant="primary" size="sm" [loading]="saving()" [disabled]="modalForm().invalid()" (clicked)="saveProvider()">
Save
</app-button>
</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, validate, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -30,6 +32,110 @@ import {
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
interface ProviderConfiguration {
webhookUrl?: string;
username?: string;
avatarUrl?: string;
botToken?: string;
chatId?: string;
topicId?: string;
sendSilently?: boolean;
apiKey?: string;
channelId?: string;
mode?: AppriseMode;
url?: string;
key?: string;
tags?: string | string[];
serviceUrls?: string;
serverUrl?: string;
topics?: string[];
authenticationType?: NtfyAuthenticationType;
password?: string;
accessToken?: string;
priority?: number | NtfyPriority | PushoverPriority;
apiToken?: string;
userKey?: string;
devices?: string[];
sound?: string;
customSound?: string;
retry?: number;
expire?: number;
applicationToken?: string;
}
interface NotificationModalModel {
name: string;
enabled: boolean;
// Discord
webhookUrl: string;
username: string;
avatarUrl: string;
// Telegram
botToken: string;
chatId: string;
topicId: string;
sendSilently: boolean;
// Notifiarr
apiKey: string;
channelId: string;
// Apprise
appriseMode: AppriseMode;
appriseUrl: string;
appriseKey: string;
appriseTags: string;
appriseServiceUrls: string[];
// Ntfy
ntfyServerUrl: string;
ntfyTopics: string[];
ntfyAuthType: NtfyAuthenticationType;
ntfyUsername: string;
ntfyPassword: string;
ntfyAccessToken: string;
ntfyPriority: NtfyPriority;
ntfyTags: string[];
// Gotify
gotifyServerUrl: string;
gotifyApplicationToken: string;
gotifyPriority: string;
// Pushover
pushoverApiToken: string;
pushoverUserKey: string;
pushoverDevices: string[];
pushoverPriority: PushoverPriority;
pushoverRetry: number | null;
pushoverExpire: number | null;
pushoverSound: string;
pushoverCustomSound: string;
pushoverTags: string[];
// Events
onFailedImportStrike: boolean;
onStalledStrike: boolean;
onSlowStrike: boolean;
onQueueItemDeleted: boolean;
onDownloadCleaned: boolean;
onCategoryChanged: boolean;
onSearchTriggered: boolean;
onSearchItemGrabbed: boolean;
}
function createDefaultModalModel(): NotificationModalModel {
return {
name: '',
enabled: true,
webhookUrl: '', username: '', avatarUrl: '',
botToken: '', chatId: '', topicId: '', sendSilently: false,
apiKey: '', channelId: '',
appriseMode: AppriseMode.Api, appriseUrl: '', appriseKey: '', appriseTags: '', appriseServiceUrls: [],
ntfyServerUrl: 'https://ntfy.sh', ntfyTopics: [], ntfyAuthType: NtfyAuthenticationType.None,
ntfyUsername: '', ntfyPassword: '', ntfyAccessToken: '', ntfyPriority: NtfyPriority.Default, ntfyTags: [],
gotifyServerUrl: '', gotifyApplicationToken: '', gotifyPriority: '5',
pushoverApiToken: '', pushoverUserKey: '', pushoverDevices: [], pushoverPriority: PushoverPriority.Normal,
pushoverRetry: 30, pushoverExpire: 3600, pushoverSound: '', pushoverCustomSound: '', pushoverTags: [],
onFailedImportStrike: true, onStalledStrike: true, onSlowStrike: true, onQueueItemDeleted: true,
onDownloadCleaned: true, onCategoryChanged: false, onSearchTriggered: false, onSearchItemGrabbed: false,
};
}
const APPRISE_MODE_OPTIONS: SelectOption[] = [
{ label: 'API', value: AppriseMode.Api },
{ label: 'CLI', value: AppriseMode.Cli },
@@ -106,12 +212,13 @@ const PUSHOVER_SOUND_OPTIONS: SelectOption[] = [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ModalComponent, EmptyStateComponent,
BadgeComponent, ChipInputComponent, NumberInputComponent, LoadingStateComponent,
FormField,
],
templateUrl: './notifications.component.html',
styleUrl: './notifications.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationsComponent implements OnInit, HasPendingChanges {
export class NotificationsComponent implements HasPendingChanges {
private readonly api = inject(NotificationApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
@@ -119,10 +226,16 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
readonly theme = this.themeService.theme;
private readonly providersResource = rxResource({
stream: () => this.api.getProviders(),
});
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.providersResource.error());
readonly saving = signal(false);
readonly providers = signal<NotificationProviderDto[]>([]);
readonly providers = computed(() =>
this.providersResource.hasValue() ? (this.providersResource.value().providers ?? []) : [],
);
// Selection modal
readonly selectionModalVisible = signal(false);
@@ -131,164 +244,85 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
readonly modalVisible = signal(false);
readonly editingProvider = signal<NotificationProviderDto | null>(null);
readonly modalType = signal<NotificationProviderType>(NotificationProviderType.Discord);
readonly modalName = signal('');
readonly modalEnabled = signal(true);
readonly testing = signal(false);
// Discord fields
readonly modalWebhookUrl = signal('');
readonly modalUsername = signal('');
readonly modalAvatarUrl = signal('');
readonly modalModel = signal<NotificationModalModel>(createDefaultModalModel());
readonly modalForm = form(this.modalModel, (p) => {
validate(p.name, () =>
!this.modalModel().name.trim() ? { kind: 'required', message: 'Name is required' } : undefined);
// Telegram fields
readonly modalBotToken = signal('');
readonly modalChatId = signal('');
readonly modalTopicId = signal('');
readonly modalSendSilently = signal(false);
// Discord
validate(p.webhookUrl, () =>
this.modalType() === NotificationProviderType.Discord && !this.modalModel().webhookUrl.trim()
? { kind: 'required', message: 'Webhook URL is required' } : undefined);
// Notifiarr fields
readonly modalApiKey = signal('');
readonly modalChannelId = signal('');
// Telegram
validate(p.botToken, () =>
this.modalType() === NotificationProviderType.Telegram && !this.modalModel().botToken.trim()
? { kind: 'required', message: 'Bot token is required' } : undefined);
validate(p.chatId, () =>
this.modalType() === NotificationProviderType.Telegram && !this.modalModel().chatId.trim()
? { kind: 'required', message: 'Chat ID is required' } : undefined);
// Apprise fields
readonly modalAppriseMode = signal<unknown>(AppriseMode.Api);
readonly modalAppriseUrl = signal('');
readonly modalAppriseKey = signal('');
readonly modalAppriseTags = signal('');
readonly modalAppriseServiceUrls = signal<string[]>([]);
// Notifiarr
validate(p.apiKey, () =>
this.modalType() === NotificationProviderType.Notifiarr && !this.modalModel().apiKey.trim()
? { kind: 'required', message: 'API key is required' } : undefined);
// Ntfy fields
readonly modalNtfyServerUrl = signal('https://ntfy.sh');
readonly modalNtfyTopics = signal<string[]>([]);
readonly modalNtfyAuthType = signal<unknown>(NtfyAuthenticationType.None);
readonly modalNtfyUsername = signal('');
readonly modalNtfyPassword = signal('');
readonly modalNtfyAccessToken = signal('');
readonly modalNtfyPriority = signal<unknown>(NtfyPriority.Default);
readonly modalNtfyTags = signal<string[]>([]);
// Apprise
validate(p.appriseUrl, () =>
this.modalType() === NotificationProviderType.Apprise && this.modalModel().appriseMode === AppriseMode.Api && !this.modalModel().appriseUrl.trim()
? { kind: 'required', message: 'Server URL is required' } : undefined);
validate(p.appriseKey, () =>
this.modalType() === NotificationProviderType.Apprise && this.modalModel().appriseMode === AppriseMode.Api && !this.modalModel().appriseKey.trim()
? { kind: 'required', message: 'Config key is required' } : undefined);
validate(p.appriseServiceUrls, () =>
this.modalType() === NotificationProviderType.Apprise && this.modalModel().appriseMode === AppriseMode.Cli && this.modalModel().appriseServiceUrls.length === 0
? { kind: 'required', message: 'At least one service URL is required' } : undefined);
// Gotify fields
readonly modalGotifyServerUrl = signal('');
readonly modalGotifyApplicationToken = signal('');
readonly modalGotifyPriority = signal<unknown>('5');
// Ntfy
validate(p.ntfyServerUrl, () =>
this.modalType() === NotificationProviderType.Ntfy && !this.modalModel().ntfyServerUrl.trim()
? { kind: 'required', message: 'Server URL is required' } : undefined);
validate(p.ntfyTopics, () =>
this.modalType() === NotificationProviderType.Ntfy && this.modalModel().ntfyTopics.length === 0
? { kind: 'required', message: 'At least one topic is required' } : undefined);
// Pushover fields
readonly modalPushoverApiToken = signal('');
readonly modalPushoverUserKey = signal('');
readonly modalPushoverDevices = signal<string[]>([]);
readonly modalPushoverPriority = signal<unknown>(PushoverPriority.Normal);
readonly modalPushoverRetry = signal<number | null>(30);
readonly modalPushoverExpire = signal<number | null>(3600);
readonly modalPushoverSound = signal<unknown>('');
readonly modalPushoverCustomSound = signal('');
readonly modalPushoverTags = signal<string[]>([]);
// Pushover
validate(p.pushoverApiToken, () =>
this.modalType() === NotificationProviderType.Pushover && !this.modalModel().pushoverApiToken.trim()
? { kind: 'required', message: 'API token is required' } : undefined);
validate(p.pushoverUserKey, () =>
this.modalType() === NotificationProviderType.Pushover && !this.modalModel().pushoverUserKey.trim()
? { kind: 'required', message: 'User key is required' } : undefined);
// Retry/expire only apply to Emergency priority; skip otherwise so stale
// values from a hidden field can't keep the modal Save disabled.
validate(p.pushoverRetry, () => {
if (this.modalType() !== NotificationProviderType.Pushover
|| this.modalModel().pushoverPriority !== PushoverPriority.Emergency) {
return undefined;
}
const retry = this.modalModel().pushoverRetry;
return retry == null || retry < 30 ? { kind: 'min', message: 'Minimum 30 seconds' } : undefined;
});
validate(p.pushoverExpire, () => {
if (this.modalType() !== NotificationProviderType.Pushover
|| this.modalModel().pushoverPriority !== PushoverPriority.Emergency) {
return undefined;
}
const expire = this.modalModel().pushoverExpire;
if (expire == null || expire < 1) return { kind: 'min', message: 'Minimum 1 second' };
if (expire > 10800) return { kind: 'max', message: 'Maximum 10800 seconds' };
return undefined;
});
// Event flags
readonly onFailedImportStrike = signal(true);
readonly onStalledStrike = signal(true);
readonly onSlowStrike = signal(true);
readonly onQueueItemDeleted = signal(true);
readonly onDownloadCleaned = signal(true);
readonly onCategoryChanged = signal(false);
readonly onSearchTriggered = signal(false);
readonly onSearchItemGrabbed = signal(false);
// Modal validation
readonly modalNameError = computed(() => {
if (!this.modalName().trim()) return 'Name is required';
return undefined;
});
readonly hasModalErrors = computed(() => {
if (this.modalNameError()) return true;
const type = this.modalType();
switch (type) {
case NotificationProviderType.Discord:
return !this.modalWebhookUrl().trim();
case NotificationProviderType.Telegram:
return !this.modalBotToken().trim() || !this.modalChatId().trim();
case NotificationProviderType.Notifiarr:
return !this.modalApiKey().trim();
case NotificationProviderType.Apprise:
if ((this.modalAppriseMode() as AppriseMode) === AppriseMode.Api) {
return !this.modalAppriseUrl().trim() || !this.modalAppriseKey().trim();
}
return this.modalAppriseServiceUrls().length === 0;
case NotificationProviderType.Ntfy:
return !this.modalNtfyServerUrl().trim() || this.modalNtfyTopics().length === 0;
case NotificationProviderType.Pushover:
return !this.modalPushoverApiToken().trim() || !this.modalPushoverUserKey().trim();
case NotificationProviderType.Gotify:
return !this.modalGotifyServerUrl().trim() || !this.modalGotifyApplicationToken().trim();
}
return false;
});
// Per-provider field errors
readonly discordWebhookError = computed(() => {
if (this.modalType() !== NotificationProviderType.Discord) return undefined;
if (!this.modalWebhookUrl().trim()) return 'Webhook URL is required';
return undefined;
});
readonly telegramBotTokenError = computed(() => {
if (this.modalType() !== NotificationProviderType.Telegram) return undefined;
if (!this.modalBotToken().trim()) return 'Bot token is required';
return undefined;
});
readonly telegramChatIdError = computed(() => {
if (this.modalType() !== NotificationProviderType.Telegram) return undefined;
if (!this.modalChatId().trim()) return 'Chat ID is required';
return undefined;
});
readonly notifiarrApiKeyError = computed(() => {
if (this.modalType() !== NotificationProviderType.Notifiarr) return undefined;
if (!this.modalApiKey().trim()) return 'API key is required';
return undefined;
});
readonly appriseUrlError = computed(() => {
if (this.modalType() !== NotificationProviderType.Apprise) return undefined;
if ((this.modalAppriseMode() as AppriseMode) === AppriseMode.Api && !this.modalAppriseUrl().trim()) return 'Server URL is required';
return undefined;
});
readonly appriseKeyError = computed(() => {
if (this.modalType() !== NotificationProviderType.Apprise) return undefined;
if ((this.modalAppriseMode() as AppriseMode) === AppriseMode.Api && !this.modalAppriseKey().trim()) return 'Config key is required';
return undefined;
});
readonly appriseServiceUrlsError = computed(() => {
if (this.modalType() !== NotificationProviderType.Apprise) return undefined;
if ((this.modalAppriseMode() as AppriseMode) === AppriseMode.Cli && this.modalAppriseServiceUrls().length === 0) return 'At least one service URL is required';
return undefined;
});
readonly ntfyServerUrlError = computed(() => {
if (this.modalType() !== NotificationProviderType.Ntfy) return undefined;
if (!this.modalNtfyServerUrl().trim()) return 'Server URL is required';
return undefined;
});
readonly ntfyTopicsError = computed(() => {
if (this.modalType() !== NotificationProviderType.Ntfy) return undefined;
if (this.modalNtfyTopics().length === 0) return 'At least one topic is required';
return undefined;
});
readonly pushoverApiTokenError = computed(() => {
if (this.modalType() !== NotificationProviderType.Pushover) return undefined;
if (!this.modalPushoverApiToken().trim()) return 'API token is required';
return undefined;
});
readonly pushoverUserKeyError = computed(() => {
if (this.modalType() !== NotificationProviderType.Pushover) return undefined;
if (!this.modalPushoverUserKey().trim()) return 'User key is required';
return undefined;
});
readonly gotifyServerUrlError = computed(() => {
if (this.modalType() !== NotificationProviderType.Gotify) return undefined;
if (!this.modalGotifyServerUrl().trim()) return 'Server URL is required';
return undefined;
});
readonly gotifyApplicationTokenError = computed(() => {
if (this.modalType() !== NotificationProviderType.Gotify) return undefined;
if (!this.modalGotifyApplicationToken().trim()) return 'Application token is required';
return undefined;
// Gotify
validate(p.gotifyServerUrl, () =>
this.modalType() === NotificationProviderType.Gotify && !this.modalModel().gotifyServerUrl.trim()
? { kind: 'required', message: 'Server URL is required' } : undefined);
validate(p.gotifyApplicationToken, () =>
this.modalType() === NotificationProviderType.Gotify && !this.modalModel().gotifyApplicationToken.trim()
? { kind: 'required', message: 'Application token is required' } : undefined);
});
// Options (exposed for template)
@@ -310,28 +344,24 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
{ type: NotificationProviderType.Telegram, name: 'Telegram', iconUrl: 'icons/ext/telegram.svg', iconLightUrl: 'icons/ext/telegram-light.svg', description: 'core.telegram.org/bots' },
];
ngOnInit(): void {
this.loadProviders();
}
private loadProviders(): void {
this.loader.start();
this.api.getProviders().subscribe({
next: (config) => {
this.providers.set(config.providers ?? []);
this.loader.stop();
},
error: () => {
constructor() {
effect(() => {
if (this.providersResource.error()) {
this.toast.error('Failed to load notification providers');
}
});
effect(() => {
if (this.providersResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadProviders();
this.providersResource.reload();
}
openAddModal(): void {
@@ -342,162 +372,113 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
this.selectionModalVisible.set(false);
this.editingProvider.set(null);
this.modalType.set(type);
this.modalName.set('');
this.modalEnabled.set(true);
this.resetModalFields();
this.resetEventFlags();
this.modalModel.set(createDefaultModalModel());
this.modalVisible.set(true);
}
openEditModal(provider: NotificationProviderDto): void {
this.editingProvider.set(provider);
this.modalType.set(provider.type);
this.modalName.set(provider.name);
this.modalEnabled.set(provider.isEnabled);
this.resetModalFields();
const config = provider.configuration as any;
const config = provider.configuration as ProviderConfiguration;
const model = createDefaultModalModel();
model.name = provider.name;
model.enabled = provider.isEnabled;
switch (provider.type) {
case NotificationProviderType.Discord:
this.modalWebhookUrl.set(config.webhookUrl ?? '');
this.modalUsername.set(config.username ?? '');
this.modalAvatarUrl.set(config.avatarUrl ?? '');
model.webhookUrl = config.webhookUrl ?? '';
model.username = config.username ?? '';
model.avatarUrl = config.avatarUrl ?? '';
break;
case NotificationProviderType.Telegram:
this.modalBotToken.set(config.botToken ?? '');
this.modalChatId.set(config.chatId ?? '');
this.modalTopicId.set(config.topicId ?? '');
this.modalSendSilently.set(config.sendSilently ?? false);
model.botToken = config.botToken ?? '';
model.chatId = config.chatId ?? '';
model.topicId = config.topicId ?? '';
model.sendSilently = config.sendSilently ?? false;
break;
case NotificationProviderType.Notifiarr:
this.modalApiKey.set(config.apiKey ?? '');
this.modalChannelId.set(config.channelId ?? '');
model.apiKey = config.apiKey ?? '';
model.channelId = config.channelId ?? '';
break;
case NotificationProviderType.Apprise:
this.modalAppriseMode.set(config.mode ?? AppriseMode.Api);
this.modalAppriseUrl.set(config.url ?? '');
this.modalAppriseKey.set(config.key ?? '');
this.modalAppriseTags.set(config.tags ?? '');
this.modalAppriseServiceUrls.set(config.serviceUrls ? config.serviceUrls.split('\n').filter((s: string) => s.trim()) : []);
model.appriseMode = config.mode ?? AppriseMode.Api;
model.appriseUrl = config.url ?? '';
model.appriseKey = config.key ?? '';
model.appriseTags = (config.tags as string) ?? '';
model.appriseServiceUrls = config.serviceUrls ? config.serviceUrls.split('\n').filter((s: string) => s.trim()) : [];
break;
case NotificationProviderType.Ntfy:
this.modalNtfyServerUrl.set(config.serverUrl ?? 'https://ntfy.sh');
this.modalNtfyTopics.set(config.topics ?? []);
this.modalNtfyAuthType.set(config.authenticationType ?? NtfyAuthenticationType.None);
this.modalNtfyUsername.set(config.username ?? '');
this.modalNtfyPassword.set(config.password ?? '');
this.modalNtfyAccessToken.set(config.accessToken ?? '');
this.modalNtfyPriority.set(config.priority ?? NtfyPriority.Default);
this.modalNtfyTags.set(config.tags ?? []);
model.ntfyServerUrl = config.serverUrl ?? 'https://ntfy.sh';
model.ntfyTopics = config.topics ?? [];
model.ntfyAuthType = config.authenticationType ?? NtfyAuthenticationType.None;
model.ntfyUsername = config.username ?? '';
model.ntfyPassword = config.password ?? '';
model.ntfyAccessToken = config.accessToken ?? '';
model.ntfyPriority = Object.values(NtfyPriority).includes(config.priority as NtfyPriority)
? (config.priority as NtfyPriority)
: NtfyPriority.Default;
model.ntfyTags = (config.tags as string[]) ?? [];
break;
case NotificationProviderType.Pushover:
this.modalPushoverApiToken.set(config.apiToken ?? '');
this.modalPushoverUserKey.set(config.userKey ?? '');
this.modalPushoverDevices.set(config.devices ?? []);
this.modalPushoverPriority.set(config.priority ?? PushoverPriority.Normal);
this.modalPushoverRetry.set(config.retry ?? 30);
this.modalPushoverExpire.set(config.expire ?? 3600);
this.modalPushoverSound.set(config.sound ?? '');
this.modalPushoverCustomSound.set(config.customSound ?? '');
this.modalPushoverTags.set(config.tags ?? []);
model.pushoverApiToken = config.apiToken ?? '';
model.pushoverUserKey = config.userKey ?? '';
model.pushoverDevices = config.devices ?? [];
model.pushoverPriority = Object.values(PushoverPriority).includes(config.priority as PushoverPriority)
? (config.priority as PushoverPriority)
: PushoverPriority.Normal;
model.pushoverRetry = config.retry ?? 30;
model.pushoverExpire = config.expire ?? 3600;
model.pushoverSound = config.sound ?? '';
model.pushoverCustomSound = config.customSound ?? '';
model.pushoverTags = (config.tags as string[]) ?? [];
break;
case NotificationProviderType.Gotify:
this.modalGotifyServerUrl.set(config.serverUrl ?? '');
this.modalGotifyApplicationToken.set(config.applicationToken ?? '');
this.modalGotifyPriority.set(String(config.priority ?? 5));
model.gotifyServerUrl = config.serverUrl ?? '';
model.gotifyApplicationToken = config.applicationToken ?? '';
model.gotifyPriority = String(config.priority ?? 5);
break;
}
this.onFailedImportStrike.set(provider.events.onFailedImportStrike);
this.onStalledStrike.set(provider.events.onStalledStrike);
this.onSlowStrike.set(provider.events.onSlowStrike);
this.onQueueItemDeleted.set(provider.events.onQueueItemDeleted);
this.onDownloadCleaned.set(provider.events.onDownloadCleaned);
this.onCategoryChanged.set(provider.events.onCategoryChanged);
this.onSearchTriggered.set(provider.events.onSearchTriggered);
this.onSearchItemGrabbed.set(provider.events.onSearchItemGrabbed);
model.onFailedImportStrike = provider.events.onFailedImportStrike;
model.onStalledStrike = provider.events.onStalledStrike;
model.onSlowStrike = provider.events.onSlowStrike;
model.onQueueItemDeleted = provider.events.onQueueItemDeleted;
model.onDownloadCleaned = provider.events.onDownloadCleaned;
model.onCategoryChanged = provider.events.onCategoryChanged;
model.onSearchTriggered = provider.events.onSearchTriggered;
model.onSearchItemGrabbed = provider.events.onSearchItemGrabbed;
this.modalModel.set(model);
this.modalVisible.set(true);
}
private resetModalFields(): void {
// Discord
this.modalWebhookUrl.set('');
this.modalUsername.set('');
this.modalAvatarUrl.set('');
// Telegram
this.modalBotToken.set('');
this.modalChatId.set('');
this.modalTopicId.set('');
this.modalSendSilently.set(false);
// Notifiarr
this.modalApiKey.set('');
this.modalChannelId.set('');
// Apprise
this.modalAppriseMode.set(AppriseMode.Api);
this.modalAppriseUrl.set('');
this.modalAppriseKey.set('');
this.modalAppriseTags.set('');
this.modalAppriseServiceUrls.set([]);
// Ntfy
this.modalNtfyServerUrl.set('https://ntfy.sh');
this.modalNtfyTopics.set([]);
this.modalNtfyAuthType.set(NtfyAuthenticationType.None);
this.modalNtfyUsername.set('');
this.modalNtfyPassword.set('');
this.modalNtfyAccessToken.set('');
this.modalNtfyPriority.set(NtfyPriority.Default);
this.modalNtfyTags.set([]);
// Pushover
this.modalPushoverApiToken.set('');
this.modalPushoverUserKey.set('');
this.modalPushoverDevices.set([]);
this.modalPushoverPriority.set(PushoverPriority.Normal);
this.modalPushoverRetry.set(30);
this.modalPushoverExpire.set(3600);
this.modalPushoverSound.set('');
this.modalPushoverCustomSound.set('');
this.modalPushoverTags.set([]);
// Gotify
this.modalGotifyServerUrl.set('');
this.modalGotifyApplicationToken.set('');
this.modalGotifyPriority.set('5');
}
private resetEventFlags(): void {
this.onFailedImportStrike.set(true);
this.onStalledStrike.set(true);
this.onSlowStrike.set(true);
this.onQueueItemDeleted.set(true);
this.onDownloadCleaned.set(true);
this.onCategoryChanged.set(false);
this.onSearchTriggered.set(false);
this.onSearchItemGrabbed.set(false);
}
private getEventFlags() {
const m = this.modalModel();
return {
onFailedImportStrike: this.onFailedImportStrike(),
onStalledStrike: this.onStalledStrike(),
onSlowStrike: this.onSlowStrike(),
onQueueItemDeleted: this.onQueueItemDeleted(),
onDownloadCleaned: this.onDownloadCleaned(),
onCategoryChanged: this.onCategoryChanged(),
onSearchTriggered: this.onSearchTriggered(),
onSearchItemGrabbed: this.onSearchItemGrabbed(),
onFailedImportStrike: m.onFailedImportStrike,
onStalledStrike: m.onStalledStrike,
onSlowStrike: m.onSlowStrike,
onQueueItemDeleted: m.onQueueItemDeleted,
onDownloadCleaned: m.onDownloadCleaned,
onCategoryChanged: m.onCategoryChanged,
onSearchTriggered: m.onSearchTriggered,
onSearchItemGrabbed: m.onSearchItemGrabbed,
};
}
testNotification(): void {
const type = this.modalType();
const m = this.modalModel();
this.testing.set(true);
const providerId = this.editingProvider()?.id;
switch (type) {
case NotificationProviderType.Discord:
this.api.testDiscord({
webhookUrl: this.modalWebhookUrl(),
username: this.modalUsername() || undefined,
avatarUrl: this.modalAvatarUrl() || undefined,
webhookUrl: m.webhookUrl,
username: m.username || undefined,
avatarUrl: m.avatarUrl || undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -506,10 +487,10 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
break;
case NotificationProviderType.Telegram:
this.api.testTelegram({
botToken: this.modalBotToken(),
chatId: this.modalChatId(),
topicId: this.modalTopicId() || undefined,
sendSilently: this.modalSendSilently(),
botToken: m.botToken,
chatId: m.chatId,
topicId: m.topicId || undefined,
sendSilently: m.sendSilently,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -518,8 +499,8 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
break;
case NotificationProviderType.Notifiarr:
this.api.testNotifiarr({
apiKey: this.modalApiKey(),
channelId: this.modalChannelId(),
apiKey: m.apiKey,
channelId: m.channelId,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -528,11 +509,11 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
break;
case NotificationProviderType.Apprise:
this.api.testApprise({
mode: this.modalAppriseMode() as AppriseMode,
url: this.modalAppriseUrl() || undefined,
key: this.modalAppriseKey() || undefined,
tags: this.modalAppriseTags() || undefined,
serviceUrls: this.modalAppriseServiceUrls().join('\n') || undefined,
mode: m.appriseMode,
url: m.appriseUrl || undefined,
key: m.appriseKey || undefined,
tags: m.appriseTags || undefined,
serviceUrls: m.appriseServiceUrls.join('\n') || undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -541,14 +522,14 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
break;
case NotificationProviderType.Ntfy:
this.api.testNtfy({
serverUrl: this.modalNtfyServerUrl(),
topics: this.modalNtfyTopics(),
authenticationType: this.modalNtfyAuthType() as NtfyAuthenticationType,
username: this.modalNtfyUsername() || undefined,
password: this.modalNtfyPassword() || undefined,
accessToken: this.modalNtfyAccessToken() || undefined,
priority: this.modalNtfyPriority() as NtfyPriority,
tags: this.modalNtfyTags().length > 0 ? this.modalNtfyTags() : undefined,
serverUrl: m.ntfyServerUrl,
topics: m.ntfyTopics,
authenticationType: m.ntfyAuthType,
username: m.ntfyUsername || undefined,
password: m.ntfyPassword || undefined,
accessToken: m.ntfyAccessToken || undefined,
priority: m.ntfyPriority,
tags: m.ntfyTags.length > 0 ? m.ntfyTags : undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -556,16 +537,16 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
});
break;
case NotificationProviderType.Pushover: {
const sound = this.modalPushoverSound() as string;
const sound = m.pushoverSound;
this.api.testPushover({
apiToken: this.modalPushoverApiToken(),
userKey: this.modalPushoverUserKey(),
devices: this.modalPushoverDevices().length > 0 ? this.modalPushoverDevices() : undefined,
priority: this.modalPushoverPriority() as PushoverPriority,
sound: sound === '__custom__' ? this.modalPushoverCustomSound() : (sound || undefined),
retry: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverRetry() ?? 30) : undefined,
expire: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverExpire() ?? 3600) : undefined,
tags: this.modalPushoverTags().length > 0 ? this.modalPushoverTags() : undefined,
apiToken: m.pushoverApiToken,
userKey: m.pushoverUserKey,
devices: m.pushoverDevices.length > 0 ? m.pushoverDevices : undefined,
priority: m.pushoverPriority,
sound: sound === '__custom__' ? m.pushoverCustomSound : (sound || undefined),
retry: m.pushoverPriority === PushoverPriority.Emergency ? (m.pushoverRetry ?? 30) : undefined,
expire: m.pushoverPriority === PushoverPriority.Emergency ? (m.pushoverExpire ?? 3600) : undefined,
tags: m.pushoverTags.length > 0 ? m.pushoverTags : undefined,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -575,9 +556,9 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Gotify:
this.api.testGotify({
serverUrl: this.modalGotifyServerUrl(),
applicationToken: this.modalGotifyApplicationToken(),
priority: parseInt(this.modalGotifyPriority() as string, 10) || 5,
serverUrl: m.gotifyServerUrl,
applicationToken: m.gotifyApplicationToken,
priority: parseInt(m.gotifyPriority, 10) || 5,
providerId,
}).subscribe({
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
@@ -588,8 +569,9 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
saveProvider(): void {
if (this.hasModalErrors()) return;
if (this.modalForm().invalid()) return;
const type = this.modalType();
const m = this.modalModel();
const editing = this.editingProvider();
this.saving.set(true);
const eventFlags = this.getEventFlags();
@@ -597,11 +579,11 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
switch (type) {
case NotificationProviderType.Discord: {
const request: CreateDiscordProviderRequest = {
name: this.modalName(),
webhookUrl: this.modalWebhookUrl(),
username: this.modalUsername() || undefined,
avatarUrl: this.modalAvatarUrl() || undefined,
isEnabled: this.modalEnabled(),
name: m.name,
webhookUrl: m.webhookUrl,
username: m.username || undefined,
avatarUrl: m.avatarUrl || undefined,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateDiscord(editing.id, request) : this.api.createDiscord(request);
@@ -610,12 +592,12 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Telegram: {
const request: CreateTelegramProviderRequest = {
name: this.modalName(),
botToken: this.modalBotToken(),
chatId: this.modalChatId(),
topicId: this.modalTopicId() || undefined,
sendSilently: this.modalSendSilently(),
isEnabled: this.modalEnabled(),
name: m.name,
botToken: m.botToken,
chatId: m.chatId,
topicId: m.topicId || undefined,
sendSilently: m.sendSilently,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateTelegram(editing.id, request) : this.api.createTelegram(request);
@@ -624,10 +606,10 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Notifiarr: {
const request: CreateNotifiarrProviderRequest = {
name: this.modalName(),
apiKey: this.modalApiKey(),
channelId: this.modalChannelId(),
isEnabled: this.modalEnabled(),
name: m.name,
apiKey: m.apiKey,
channelId: m.channelId,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateNotifiarr(editing.id, request) : this.api.createNotifiarr(request);
@@ -636,13 +618,13 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Apprise: {
const request: CreateAppriseProviderRequest = {
name: this.modalName(),
mode: this.modalAppriseMode() as AppriseMode,
url: this.modalAppriseUrl() || undefined,
key: this.modalAppriseKey() || undefined,
tags: this.modalAppriseTags() || undefined,
serviceUrls: this.modalAppriseServiceUrls().join('\n') || undefined,
isEnabled: this.modalEnabled(),
name: m.name,
mode: m.appriseMode,
url: m.appriseUrl || undefined,
key: m.appriseKey || undefined,
tags: m.appriseTags || undefined,
serviceUrls: m.appriseServiceUrls.join('\n') || undefined,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateApprise(editing.id, request) : this.api.createApprise(request);
@@ -651,16 +633,16 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Ntfy: {
const request: CreateNtfyProviderRequest = {
name: this.modalName(),
serverUrl: this.modalNtfyServerUrl(),
topics: this.modalNtfyTopics(),
authenticationType: this.modalNtfyAuthType() as NtfyAuthenticationType,
username: this.modalNtfyUsername() || undefined,
password: this.modalNtfyPassword() || undefined,
accessToken: this.modalNtfyAccessToken() || undefined,
priority: this.modalNtfyPriority() as NtfyPriority,
tags: this.modalNtfyTags().length > 0 ? this.modalNtfyTags() : undefined,
isEnabled: this.modalEnabled(),
name: m.name,
serverUrl: m.ntfyServerUrl,
topics: m.ntfyTopics,
authenticationType: m.ntfyAuthType,
username: m.ntfyUsername || undefined,
password: m.ntfyPassword || undefined,
accessToken: m.ntfyAccessToken || undefined,
priority: m.ntfyPriority,
tags: m.ntfyTags.length > 0 ? m.ntfyTags : undefined,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateNtfy(editing.id, request) : this.api.createNtfy(request);
@@ -668,18 +650,18 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
break;
}
case NotificationProviderType.Pushover: {
const sound = this.modalPushoverSound() as string;
const sound = m.pushoverSound;
const request: CreatePushoverProviderRequest = {
name: this.modalName(),
apiToken: this.modalPushoverApiToken(),
userKey: this.modalPushoverUserKey(),
devices: this.modalPushoverDevices().length > 0 ? this.modalPushoverDevices() : undefined,
priority: this.modalPushoverPriority() as PushoverPriority,
sound: sound === '__custom__' ? this.modalPushoverCustomSound() : (sound || undefined),
retry: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverRetry() ?? 30) : undefined,
expire: this.modalPushoverPriority() === PushoverPriority.Emergency ? (this.modalPushoverExpire() ?? 3600) : undefined,
tags: this.modalPushoverTags().length > 0 ? this.modalPushoverTags() : undefined,
isEnabled: this.modalEnabled(),
name: m.name,
apiToken: m.pushoverApiToken,
userKey: m.pushoverUserKey,
devices: m.pushoverDevices.length > 0 ? m.pushoverDevices : undefined,
priority: m.pushoverPriority,
sound: sound === '__custom__' ? m.pushoverCustomSound : (sound || undefined),
retry: m.pushoverPriority === PushoverPriority.Emergency ? (m.pushoverRetry ?? 30) : undefined,
expire: m.pushoverPriority === PushoverPriority.Emergency ? (m.pushoverExpire ?? 3600) : undefined,
tags: m.pushoverTags.length > 0 ? m.pushoverTags : undefined,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updatePushover(editing.id, request) : this.api.createPushover(request);
@@ -688,11 +670,11 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
}
case NotificationProviderType.Gotify: {
const request: CreateGotifyProviderRequest = {
name: this.modalName(),
serverUrl: this.modalGotifyServerUrl(),
applicationToken: this.modalGotifyApplicationToken(),
priority: parseInt(this.modalGotifyPriority() as string, 10) || 5,
isEnabled: this.modalEnabled(),
name: m.name,
serverUrl: m.gotifyServerUrl,
applicationToken: m.gotifyApplicationToken,
priority: parseInt(m.gotifyPriority, 10) || 5,
isEnabled: m.enabled,
...eventFlags,
};
const obs = editing ? this.api.updateGotify(editing.id, request) : this.api.createGotify(request);
@@ -706,7 +688,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
this.toast.success(editing ? 'Provider updated' : 'Provider added');
this.modalVisible.set(false);
this.saving.set(false);
this.loadProviders();
this.providersResource.reload();
}
private onSaveError(): void {
@@ -726,7 +708,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
this.api.deleteProvider(provider.id).subscribe({
next: () => {
this.toast.success('Provider deleted');
this.loadProviders();
this.providersResource.reload();
},
error: () => this.toast.error('Failed to delete provider'),
});
@@ -19,30 +19,30 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="qcForm.enabled"
hint="When enabled, the queue cleaner will run according to the schedule"
helpKey="queue-cleaner:enabled" />
@if (enabled()) {
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
@if (qcForm.enabled().value()) {
<app-toggle label="Process downloads with no content ID" [formField]="qcForm.processNoContentId"
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
helpKey="queue-cleaner:processNoContentId" />
<div class="form-divider"></div>
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
<app-toggle label="Advanced Scheduling" [formField]="qcForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="queue-cleaner:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (qcForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="qcForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="qcForm.cronExpression().errors()[0]?.message"
helpKey="queue-cleaner:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="qcForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="queue-cleaner:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="qcForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="qcForm.scheduleEvery().errors()[0]?.message"
helpKey="queue-cleaner:scheduleEvery" />
</div>
}
@@ -51,55 +51,50 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="qcForm.ignoredDownloads"
helpKey="queue-cleaner:ignoredDownloads"
/>
}
</div>
</app-card>
@if (enabled()) {
@if (qcForm.enabled().value()) {
<app-accordion header="Failed Import" subtitle="Settings for handling failed imports" [(expanded)]="failedExpanded">
<div class="form-stack">
<app-number-input label="Max Strikes" [(value)]="failedMaxStrikes" [min]="0" [max]="5000"
<app-number-input label="Max Strikes" [formField]="qcForm.failedMaxStrikes"
hint="Number of strikes before action is taken (0 to disable, min 3 to enable)"
[error]="failedMaxStrikesError()"
[error]="qcForm.failedMaxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:failedImport.maxStrikes" />
<div class="form-divider"></div>
<app-toggle label="Ignore Private Torrents" [(checked)]="failedIgnorePrivate"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Ignore Private Torrents" [formField]="qcForm.failedIgnorePrivate" [forceDisabled]="qcForm.failedIgnorePrivate().disabled()"
hint="When enabled, private torrents will not be checked for being failed imports"
helpKey="queue-cleaner:failedImport.ignorePrivate" />
<app-toggle label="Change category instead of delete" [(checked)]="failedChangeCategory"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Change category instead of delete" [formField]="qcForm.failedChangeCategory" [forceDisabled]="qcForm.failedChangeCategory().disabled()"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:failedImport.changeCategory" />
@if (!failedChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="failedDeletePrivate"
[disabled]="failedDeletePrivateDisabled()"
@if (!qcForm.failedChangeCategory().value()) {
<app-toggle label="Delete Private from Client" [formField]="qcForm.failedDeletePrivate" [forceDisabled]="qcForm.failedDeletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:failedImport.deletePrivate" />
}
<app-toggle label="Skip if Not Found in Client" [(checked)]="failedSkipNotFound"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Skip if Not Found in Client" [formField]="qcForm.failedSkipNotFound" [forceDisabled]="qcForm.failedSkipNotFound().disabled()"
hint="Skip failed import check for torrents not found in any enabled torrent client"
helpKey="queue-cleaner:failedImport.skipIfNotFoundInClient" />
<div class="form-divider"></div>
<app-select label="Pattern Mode" [options]="patternModeOptions" [(value)]="failedPatternMode"
[disabled]="failedSubFieldsDisabled()"
<app-select label="Pattern Mode" [options]="patternModeOptions" [formField]="qcForm.failedPatternMode" [forceDisabled]="qcForm.failedPatternMode().disabled()"
hint="Choose how the patterns are applied to failed imports"
helpKey="queue-cleaner:failedImport.pattern-mode" />
<app-chip-input
[label]="patternLabel()"
placeholder="Add a pattern..."
[hint]="patternHint()"
[error]="failedPatternsError()"
[disabled]="failedSubFieldsDisabled()"
[(items)]="failedPatterns"
[error]="qcForm.failedPatterns().errors()[0]?.message"
[formField]="qcForm.failedPatterns"
[forceDisabled]="qcForm.failedPatterns().disabled()"
helpKey="queue-cleaner:failedImport.patterns"
/>
</div>
@@ -107,9 +102,9 @@
<app-accordion header="Downloading Metadata" subtitle="Settings for stalled metadata downloads (qBittorrent only)" [(expanded)]="metadataExpanded">
<div class="form-stack">
<app-number-input label="Max Strikes" [(value)]="metadataMaxStrikes" [min]="0" [max]="5000"
<app-number-input label="Max Strikes" [formField]="qcForm.metadataMaxStrikes"
hint="Number of strikes before action is taken (0 to disable, min 3 to enable)"
[error]="metadataMaxStrikesError()"
[error]="qcForm.metadataMaxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:downloadingMetadataMaxStrikes" />
</div>
</app-accordion>
@@ -251,49 +246,47 @@
<!-- Stall Rule Modal -->
<app-modal [title]="editingStallRule() ? 'Edit Stall Rule' : 'Add Stall Rule'" [(visible)]="stallModalVisible" size="lg">
<div class="form-grid">
<app-input label="Name" placeholder="My Stall Rule" [(value)]="stallName"
[error]="stallNameError()"
<app-input label="Name" placeholder="My Stall Rule" [formField]="stallForm.name"
[error]="stallForm.name().errors()[0]?.message"
helpKey="queue-cleaner:stallRule.name" />
<app-toggle label="Enabled" [(checked)]="stallEnabled"
<app-toggle label="Enabled" [formField]="stallForm.enabled"
hint="Enable this rule"
helpKey="queue-cleaner:stallRule.enabled" />
<app-number-input label="Max Strikes" [(value)]="stallMaxStrikes" [min]="3" [max]="5000"
<app-number-input label="Max Strikes" [formField]="stallForm.maxStrikes"
hint="Number of strikes before action is taken"
[error]="stallMaxStrikesError()"
[error]="stallForm.maxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:stallRule.maxStrikes" />
<app-select label="Privacy Type" [options]="privacyTypeOptions" [value]="stallPrivacyType()"
(valueChange)="onStallPrivacyTypeChange($event)"
<app-select label="Privacy Type" [options]="privacyTypeOptions" [formField]="stallForm.privacyType"
hint="Which torrent types this rule applies to"
helpKey="queue-cleaner:stallRule.privacyType" />
<app-number-input label="Min Completion %" [(value)]="stallMinCompletion" [min]="0" [max]="100" suffix="%"
<app-number-input label="Min Completion %" [formField]="stallForm.minCompletion" suffix="%"
hint="Apply the rule once completion percentage exceeds this value (0 includes items at 0% and above)"
helpKey="queue-cleaner:stallRule.completionRange" />
<app-number-input label="Max Completion %" [(value)]="stallMaxCompletion" [min]="1" [max]="100" suffix="%"
<app-number-input label="Max Completion %" [formField]="stallForm.maxCompletion" suffix="%"
hint="Apply the rule to items with a completion percentage less than or equal to this value"
[error]="stallCompletionError()"
[error]="stallForm.maxCompletion().errors()[0]?.message"
helpKey="queue-cleaner:stallRule.completionRange" />
<app-toggle label="Reset Strikes on Progress" [(checked)]="stallResetOnProgress"
<app-toggle label="Reset Strikes on Progress" [formField]="stallForm.resetOnProgress"
hint="Reset strike count when torrent shows progress"
helpKey="queue-cleaner:stallRule.resetStrikesOnProgress" />
@if (stallResetOnProgress()) {
<app-size-input label="Minimum Progress to Reset" [units]="sizeUnits" [(value)]="stallMinProgress"
@if (stallForm.resetOnProgress().value()) {
<app-size-input label="Minimum Progress to Reset" [units]="sizeUnits" [formField]="stallForm.minProgress"
placeholder="e.g. 1"
hint="Only reset strikes after the torrent downloads at least this amount. Leave blank to reset on any progress."
helpKey="queue-cleaner:stallRule.minimumProgress" />
}
<app-toggle label="Change category instead of delete" [(checked)]="stallChangeCategory"
<app-toggle label="Change category instead of delete" [formField]="stallForm.changeCategory"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:stallRule.changeCategory" />
@if (!stallChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="stallDeletePrivate"
[disabled]="stallPrivacyType() === 'Public'"
@if (!stallForm.changeCategory().value()) {
<app-toggle label="Delete Private from Client" [formField]="stallForm.deletePrivate" [forceDisabled]="stallForm.deletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:stallRule.deletePrivateTorrentsFromClient" />
}
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="stallModalVisible.set(false)">Cancel</app-button>
<app-button variant="primary" (clicked)="saveStallRule()">
<app-button variant="primary" [disabled]="stallForm().invalid()" (clicked)="saveStallRule()">
{{ editingStallRule() ? 'Update' : 'Create' }}
</app-button>
</div>
@@ -302,54 +295,52 @@
<!-- Slow Rule Modal -->
<app-modal [title]="editingSlowRule() ? 'Edit Slow Rule' : 'Add Slow Rule'" [(visible)]="slowModalVisible" size="lg">
<div class="form-grid">
<app-input label="Name" placeholder="My Slow Rule" [(value)]="slowName"
[error]="slowNameError()"
<app-input label="Name" placeholder="My Slow Rule" [formField]="slowForm.name"
[error]="slowForm.name().errors()[0]?.message"
helpKey="queue-cleaner:slowRule.name" />
<app-toggle label="Enabled" [(checked)]="slowEnabled"
<app-toggle label="Enabled" [formField]="slowForm.enabled"
hint="Enable this rule"
helpKey="queue-cleaner:slowRule.enabled" />
<app-number-input label="Max Strikes" [(value)]="slowMaxStrikes" [min]="3" [max]="5000"
<app-number-input label="Max Strikes" [formField]="slowForm.maxStrikes"
hint="Number of strikes before action is taken"
[error]="slowMaxStrikesError()"
[error]="slowForm.maxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:slowRule.maxStrikes" />
<app-size-input label="Min Speed" [units]="speedUnits" [(value)]="slowMinSpeed"
<app-size-input label="Min Speed" [units]="speedUnits" [formField]="slowForm.minSpeed"
placeholder="e.g. 100"
hint="Minimum speed threshold for slow downloads"
helpKey="queue-cleaner:slowRule.minSpeed" />
<app-number-input label="Maximum Time (Hours)" [(value)]="slowMaxTimeHours" [min]="0"
<app-number-input label="Maximum Time (Hours)" [formField]="slowForm.maxTimeHours"
hint="Maximum time allowed for slow downloads (0 means disabled)"
helpKey="queue-cleaner:slowRule.maxTimeHours" />
<app-select label="Privacy Type" [options]="privacyTypeOptions" [value]="slowPrivacyType()"
(valueChange)="onSlowPrivacyTypeChange($event)"
<app-select label="Privacy Type" [options]="privacyTypeOptions" [formField]="slowForm.privacyType"
hint="Which torrent types this rule applies to"
helpKey="queue-cleaner:slowRule.privacyType" />
<app-number-input label="Min Completion %" [(value)]="slowMinCompletion" [min]="0" [max]="100" suffix="%"
<app-number-input label="Min Completion %" [formField]="slowForm.minCompletion" suffix="%"
hint="Apply the rule once completion percentage exceeds this value (0 still includes exactly 0%)"
helpKey="queue-cleaner:slowRule.completionRange" />
<app-number-input label="Max Completion %" [(value)]="slowMaxCompletion" [min]="1" [max]="100" suffix="%"
<app-number-input label="Max Completion %" [formField]="slowForm.maxCompletion" suffix="%"
hint="Apply the rule up to and including this completion percentage"
[error]="slowCompletionError()"
[error]="slowForm.maxCompletion().errors()[0]?.message"
helpKey="queue-cleaner:slowRule.completionRange" />
<app-size-input label="Ignore Above Size" [units]="sizeUnitsLarge" [(value)]="slowIgnoreAboveSize"
<app-size-input label="Ignore Above Size" [units]="sizeUnitsLarge" [formField]="slowForm.ignoreAboveSize"
placeholder="e.g. 25"
hint="Downloads will be ignored if size exceeds this threshold"
helpKey="queue-cleaner:slowRule.ignoreAboveSize" />
<app-toggle label="Reset Strikes on Progress" [(checked)]="slowResetOnProgress"
<app-toggle label="Reset Strikes on Progress" [formField]="slowForm.resetOnProgress"
hint="Reset strike count when torrent shows progress"
helpKey="queue-cleaner:slowRule.resetStrikesOnProgress" />
<app-toggle label="Change category instead of delete" [(checked)]="slowChangeCategory"
<app-toggle label="Change category instead of delete" [formField]="slowForm.changeCategory"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:slowRule.changeCategory" />
@if (!slowChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="slowDeletePrivate"
[disabled]="slowPrivacyType() === 'Public'"
@if (!slowForm.changeCategory().value()) {
<app-toggle label="Delete Private from Client" [formField]="slowForm.deletePrivate" [forceDisabled]="slowForm.deletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:slowRule.deletePrivateTorrentsFromClient" />
}
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="slowModalVisible.set(false)">Cancel</app-button>
<app-button variant="primary" (clicked)="saveSlowRule()">
<app-button variant="primary" [disabled]="slowForm().invalid()" (clicked)="saveSlowRule()">
{{ editingSlowRule() ? 'Update' : 'Create' }}
</app-button>
</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
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,
@@ -36,6 +38,52 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
{ 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,
@@ -43,13 +91,13 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
SizeInputComponent, NgIcon,
SizeInputComponent, NgIcon, FormField,
],
templateUrl: './queue-cleaner.component.html',
styleUrl: './queue-cleaner.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QueueCleanerComponent implements OnInit, HasPendingChanges {
export class QueueCleanerComponent implements HasPendingChanges {
private readonly api = inject(QueueCleanerApi);
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
@@ -72,346 +120,324 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
{ 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 = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
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);
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 unit = this.scheduleUnit() as ScheduleUnit;
const values = ScheduleOptions[unit] ?? [];
const values = ScheduleOptions[this.model().scheduleUnit] ?? [];
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 failedChangeCategory = signal(false);
// UI-only expansion state
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 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
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);
readonly stallChangeCategory = signal(false);
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 = signal<SlowRule[]>([]);
readonly slowRulesLoading = signal(false);
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
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);
readonly slowChangeCategory = signal(false);
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.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]));
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 ignorePrivate = this.failedIgnorePrivate();
if (ignorePrivate) {
untracked(() => this.failedDeletePrivate.set(false));
const m = this.model();
if (m.failedChangeCategory && m.failedDeletePrivate) {
untracked(() => this.model.update(mm => ({ ...mm, failedDeletePrivate: false })));
}
});
effect(() => {
if (this.failedChangeCategory()) {
untracked(() => this.failedDeletePrivate.set(false));
const m = this.stallModel();
if ((m.changeCategory || m.privacyType === TorrentPrivacyType.Public) && m.deletePrivate) {
untracked(() => this.stallModel.update(s => ({ ...s, deletePrivate: false })));
}
});
effect(() => {
if (this.stallChangeCategory()) {
untracked(() => this.stallDeletePrivate.set(false));
const m = this.slowModel();
if ((m.changeCategory || m.privacyType === TorrentPrivacyType.Public) && m.deletePrivate) {
untracked(() => this.slowModel.update(s => ({ ...s, deletePrivate: false })));
}
});
effect(() => {
if (this.slowChangeCategory()) {
untracked(() => this.slowDeletePrivate.set(false));
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');
}
});
}
// 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 patternLabel = computed(() =>
this.model().failedPatternMode === PatternMode.Include ? 'Included Patterns' : 'Excluded Patterns'
);
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
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';
});
: '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())
));
readonly hasErrors = computed(() =>
this.qcForm().invalid() || 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.failedChangeCategory.set(config.failedImport.changeCategory ?? false);
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();
this.configResource.reload();
this.stallRulesResource.reload();
this.slowRulesResource.reload();
}
// 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);
this.stallChangeCategory.set(rule.changeCategory ?? false);
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.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.stallChangeCategory.set(false);
this.stallModel.set({ ...this.stallDefaults });
}
this.stallModalVisible.set(true);
}
saveStallRule(): void {
if (this.stallNameError() || this.stallMaxStrikesError() || this.stallCompletionError()) return;
if (this.stallForm().invalid()) return;
const changeCategory = this.stallChangeCategory();
const m = this.stallModel();
const changeCategory = m.changeCategory;
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: changeCategory ? false : this.stallDeletePrivate(),
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,
};
@@ -424,7 +450,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
next: () => {
this.toast.success(editing ? 'Stall rule updated' : 'Stall rule created');
this.stallModalVisible.set(false);
this.loadStallRules();
this.stallRulesResource.reload();
},
error: (e: Error) => this.toast.error(e.message),
});
@@ -441,7 +467,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
this.api.deleteStallRule(rule.id).subscribe({
next: () => {
this.toast.success('Stall rule deleted');
this.loadStallRules();
this.stallRulesResource.reload();
},
error: () => this.toast.error('Failed to delete stall rule'),
});
@@ -451,51 +477,43 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
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);
this.slowChangeCategory.set(rule.changeCategory ?? false);
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.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.slowChangeCategory.set(false);
this.slowModel.set({ ...this.slowDefaults });
}
this.slowModalVisible.set(true);
}
saveSlowRule(): void {
if (this.slowNameError() || this.slowMaxStrikesError() || this.slowCompletionError()) return;
if (this.slowForm().invalid()) return;
const changeCategory = this.slowChangeCategory();
const m = this.slowModel();
const changeCategory = m.changeCategory;
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: changeCategory ? false : this.slowDeletePrivate(),
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,
};
@@ -508,7 +526,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
next: () => {
this.toast.success(editing ? 'Slow rule updated' : 'Slow rule created');
this.slowModalVisible.set(false);
this.loadSlowRules();
this.slowRulesResource.reload();
},
error: (e: Error) => this.toast.error(e.message),
});
@@ -525,7 +543,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
this.api.deleteSlowRule(rule.id).subscribe({
next: () => {
this.toast.success('Slow rule deleted');
this.loadSlowRules();
this.slowRulesResource.reload();
},
error: () => this.toast.error('Failed to delete slow rule'),
});
@@ -534,28 +552,29 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
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()
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: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
enabled: m.enabled,
useAdvancedScheduling: m.useAdvancedScheduling,
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
processNoContentId: this.processNoContentId(),
ignoredDownloads: m.ignoredDownloads,
processNoContentId: m.processNoContentId,
failedImport: {
maxStrikes: this.failedMaxStrikes() ?? 3,
ignorePrivate: this.failedIgnorePrivate(),
deletePrivate: this.failedChangeCategory() ? false : this.failedDeletePrivate(),
skipIfNotFoundInClient: this.failedSkipNotFound(),
patterns: this.failedPatterns(),
patternMode: this.failedPatternMode() as PatternMode,
changeCategory: this.failedChangeCategory(),
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: this.metadataMaxStrikes() ?? 3,
downloadingMetadataMaxStrikes: m.metadataMaxStrikes ?? 3,
};
this.saving.set(true);
@@ -575,23 +594,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
}
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(),
failedChangeCategory: this.failedChangeCategory(),
metadataMaxStrikes: this.metadataMaxStrikes(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -602,14 +605,4 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
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);
}
}
@@ -19,11 +19,11 @@
<div class="settings-form">
<app-card header="Search Settings">
<div class="form-stack">
<app-toggle label="Search Enabled" [(checked)]="searchEnabled"
<app-toggle label="Search Enabled" [formField]="seekerForm.searchEnabled"
hint="Master toggle for all searching. When disabled, no replacement or proactive searches will be performed."
helpKey="seeker:searchEnabled" />
@if (searchEnabled()) {
<app-select label="Search Interval" [options]="intervalOptions" [(value)]="searchInterval"
@if (seekerForm.searchEnabled().value()) {
<app-select label="Search Interval" [options]="intervalOptions" [formField]="seekerForm.searchInterval"
hint="How often the seeker runs to process searches"
helpKey="seeker:searchInterval" />
<p class="interval-warning">Using a very low interval will trigger searches more frequently, increasing indexer load significantly. This can result in rate limit warnings or a ban from your indexer, so be careful!</p>
@@ -34,14 +34,14 @@
</div>
</app-card>
@if (searchEnabled()) {
@if (seekerForm.searchEnabled().value()) {
<app-card header="Proactive Search">
<div class="form-stack">
<app-toggle label="Proactive Search" [(checked)]="proactiveSearchEnabled"
<app-toggle label="Proactive Search" [formField]="seekerForm.proactiveSearchEnabled"
hint="When enabled, the seeker will periodically search for missing items and quality upgrades across your library"
helpKey="seeker:proactiveSearchEnabled" />
@if (proactiveSearchEnabled()) {
<app-select label="Selection Strategy" [options]="strategyOptions" [(value)]="selectionStrategy"
@if (seekerForm.proactiveSearchEnabled().value()) {
<app-select label="Selection Strategy" [options]="strategyOptions" [formField]="seekerForm.selectionStrategy"
hint="How items are selected for searching"
helpKey="seeker:selectionStrategy" />
@if (strategyDescription()) {
@@ -52,7 +52,7 @@
<app-toggle label="Enable Missing Search" [checked]="true" [disabled]="true"
hint="Always searches for items with no file. This cannot be disabled."
helpKey="seeker:missingSearchEnabled" />
<app-toggle label="Round Robin" [(checked)]="useRoundRobin"
<app-toggle label="Round Robin" [formField]="seekerForm.useRoundRobin"
[beforeChange]="confirmRoundRobin"
hint="Process one instance per run to spread indexer load"
helpKey="seeker:useRoundRobin" />
@@ -60,17 +60,14 @@
label="Post-Release Grace Period (hours)"
placeholder="6"
hint="Wait this many hours after content is released before searching. Gives indexers time to process new releases. (0 = disabled)"
[value]="postReleaseGraceHours()"
[min]="0"
[max]="72"
(valueChange)="postReleaseGraceHours.set($event ?? 6)"
[formField]="seekerForm.postReleaseGraceHours"
helpKey="seeker:postReleaseGraceHours"
/>
}
</div>
</app-card>
@if (proactiveSearchEnabled() && instances().length > 0) {
@if (seekerForm.proactiveSearchEnabled().value() && instances().length > 0) {
<app-card header="Instances">
@if (instanceError()) {
<div class="instance-error">{{ instanceError() }}</div>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, min, max, FormField } from '@angular/forms/signals';
import { DatePipe } from '@angular/common';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
@@ -52,6 +54,15 @@ const STRATEGY_DESCRIPTIONS: Record<SelectionStrategy, string> = {
[SelectionStrategy.Random]: 'Every item has an equal chance of being picked. No prioritization.',
};
interface SeekerFormModel {
searchEnabled: boolean;
searchInterval: number;
proactiveSearchEnabled: boolean;
selectionStrategy: SelectionStrategy;
useRoundRobin: boolean;
postReleaseGraceHours: number | null;
}
interface InstanceState {
arrInstanceId: string;
instanceName: string;
@@ -73,60 +84,74 @@ interface InstanceState {
imports: [
PageHeaderComponent, CardComponent, ButtonComponent,
ToggleComponent, SelectComponent, ChipInputComponent, NumberInputComponent,
EmptyStateComponent, LoadingStateComponent, BadgeComponent, DatePipe,
EmptyStateComponent, LoadingStateComponent, BadgeComponent, DatePipe, FormField,
],
templateUrl: './seeker.component.html',
styleUrl: './seeker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SeekerComponent implements OnInit, HasPendingChanges {
export class SeekerComponent implements HasPendingChanges {
private readonly api = inject(SeekerApi);
private readonly toast = inject(ToastService);
private readonly confirm = inject(ConfirmService);
private readonly savedSnapshot = signal('');
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
readonly intervalOptions = INTERVAL_OPTIONS;
readonly strategyOptions = STRATEGY_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
readonly searchEnabled = signal(true);
readonly searchInterval = signal<unknown>(2);
readonly proactiveSearchEnabled = signal(false);
readonly selectionStrategy = signal<unknown>(SelectionStrategy.BalancedWeighted);
readonly useRoundRobin = signal(true);
readonly postReleaseGraceHours = signal<number>(6);
// Scalar settings live in a Signal Forms model; the per-instance array stays a
// signal (one-way bound rows with imperative updates) and is not a form field.
private readonly model = signal<SeekerFormModel>({
searchEnabled: true,
searchInterval: 2,
proactiveSearchEnabled: false,
selectionStrategy: SelectionStrategy.BalancedWeighted,
useRoundRobin: true,
postReleaseGraceHours: 6,
});
readonly seekerForm = form(this.model, (p) => {
min(p.postReleaseGraceHours, 0);
max(p.postReleaseGraceHours, 72);
});
readonly instances = signal<InstanceState[]>([]);
readonly strategyDescription = computed(() => STRATEGY_DESCRIPTIONS[this.selectionStrategy() as SelectionStrategy] ?? '');
readonly strategyDescription = computed(() => STRATEGY_DESCRIPTIONS[this.model().selectionStrategy] ?? '');
readonly instanceError = computed(() => {
if (this.proactiveSearchEnabled() && this.instances().length > 0 && !this.instances().some(i => i.enabled)) {
if (this.model().proactiveSearchEnabled && this.instances().length > 0 && !this.instances().some(i => i.enabled)) {
return 'At least one instance must be enabled when proactive search is enabled';
}
return undefined;
});
readonly hasErrors = computed(() => !!this.instanceError());
readonly hasErrors = computed(() => this.seekerForm().invalid() || !!this.instanceError());
ngOnInit(): void {
this.loadConfig();
}
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
this.searchEnabled.set(config.searchEnabled);
this.searchInterval.set(config.searchInterval);
this.proactiveSearchEnabled.set(config.proactiveSearchEnabled);
this.selectionStrategy.set(config.selectionStrategy);
this.useRoundRobin.set(config.useRoundRobin);
this.postReleaseGraceHours.set(config.postReleaseGraceHours);
constructor() {
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.model.set({
searchEnabled: config.searchEnabled,
searchInterval: config.searchInterval,
proactiveSearchEnabled: config.proactiveSearchEnabled,
selectionStrategy: config.selectionStrategy,
useRoundRobin: config.useRoundRobin,
postReleaseGraceHours: config.postReleaseGraceHours,
});
this.instances.set(config.instances.map(i => ({
arrInstanceId: i.arrInstanceId,
instanceName: i.instanceName,
@@ -141,20 +166,27 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
useCutoff: i.useCutoff,
useCustomFormatScore: i.useCustomFormatScore,
})));
this.loader.stop();
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load seeker settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
readonly confirmRoundRobin = async (newValue: boolean): Promise<boolean> => {
@@ -236,13 +268,14 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
}
save(): void {
const m = this.model();
const config: UpdateSeekerConfig = {
searchEnabled: this.searchEnabled(),
searchInterval: (this.searchInterval() as number) ?? 2,
proactiveSearchEnabled: this.proactiveSearchEnabled(),
selectionStrategy: this.selectionStrategy() as SelectionStrategy,
useRoundRobin: this.useRoundRobin(),
postReleaseGraceHours: this.postReleaseGraceHours(),
searchEnabled: m.searchEnabled,
searchInterval: m.searchInterval ?? 2,
proactiveSearchEnabled: m.proactiveSearchEnabled,
selectionStrategy: m.selectionStrategy,
useRoundRobin: m.useRoundRobin,
postReleaseGraceHours: m.postReleaseGraceHours ?? 6,
instances: this.instances().map(i => ({
arrInstanceId: i.arrInstanceId,
enabled: i.enabled,
@@ -275,12 +308,7 @@ export class SeekerComponent implements OnInit, HasPendingChanges {
private buildSnapshot(): string {
return JSON.stringify({
searchEnabled: this.searchEnabled(),
searchInterval: this.searchInterval(),
proactiveSearchEnabled: this.proactiveSearchEnabled(),
selectionStrategy: this.selectionStrategy(),
useRoundRobin: this.useRoundRobin(),
postReleaseGraceHours: this.postReleaseGraceHours(),
...this.model(),
instances: [...this.instances()].sort((a, b) => a.arrInstanceId.localeCompare(b.arrInstanceId)),
});
}
@@ -5,7 +5,7 @@
<div class="page-content">
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-select
placeholder="All Types"
@@ -42,7 +42,11 @@
>
<div
class="strike-row__main"
role="button"
tabindex="0"
(click)="toggleExpand(item.downloadItemId)"
(keydown.enter)="toggleExpand(item.downloadItemId)"
(keydown.space)="toggleExpand(item.downloadItemId)"
>
<ng-icon name="tablerBolt" class="strike-row__icon" />
<span class="strike-row__title">{{ item.title }}</span>
@@ -71,6 +75,8 @@
<button
class="strike-row__delete"
(click)="deleteItemStrikes(item); $event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
title="Delete all strikes for this item"
>
<ng-icon name="tablerTrash" />
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
@@ -13,6 +14,7 @@ import { ConfirmService } from '@core/services/confirm.service';
import { PaginationService } from '@core/services/pagination.service';
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models';
import { PaginatedResult } from '@core/models/pagination.model';
@Component({
selector: 'app-strikes',
@@ -44,9 +46,6 @@ export class StrikesComponent implements OnInit, OnDestroy {
private readonly pagination = inject(PaginationService);
private pollTimer: ReturnType<typeof setInterval> | null = null;
readonly items = signal<DownloadItemStrikes[]>([]);
readonly totalRecords = signal(0);
readonly loading = signal(false);
readonly expandedId = signal<string | null>(null);
readonly currentPage = signal(1);
@@ -54,12 +53,50 @@ export class StrikesComponent implements OnInit, OnDestroy {
readonly selectedType = signal<unknown>('');
readonly searchQuery = signal('');
readonly typeOptions = signal<SelectOption[]>([{ label: 'All Types', value: '' }]);
private readonly strikeFilter = computed<StrikeFilter>(() => {
const filter: StrikeFilter = {
page: this.currentPage(),
pageSize: this.pageSize(),
};
const type = this.selectedType() as string;
const search = this.searchQuery();
if (type) {
filter.type = type;
}
if (search) {
filter.search = search;
}
return filter;
});
private readonly strikesResource = rxResource({
params: () => this.strikeFilter(),
stream: ({ params }) => this.strikesApi.getStrikes(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as PaginatedResult<DownloadItemStrikes>,
});
private readonly strikeTypesResource = rxResource({
stream: () => this.strikesApi.getStrikeTypes(),
defaultValue: [] as string[],
});
readonly items = computed(() => this.strikesResource.value().items);
readonly totalRecords = computed(() => this.strikesResource.value().totalCount);
readonly typeOptions = computed<SelectOption[]>(() => [
{ label: 'All Types', value: '' },
...this.strikeTypesResource.value().map((t) => ({ label: this.formatStrikeType(t), value: t })),
]);
constructor() {
effect(() => {
if (this.strikesResource.error()) {
this.toast.error('Failed to load strikes');
}
});
}
ngOnInit(): void {
this.loadStrikeTypes();
this.loadStrikes();
this.pollTimer = setInterval(() => this.loadStrikes(), 10_000);
this.pollTimer = setInterval(() => this.strikesResource.reload(), 10_000);
}
ngOnDestroy(): void {
@@ -68,57 +105,18 @@ export class StrikesComponent implements OnInit, OnDestroy {
}
}
loadStrikes(): void {
const filter: StrikeFilter = {
page: this.currentPage(),
pageSize: this.pageSize(),
};
const type = this.selectedType() as string;
const search = this.searchQuery();
if (type) filter.type = type;
if (search) filter.search = search;
this.loading.set(true);
this.strikesApi.getStrikes(filter).subscribe({
next: (result) => {
this.items.set(result.items);
this.totalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
this.toast.error('Failed to load strikes');
},
});
}
private loadStrikeTypes(): void {
this.strikesApi.getStrikeTypes().subscribe({
next: (types) => {
this.typeOptions.set([
{ label: 'All Types', value: '' },
...types.map((t) => ({ label: this.formatStrikeType(t), value: t })),
]);
},
});
}
onFilterChange(): void {
this.currentPage.set(1);
this.loadStrikes();
}
onPageChange(page: number): void {
this.currentPage.set(page);
this.loadStrikes();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
StrikesComponent.PAGE_SIZE_KEY,
this.pageSize,
this.currentPage,
() => this.loadStrikes(),
);
toggleExpand(itemId: string): void {
@@ -138,14 +136,14 @@ export class StrikesComponent implements OnInit, OnDestroy {
this.strikesApi.deleteStrikesForItem(item.downloadItemId).subscribe({
next: () => {
this.toast.success(`Strikes deleted for "${item.title}"`);
this.loadStrikes();
this.strikesResource.reload();
},
error: () => this.toast.error('Failed to delete strikes'),
});
}
refresh(): void {
this.loadStrikes();
this.strikesResource.reload();
}
// Helpers
@@ -13,12 +13,6 @@ interface NavItem {
route: string;
}
interface ExternalLink {
label: string;
icon: string;
href: string;
}
@Component({
selector: 'app-nav-sidebar',
standalone: true,
@@ -5,6 +5,8 @@
>
<!-- Mobile backdrop -->
@if (isMobile() && mobileMenuOpen()) {
<!-- Backdrop dismissal is pointer-only sugar; keyboard users dismiss via the Escape HostListener. -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->
<div class="shell__backdrop" (click)="closeMobileMenu()"></div>
}
@@ -15,6 +15,7 @@ import { NavSidebarComponent } from '../nav-sidebar/nav-sidebar.component';
import { ToolbarComponent } from '../toolbar/toolbar.component';
import { AppHubService } from '@core/realtime/app-hub.service';
import { FeatureBadgeService } from '@core/feature-badges/feature-badge.service';
import { registerOverlayEffect } from '@core/services/overlay-stack.service';
@Component({
selector: 'app-shell',
@@ -37,6 +38,11 @@ export class ShellComponent implements OnInit, OnDestroy {
private readonly MOBILE_BREAKPOINT = 768;
private readonly TABLET_BREAKPOINT = 1024;
private autoCollapsed = signal(false);
private isTopmostOverlay!: () => boolean;
constructor() {
this.isTopmostOverlay = registerOverlayEffect(this.mobileMenuOpen);
}
ngOnInit(): void {
this.checkMobile();
@@ -61,6 +67,13 @@ export class ShellComponent implements OnInit, OnDestroy {
this.checkMobile();
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.mobileMenuOpen() && this.isTopmostOverlay()) {
this.closeMobileMenu();
}
}
toggleSidebar(): void {
if (this.isMobile()) {
this.mobileMenuOpen.set(!this.mobileMenuOpen());
@@ -1,5 +1,5 @@
@if (label()) {
<label class="chip-label">
<label class="chip-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
@@ -11,20 +11,21 @@
}
</label>
}
<div class="chip-wrapper" [class.chip-wrapper--disabled]="disabled()" [class.chip-wrapper--error]="error() || uncommittedError()">
<div class="chip-wrapper" [class.chip-wrapper--disabled]="effectiveDisabled()" [class.chip-wrapper--error]="error() || uncommittedError()">
@for (item of items(); track item; let i = $index) {
<span class="chip">
{{ item }}
@if (!disabled()) {
@if (!effectiveDisabled()) {
<button class="chip__remove" type="button" (click)="removeItem(i)" aria-label="Remove">&times;</button>
}
</span>
}
<input
[id]="controlId"
class="chip-input"
enterkeyhint="enter"
[placeholder]="items().length === 0 ? placeholder() : ''"
[disabled]="disabled()"
[disabled]="effectiveDisabled()"
[(ngModel)]="inputValue"
(keydown)="onKeydown($event)"
(blur)="onBlur()"
@@ -1,8 +1,14 @@
import { Component, ChangeDetectionStrategy, input, model, signal, computed, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy, input, model, signal, computed, effect, untracked, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
import { effectiveDisabled as computeEffectiveDisabled } from '@ui/effective-disabled';
function sameItems(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
@Component({
selector: 'app-chip-input',
@@ -15,20 +21,49 @@ import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
export class ChipInputComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-chip');
label = input<string>();
featureId = input<string>();
placeholder = input('Type and press Enter...');
disabled = input(false);
forceDisabled = input(false);
error = input<string>();
hint = input<string>();
helpKey = input<string>();
items = model<string[]>([]);
// `value` mirrors `items` so the control also satisfies Signal Forms' FormValueControl
// contract and can be bound via [formField]. Legacy [(items)] call sites keep working.
value = model<string[]>([]);
readonly inputValue = signal('');
readonly touched = signal(false);
readonly effectiveDisabled = computeEffectiveDisabled(this.disabled, this.forceDisabled);
constructor() {
// `value` ([formField]) and `items` ([(items)] call sites) are both two-way models mirroring the same list. Guard each write on shallow-equality so the
// pair can never ping-pong into an infinite loop, even if a future edit copies or maps the array instead of passing the same reference through.
effect(() => {
const v = this.value();
untracked(() => {
if (!sameItems(this.items(), v)) {
this.items.set(v);
}
});
});
effect(() => {
const items = this.items();
untracked(() => {
if (!sameItems(this.value(), items)) {
this.value.set(items);
}
});
});
}
readonly hasUncommittedInput = computed(() => {
return this.inputValue().trim().length > 0 && !this.disabled();
return this.inputValue().trim().length > 0 && !this.effectiveDisabled();
});
readonly uncommittedError = computed(() => {
@@ -1,8 +1,9 @@
@if (confirm.state(); as state) {
<div class="confirm-backdrop" (click)="confirm.cancel()">
<!-- Backdrop dismissal is pointer-only sugar; keyboard users dismiss via the Escape HostListener. -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->
<div class="confirm-backdrop" (click)="onBackdropClick($event)">
<div
class="confirm"
(click)="$event.stopPropagation()"
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
@@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, HostListener } from '@angular/core';
import { ConfirmService } from '@core/services/confirm.service';
import { registerOverlayEffect } from '@core/services/overlay-stack.service';
import { ButtonComponent } from '../button/button.component';
@Component({
@@ -13,9 +14,17 @@ import { ButtonComponent } from '../button/button.component';
export class ConfirmDialogComponent {
readonly confirm = inject(ConfirmService);
private readonly isTopmostOverlay = registerOverlayEffect(this.confirm.state);
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.confirm.state()) {
if (this.confirm.state() && this.isTopmostOverlay()) {
this.confirm.cancel();
}
}
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.confirm.cancel();
}
}
+6
View File
@@ -0,0 +1,6 @@
let counter = 0;
/** Unique, stable DOM id for a form control, e.g. `generateControlId('app-input')`. */
export function generateControlId(prefix: string): string {
return `${prefix}-${++counter}`;
}
@@ -1,8 +1,9 @@
@if (visible()) {
<div class="drawer-backdrop" (click)="onBackdropClick()">
<!-- Backdrop dismissal is pointer-only sugar; keyboard users dismiss via the Escape HostListener. -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->
<div class="drawer-backdrop" (click)="onBackdropClick($event)">
<aside
class="drawer"
(click)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="title() ? titleId : null"
@@ -1,5 +1,7 @@
import { Component, ChangeDetectionStrategy, input, output, model, HostListener, effect, ElementRef, inject, OnInit, OnDestroy } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';
import { generateControlId } from '@ui/control-id';
import { registerOverlayEffect } from '@core/services/overlay-stack.service';
@Component({
selector: 'app-drawer',
@@ -10,12 +12,10 @@ import { A11yModule } from '@angular/cdk/a11y';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DrawerComponent implements OnInit, OnDestroy {
private static nextId = 0;
private readonly host: ElementRef<HTMLElement> = inject(ElementRef);
private previousFocus: HTMLElement | null = null;
readonly titleId = `drawer-title-${++DrawerComponent.nextId}`;
readonly titleId = generateControlId('drawer-title');
title = input<string>();
visible = model(false);
@@ -23,6 +23,8 @@ export class DrawerComponent implements OnInit, OnDestroy {
closed = output<void>();
private readonly isTopmostOverlay = registerOverlayEffect(this.visible);
constructor() {
effect(() => {
if (this.visible()) {
@@ -45,7 +47,7 @@ export class DrawerComponent implements OnInit, OnDestroy {
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.visible()) {
if (this.visible() && this.isTopmostOverlay()) {
this.close();
}
}
@@ -56,8 +58,8 @@ export class DrawerComponent implements OnInit, OnDestroy {
this.closed.emit();
}
onBackdropClick(): void {
if (this.closeOnBackdrop()) {
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget && this.closeOnBackdrop()) {
this.close();
}
}
@@ -0,0 +1,6 @@
import { Signal, computed } from '@angular/core';
/** Combines a control's own `disabled` with an external `forceDisabled` override. */
export function effectiveDisabled(disabled: Signal<boolean>, forceDisabled: Signal<boolean>): Signal<boolean> {
return computed(() => disabled() || forceDisabled());
}
@@ -1,5 +1,5 @@
@if (label()) {
<label class="input-label">
<label class="input-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button type="button" class="field-help-btn" (click)="onHelpClick($event)" [attr.aria-label]="'Open documentation for ' + label()">
@@ -14,6 +14,7 @@
<div class="input-wrapper">
<input
#inputEl
[id]="controlId"
class="input-field"
[class.input-field--error]="error()"
[class.input-field--has-eye]="hasEye()"
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
@Component({
selector: 'app-input',
@@ -15,6 +16,8 @@ import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
export class InputComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-input');
label = input<string>();
featureId = input<string>();
placeholder = input('');
@@ -1,9 +1,10 @@
@if (visible()) {
<div class="modal-backdrop" (click)="onBackdropClick()">
<!-- Backdrop dismissal is pointer-only sugar; keyboard users dismiss via the Escape HostListener. -->
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->
<div class="modal-backdrop" (click)="onBackdropClick($event)">
<div
class="modal"
[class]="'modal modal--' + size()"
(click)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
@@ -1,4 +1,5 @@
import { Component, ChangeDetectionStrategy, input, output, model, HostListener } from '@angular/core';
import { registerOverlayEffect } from '@core/services/overlay-stack.service';
@Component({
selector: 'app-modal',
@@ -15,9 +16,13 @@ export class ModalComponent {
closed = output<void>();
private readonly isTopmostOverlay = registerOverlayEffect(this.visible);
@HostListener('document:keydown.escape')
onEscapeKey(): void {
this.close();
if (this.visible() && this.isTopmostOverlay()) {
this.close();
}
}
close(): void {
@@ -25,8 +30,8 @@ export class ModalComponent {
this.closed.emit();
}
onBackdropClick(): void {
if (this.closeOnBackdrop()) {
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget && this.closeOnBackdrop()) {
this.close();
}
}
@@ -1,5 +1,5 @@
@if (label()) {
<label class="number-label">
<label class="number-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
@@ -20,6 +20,7 @@
aria-label="Decrease"
>−</button>
<input
[id]="controlId"
class="number-field"
[class.number-field--error]="error()"
type="number"
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
@Component({
selector: 'app-number-input',
@@ -15,6 +16,8 @@ import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
export class NumberInputComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-number');
label = input<string>();
featureId = input<string>();
placeholder = input('');
@@ -1,5 +1,5 @@
@if (label()) {
<label class="select-label">
<label class="select-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
@@ -13,14 +13,16 @@
}
<div class="select-wrapper">
<button
[id]="controlId"
class="select-trigger"
[class.select-trigger--open]="isOpen()"
[class.select-trigger--error]="error()"
[class.select-trigger--has-value]="value() != null"
[disabled]="disabled()"
[class.select-trigger--has-value]="hasValue()"
[disabled]="effectiveDisabled()"
type="button"
role="combobox"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="listboxId"
(click)="toggleDropdown()"
(keydown)="onKeydown($event)"
>
@@ -32,6 +34,7 @@
@if (isOpen()) {
<div
[id]="listboxId"
class="select-dropdown"
[class.select-dropdown--top]="placement() === 'top'"
role="listbox"
@@ -1,8 +1,10 @@
import { Component, ChangeDetectionStrategy, input, model, signal, ElementRef, inject, HostListener } from '@angular/core';
import { Component, ChangeDetectionStrategy, input, model, signal, computed, ElementRef, inject, HostListener } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
import { effectiveDisabled as computeEffectiveDisabled } from '@ui/effective-disabled';
export interface SelectOption {
label: string;
@@ -21,17 +23,25 @@ export interface SelectOption {
export class SelectComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-select');
protected readonly listboxId = `${this.controlId}-listbox`;
label = input<string>();
featureId = input<string>();
placeholder = input('Select...');
options = input<SelectOption[]>([]);
disabled = input(false);
forceDisabled = input(false);
error = input<string>();
hint = input<string>();
helpKey = input<string>();
placement = input<'bottom' | 'top'>('bottom');
value = model<unknown>(null);
readonly effectiveDisabled = computeEffectiveDisabled(this.disabled, this.forceDisabled);
readonly hasValue = computed(() => this.value() != null);
readonly isOpen = signal(false);
private readonly el = inject(ElementRef);
@@ -50,7 +60,7 @@ export class SelectComponent {
}
toggleDropdown(): void {
if (!this.disabled()) {
if (!this.effectiveDisabled()) {
if (this.isOpen()) {
this.close();
} else {
@@ -1,5 +1,5 @@
@if (label()) {
<label class="size-label">
<label class="size-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
@@ -13,12 +13,13 @@
}
<div class="size-wrapper">
<input
[id]="controlId"
class="size-field"
[class.size-field--error]="error()"
type="number"
[placeholder]="placeholder()"
[disabled]="disabled()"
[min]="min()"
[min]="minValue()"
[value]="numericValue()"
(input)="onNumericInput($event)"
/>
@@ -2,6 +2,7 @@ import { Component, ChangeDetectionStrategy, input, model, signal, effect, untra
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
export interface SizeUnit {
label: string;
@@ -19,12 +20,14 @@ export interface SizeUnit {
export class SizeInputComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-size');
label = input<string>();
featureId = input<string>();
units = input.required<SizeUnit[]>();
placeholder = input('');
disabled = input(false);
min = input<number>(0);
minValue = input<number>(0);
error = input<string>();
hint = input<string>();
helpKey = input<string>();
@@ -1,5 +1,5 @@
@if (label()) {
<label class="textarea-label">
<label class="textarea-label" [attr.for]="controlId">
{{ label() }}
@if (helpKey()) {
<button class="field-help-btn" (click)="onHelpClick($event)" aria-label="Open documentation" tabindex="-1">
@@ -12,6 +12,7 @@
</label>
}
<textarea
[id]="controlId"
class="textarea-field"
[class.textarea-field--error]="error()"
[placeholder]="placeholder()"
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
@Component({
selector: 'app-textarea',
@@ -15,6 +16,8 @@ import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
export class TextareaComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-textarea');
label = input<string>();
featureId = input<string>();
placeholder = input('');
@@ -1,11 +1,12 @@
<label class="toggle" [class.toggle--disabled]="disabled()">
<label class="toggle" [class.toggle--disabled]="effectiveDisabled()" [attr.for]="controlId">
<button
[id]="controlId"
class="toggle__track"
[class.toggle__track--checked]="checked()"
role="switch"
[attr.aria-checked]="checked()"
[attr.aria-label]="label()"
[disabled]="disabled()"
[disabled]="effectiveDisabled()"
(click)="toggle()"
(keydown)="onKeydown($event)"
>
@@ -2,6 +2,8 @@ import { Component, ChangeDetectionStrategy, input, model, inject } from '@angul
import { NgIcon } from '@ng-icons/core';
import { DocumentationService } from '@core/services/documentation.service';
import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
import { generateControlId } from '@ui/control-id';
import { effectiveDisabled as computeEffectiveDisabled } from '@ui/effective-disabled';
@Component({
selector: 'app-toggle',
@@ -14,18 +16,23 @@ import { NewBadgeComponent } from '@ui/new-badge/new-badge.component';
export class ToggleComponent {
private readonly docs = inject(DocumentationService);
protected readonly controlId = generateControlId('app-toggle');
label = input<string>();
featureId = input<string>();
disabled = input(false);
forceDisabled = input(false);
hint = input<string>();
helpKey = input<string>();
beforeChange = input<(newValue: boolean) => Promise<boolean> | boolean>();
checked = model(false);
readonly effectiveDisabled = computeEffectiveDisabled(this.disabled, this.forceDisabled);
private pending = false;
async toggle(): Promise<void> {
if (this.disabled() || this.pending) return;
if (this.effectiveDisabled() || this.pending) return;
const newValue = !this.checked();
const guard = this.beforeChange();
+2 -6
View File
@@ -6,10 +6,6 @@
"outDir": "./out-tsc/app",
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+8 -9
View File
@@ -14,16 +14,15 @@
"importHelpers": true,
"target": "ES2022",
"module": "preserve",
"baseUrl": ".",
"paths": {
"@app/*": ["src/app/*"],
"@ui": ["src/app/ui/index"],
"@ui/*": ["src/app/ui/*"],
"@core/*": ["src/app/core/*"],
"@shared/*": ["src/app/shared/*"],
"@features/*": ["src/app/features/*"],
"@layout/*": ["src/app/layout/*"],
"@styles/*": ["src/styles/*"]
"@app/*": ["./src/app/*"],
"@ui": ["./src/app/ui/index"],
"@ui/*": ["./src/app/ui/*"],
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"],
"@layout/*": ["./src/app/layout/*"],
"@styles/*": ["./src/styles/*"]
}
},
"angularCompilerOptions": {
+1 -1
View File
@@ -13,7 +13,7 @@ E2E tests use a real Keycloak instance and Playwright browser automation to vali
## Prerequisites
- Docker + Docker Compose
- Node.js 18+
- Node.js 26+
- GitHub Packages credentials (for building the app image)
## Running Locally
+1 -1
View File
@@ -68,6 +68,6 @@
]
},
"engines": {
"node": ">=18.0"
"node": ">=20"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { loginAndGotoSettings } from './helpers/ui';
// Behavior-parity spec for the Arr (Sonarr) instance create/edit modal.
test.describe('Arr Settings UI', () => {
test('required fields gate the modal Save button', async ({ page }) => {
await loginAndGotoSettings(page, 'arr/sonarr');
await page.getByRole('button', { name: 'Add Instance' }).click();
const modal = page.getByRole('dialog', { name: 'Add Instance' });
await expect(modal).toBeVisible();
const save = modal.getByRole('button', { name: 'Save' });
await expect(save).toBeDisabled();
await modal.locator('app-input').first().locator('input').fill('E2E Sonarr'); // Name
await modal.locator('app-input').filter({ hasText: 'URL' }).first().locator('input').fill('http://localhost:9999');
await expect(save).toBeDisabled();
await modal.locator('app-input').filter({ hasText: 'API Key' }).locator('input').fill('0123456789abcdef0123456789abcdef');
await expect(save).toBeEnabled();
});
test('create then delete an instance round-trip', async ({ page }) => {
await loginAndGotoSettings(page, 'arr/sonarr');
await page.getByRole('button', { name: 'Add Instance' }).click();
const modal = page.getByRole('dialog', { name: 'Add Instance' });
await modal.locator('app-input').first().locator('input').fill('E2E Sonarr RT');
await modal.locator('app-input').filter({ hasText: 'URL' }).first().locator('input').fill('http://localhost:9999');
await modal.locator('app-input').filter({ hasText: 'API Key' }).locator('input').fill('0123456789abcdef0123456789abcdef');
await modal.getByRole('button', { name: 'Save' }).click();
const row = page.locator('.instance-row').filter({ hasText: 'E2E Sonarr RT' });
await expect(row).toBeVisible({ timeout: 10_000 });
await row.getByRole('button', { name: 'Delete' }).click();
const dialog = page.getByRole('alertdialog', { name: 'Delete Instance' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect(row).toHaveCount(0, { timeout: 10_000 });
});
});
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from '@playwright/test';
import {
loginAndGotoSettings,
toggle,
textInput,
expectGuardOnLeave,
expectNoGuardOnLeave,
} from './helpers/ui';
// Behavior-parity spec for the Blacklist Sync settings form (Signal Forms migration).
// Authored against pre-migration (main) behavior; must stay green after the migration.
test.describe('Blacklist Sync UI', () => {
const pathField = (page: import('@playwright/test').Page) =>
page.locator('app-input').filter({ hasText: 'Blacklist File Path' });
test('path field is gated by Enabled and required when enabled', async ({ page }) => {
await loginAndGotoSettings(page, 'blacklist-sync');
// Hidden while disabled.
await expect(pathField(page)).toHaveCount(0);
// Enabling reveals the path field.
await toggle(page, 'Enabled').click();
await expect(pathField(page)).toBeVisible();
// Empty path -> required error + Save disabled.
const save = page.getByRole('button', { name: /Save Settings|Saved!/ });
await expect(page.getByText(/required when blacklist sync is enabled/i)).toBeVisible();
await expect(save).toBeDisabled();
// Filling the path clears the error and enables Save.
await textInput(page, 'Blacklist File Path').fill('/config/blacklist.txt');
await expect(page.getByText(/required when blacklist sync is enabled/i)).toHaveCount(0);
await expect(save).toBeEnabled();
});
test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => {
await loginAndGotoSettings(page, 'blacklist-sync');
// Toggling Enabled makes the form dirty -> guard on leave.
await toggle(page, 'Enabled').click();
await expectGuardOnLeave(page);
// Reverting the toggle restores the saved snapshot -> no guard on leave.
await toggle(page, 'Enabled').click();
await expectNoGuardOnLeave(page);
});
});
+124
View File
@@ -0,0 +1,124 @@
import { test, expect, type Page } from '@playwright/test';
import {
loginAndGetToken,
createDownloadClient,
deleteDownloadClient,
listDownloadClients,
} from './helpers/app-api';
import {
loginAndGotoSettings,
toggle,
select,
selectOption,
ensureToggle,
ensureAccordionExpanded,
} from './helpers/ui';
const CLIENT_A = 'E2E DC One';
const CLIENT_B = 'E2E DC Two';
function clientPayload(name: string) {
return {
enabled: true,
name,
type: 'Torrent',
typeName: 'qBittorrent',
host: 'http://localhost:8090',
username: 'admin',
password: 'adminadmin',
urlBase: '',
downloadDirectorySource: null,
downloadDirectoryTarget: null,
};
}
// Behavior-parity spec for the Download Cleaner form: global config, seeding-rule modal,
// and the per-client sub-configs (the linkedSignal-backed part with the highest migration risk).
test.describe.serial('Download Cleaner UI', () => {
const createdIds: string[] = [];
test.beforeAll(async () => {
const token = await loginAndGetToken();
await createDownloadClient(token, clientPayload(CLIENT_A));
await createDownloadClient(token, clientPayload(CLIENT_B));
const clients = await listDownloadClients(token);
for (const c of clients) {
if (c.name === CLIENT_A || c.name === CLIENT_B) createdIds.push(c.id);
}
});
test.afterAll(async () => {
const token = await loginAndGetToken();
for (const id of createdIds) {
await deleteDownloadClient(token, id).catch(() => undefined);
}
});
async function enableAndSelect(page: Page, clientName = CLIENT_A) {
await loginAndGotoSettings(page, 'download-cleaner');
await ensureToggle(toggle(page, 'Enabled'), true);
await expect(select(page, 'Download Client')).toBeVisible();
await selectOption(page, 'Download Client', clientName);
}
test('global Enabled gates the schedule and the per-client card', async ({ page }) => {
await loginAndGotoSettings(page, 'download-cleaner');
const enabled = toggle(page, 'Enabled');
await ensureToggle(enabled, false);
await expect(toggle(page, 'Advanced Scheduling')).toHaveCount(0);
await expect(select(page, 'Download Client')).toHaveCount(0);
await ensureToggle(enabled, true);
await expect(toggle(page, 'Advanced Scheduling')).toBeVisible();
await expect(select(page, 'Download Client')).toBeVisible();
});
test('seeding-rule modal requires a name and at least one category', async ({ page }) => {
await enableAndSelect(page);
await ensureAccordionExpanded(page, 'Seeding Rules', page.getByRole('button', { name: 'Add Seeding Rule' }));
await page.getByRole('button', { name: 'Add Seeding Rule' }).click();
const modal = page.getByRole('dialog', { name: 'Add Seeding Rule' });
await expect(modal).toBeVisible();
const nameCard = modal.locator('app-input').filter({ hasText: 'Rule Name' });
await expect(nameCard.locator('.input-error')).toBeVisible();
const catCard = modal.locator('app-chip-input').filter({ hasText: 'Categories' });
await expect(catCard.locator('.chip-error')).toBeVisible();
await nameCard.locator('input').fill('My Rule');
await expect(nameCard.locator('.input-error')).toHaveCount(0);
});
test('per-client Dead Torrents requires strikes >= 3', async ({ page }) => {
await enableAndSelect(page);
const accordion = page.locator('app-accordion').filter({ hasText: 'Dead Torrents' });
await ensureAccordionExpanded(page, 'Dead Torrents', accordion.getByRole('switch', { name: 'Enabled' }));
await accordion.getByRole('switch', { name: 'Enabled' }).click();
const strikes = accordion.locator('app-number-input').filter({ hasText: 'Strikes' });
await strikes.locator('input').fill('1');
await expect(strikes.locator('.number-error')).toBeVisible();
await strikes.locator('input').fill('5');
await expect(strikes.locator('.number-error')).toHaveCount(0);
});
test('switching client with unsaved per-client edits prompts to discard', async ({ page }) => {
await enableAndSelect(page, CLIENT_A);
// Make a per-client edit (enable Dead Torrents) so the client's sub-config is dirty.
const accordion = page.locator('app-accordion').filter({ hasText: 'Dead Torrents' });
await ensureAccordionExpanded(page, 'Dead Torrents', accordion.getByRole('switch', { name: 'Enabled' }));
await accordion.getByRole('switch', { name: 'Enabled' }).click();
// Switching clients must prompt to discard the unsaved edits.
await selectOption(page, 'Download Client', CLIENT_B);
const dialog = page.getByRole('alertdialog', { name: 'Unsaved Changes' });
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toBeHidden();
});
});
+46
View File
@@ -0,0 +1,46 @@
import { test, expect } from '@playwright/test';
import { loginAndGotoSettings, selectOption } from './helpers/ui';
// Behavior-parity spec for the Download Clients create/edit modal (client-type driven fields).
test.describe('Download Clients UI', () => {
test('client type switching shows/hides fields and auto-fills URL base', async ({ page }) => {
await loginAndGotoSettings(page, 'download-clients');
await page.getByRole('button', { name: 'Add Client' }).click();
const modal = page.getByRole('dialog', { name: 'Add Client' });
await expect(modal).toBeVisible();
const username = modal.locator('app-input').filter({ hasText: 'Username' });
const urlBase = modal.locator('app-input').filter({ hasText: 'URL Base' }).locator('input');
// qBittorrent (default) shows Username.
await expect(username).toBeVisible();
// Deluge hides Username.
await selectOption(modal, 'Client Type', 'Deluge');
await expect(username).toHaveCount(0);
// Transmission auto-fills the URL base.
await selectOption(modal, 'Client Type', 'Transmission');
await expect(urlBase).toHaveValue('transmission');
await expect(username).toBeVisible();
// rTorrent auto-fills a different URL base.
await selectOption(modal, 'Client Type', 'rTorrent');
await expect(urlBase).toHaveValue('plugins/httprpc/action.php');
});
test('name and host required gate the modal Save button', async ({ page }) => {
await loginAndGotoSettings(page, 'download-clients');
await page.getByRole('button', { name: 'Add Client' }).click();
const modal = page.getByRole('dialog', { name: 'Add Client' });
const save = modal.getByRole('button', { name: 'Save' });
await expect(save).toBeDisabled();
await modal.locator('app-input').first().locator('input').fill('E2E Client'); // Name
await expect(save).toBeDisabled();
await modal.locator('app-input').filter({ hasText: 'Host' }).locator('input').fill('http://localhost:8090');
await expect(save).toBeEnabled();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test';
import {
loginAndGotoSettings,
toggle,
numberInput,
expectGuardOnLeave,
expectNoGuardOnLeave,
} from './helpers/ui';
// Behavior-parity spec for the General settings form (Signal Forms migration).
test.describe('General Settings UI', () => {
test('local-auth-bypass toggle reveals and hides nested fields', async ({ page }) => {
await loginAndGotoSettings(page, 'general');
// Nested fields hidden until the master toggle is on.
await expect(toggle(page, 'Trust Forwarded Headers')).toHaveCount(0);
await expect(page.locator('app-chip-input').filter({ hasText: 'Additional Trusted Networks' })).toHaveCount(0);
await toggle(page, 'Disable Authentication for Local Addresses').click();
await expect(toggle(page, 'Trust Forwarded Headers')).toBeVisible();
await expect(page.locator('app-chip-input').filter({ hasText: 'Additional Trusted Networks' })).toBeVisible();
await toggle(page, 'Disable Authentication for Local Addresses').click();
await expect(toggle(page, 'Trust Forwarded Headers')).toHaveCount(0);
});
test('strike inactivity window enforces its max bound', async ({ page }) => {
await loginAndGotoSettings(page, 'general');
const fieldEl = page.locator('app-number-input').filter({ hasText: 'Strike Inactivity Window' });
await numberInput(page, 'Strike Inactivity Window').fill('200');
await expect(fieldEl.getByText(/168/)).toBeVisible();
await numberInput(page, 'Strike Inactivity Window').fill('24');
await expect(fieldEl.getByText(/168/)).toHaveCount(0);
});
test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => {
await loginAndGotoSettings(page, 'general');
await toggle(page, 'Dry Run Mode').click();
await expectGuardOnLeave(page);
await toggle(page, 'Dry Run Mode').click();
await expectNoGuardOnLeave(page);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { expect, type Page, type Locator } from '@playwright/test';
import { TEST_CONFIG } from './test-config';
type Scope = Page | Locator;
/**
* Log in through the browser UI and land on the dashboard.
* Mirrors the flow used by the OIDC UI specs.
*/
export async function loginViaBrowser(page: Page): Promise<void> {
await page.goto(`${TEST_CONFIG.appUrl}/auth/login`);
await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername);
await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword);
await page.getByRole('button', { name: 'Sign In', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
}
export async function gotoSettings(page: Page, path: string): Promise<void> {
await page.goto(`${TEST_CONFIG.appUrl}/settings/${path}`);
}
/** Log in, then navigate directly to a settings sub-page. */
export async function loginAndGotoSettings(page: Page, path: string): Promise<void> {
await loginViaBrowser(page);
await gotoSettings(page, path);
}
// --- Form-control locators ---------------------------------------------------
// The shared UI inputs/selects now associate their <label> with the control via
// for/id, but the label also contains a help button and a "new" badge, so the
// accessible name is polluted and getByLabel is brittle. We instead scope by the
// stable custom-element tag plus the label text, then the inner native control.
// Toggles are located by their aria-label (role=switch).
/** A toggle (role=switch) located by its label (aria-label). */
export function toggle(scope: Scope, label: string): Locator {
return scope.getByRole('switch', { name: label, exact: true });
}
/** The native <input> inside an app-input whose label contains `label`. */
export function textInput(scope: Scope, label: string): Locator {
return scope.locator('app-input').filter({ hasText: label }).locator('input');
}
/** The native number <input> inside an app-number-input whose label contains `label`. */
export function numberInput(scope: Scope, label: string): Locator {
return scope.locator('app-number-input').filter({ hasText: label }).locator('input');
}
/** The text entry <input> inside an app-chip-input whose label contains `label`. */
export function chipInput(scope: Scope, label: string): Locator {
return scope.locator('app-chip-input').filter({ hasText: label }).locator('input');
}
/** The combobox trigger button inside an app-select whose label contains `label`. */
export function select(scope: Scope, label: string): Locator {
return scope.locator('app-select').filter({ hasText: label }).getByRole('combobox');
}
/** Open an app-select (by label) and pick an option by its visible text. */
export async function selectOption(scope: Scope, label: string, optionText: string): Promise<void> {
const el = scope.locator('app-select').filter({ hasText: label });
await el.getByRole('combobox').click();
await el.getByRole('option', { name: optionText, exact: true }).click();
}
/** Read a toggle's checked state. */
export async function isToggleOn(sw: Locator): Promise<boolean> {
return (await sw.getAttribute('aria-checked')) === 'true';
}
/** Ensure a toggle is in the desired state, clicking only if needed. */
export async function ensureToggle(sw: Locator, on: boolean): Promise<void> {
if ((await isToggleOn(sw)) !== on) {
await sw.click();
}
}
/**
* Ensure an app-accordion is expanded. Clicks the header only if `probe`
* (an element inside the accordion body) is not already visible, so it works
* regardless of the accordion's default state.
*/
export async function ensureAccordionExpanded(page: Page, header: string, probe: Locator): Promise<void> {
if (!(await probe.first().isVisible().catch(() => false))) {
await page.getByText(header, { exact: true }).click();
await expect(probe.first()).toBeVisible({ timeout: 5_000 });
}
}
/** Add a value to an app-chip-input (types + Enter). */
export async function addChip(scope: Scope, label: string, value: string): Promise<void> {
const input = chipInput(scope, label);
await input.fill(value);
await input.press('Enter');
}
// --- Unsaved-changes navigation guard ---------------------------------------
// pendingChangesGuard shows a confirm alertdialog titled "Unsaved Changes"
// (buttons "Leave" / "Stay") when leaving a dirty settings route via a router
// link. We navigate via the sidebar link so the Angular guard actually fires
// (a full page.goto would bypass canDeactivate).
const GUARD = { name: 'Unsaved Changes' } as const;
/** Click a sidebar router link by its label. */
export function navLink(page: Page, label: string): Locator {
return page.getByRole('link', { name: label, exact: true });
}
/**
* Attempt to leave via the sidebar link and assert the unsaved-changes guard
* appears, then stay on the page.
*/
export async function expectGuardOnLeave(page: Page, linkLabel = 'Dashboard'): Promise<void> {
await navLink(page, linkLabel).click();
const dialog = page.getByRole('alertdialog', { name: GUARD.name });
await expect(dialog).toBeVisible({ timeout: 5_000 });
await dialog.getByRole('button', { name: 'Stay', exact: true }).click();
await expect(dialog).toBeHidden();
}
/**
* Attempt to leave via the sidebar link and assert NO guard appears (the form
* is not dirty), i.e. navigation proceeds. Returns having navigated away.
*/
export async function expectNoGuardOnLeave(page: Page, linkLabel = 'Dashboard'): Promise<void> {
await navLink(page, linkLabel).click();
await expect(page).toHaveURL(new RegExp(`/${linkLabel.toLowerCase()}`), { timeout: 5_000 });
await expect(page.getByRole('alertdialog', { name: GUARD.name })).toHaveCount(0);
}
+69
View File
@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
import {
loginAndGotoSettings,
toggle,
ensureToggle,
ensureAccordionExpanded,
expectGuardOnLeave,
expectNoGuardOnLeave,
} from './helpers/ui';
// Behavior-parity spec for the Malware Blocker settings form (page-level form + per-arr accordion).
test.describe('Malware Blocker UI', () => {
test('Enabled gates the form; Ignore Private disables Delete Private', async ({ page }) => {
await loginAndGotoSettings(page, 'malware-blocker');
const enabled = toggle(page, 'Enabled');
await ensureToggle(enabled, false);
await expect(toggle(page, 'Ignore Private Torrents')).toHaveCount(0);
await ensureToggle(enabled, true);
const ignorePrivate = toggle(page, 'Ignore Private Torrents');
const deletePrivate = toggle(page, 'Delete Private from Client');
await expect(ignorePrivate).toBeVisible();
await ensureToggle(ignorePrivate, false);
await expect(deletePrivate).toBeEnabled();
await ignorePrivate.click();
await expect(deletePrivate).toBeDisabled();
});
test('advanced scheduling swaps cron input and unit selects', async ({ page }) => {
await loginAndGotoSettings(page, 'malware-blocker');
await ensureToggle(toggle(page, 'Enabled'), true);
const adv = toggle(page, 'Advanced Scheduling');
await expect(page.locator('app-select').filter({ hasText: 'Schedule Unit' })).toBeVisible();
await ensureToggle(adv, true);
await expect(page.locator('app-input').filter({ hasText: 'Cron Expression' })).toBeVisible();
await expect(page.locator('app-select').filter({ hasText: 'Schedule Unit' })).toHaveCount(0);
});
test('per-arr blocklist path is gated by the section toggle and required', async ({ page }) => {
await loginAndGotoSettings(page, 'malware-blocker');
await ensureToggle(toggle(page, 'Enabled'), true);
const sonarr = page.locator('.arr-blocklist').filter({ hasText: 'Sonarr' });
await ensureAccordionExpanded(page, 'Arr Blocklists', sonarr);
const pathField = sonarr.locator('app-input').filter({ hasText: 'Blocklist Path' });
await expect(pathField).toHaveCount(0);
await sonarr.getByRole('switch', { name: 'Enabled' }).click();
await expect(pathField).toBeVisible();
await expect(pathField.locator('.input-error')).toBeVisible();
await pathField.locator('input').fill('/config/blacklist.txt');
await expect(pathField.locator('.input-error')).toHaveCount(0);
});
test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => {
await loginAndGotoSettings(page, 'malware-blocker');
const enabled = toggle(page, 'Enabled');
await enabled.click();
await expectGuardOnLeave(page);
await enabled.click();
await expectNoGuardOnLeave(page);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { test, expect, type Page } from '@playwright/test';
import { loginAndGotoSettings, selectOption } from './helpers/ui';
// Behavior-parity spec for the Notifications provider modal (discriminated union over provider type).
test.describe('Notifications UI', () => {
async function openProvider(page: Page, providerName: string) {
await page.getByRole('button', { name: 'Add Provider' }).click();
const selection = page.getByRole('dialog', { name: 'Add Notification Provider' });
await expect(selection).toBeVisible();
await selection.locator('button.provider-card').filter({ hasText: providerName }).click();
const modal = page.getByRole('dialog', { name: `Add ${providerName} Provider` });
await expect(modal).toBeVisible();
return modal;
}
test('Discord modal shows webhook field and gates Save on required fields', async ({ page }) => {
await loginAndGotoSettings(page, 'notifications');
const modal = await openProvider(page, 'Discord');
const webhook = modal.locator('app-input').filter({ hasText: 'Webhook URL' });
await expect(webhook).toBeVisible();
const save = modal.getByRole('button', { name: 'Save' });
await expect(save).toBeDisabled();
await modal.locator('app-input').first().locator('input').fill('My Discord'); // Name
await webhook.locator('input').fill('https://discord.com/api/webhooks/123/abc');
await expect(save).toBeEnabled();
});
test('Apprise mode switches between API and CLI fields', async ({ page }) => {
await loginAndGotoSettings(page, 'notifications');
const modal = await openProvider(page, 'Apprise');
const serverUrl = modal.locator('app-input').filter({ hasText: 'Server URL' });
const serviceUrls = modal.locator('app-chip-input').filter({ hasText: 'Service URLs' });
// API mode (default) shows Server URL + Config Key; CLI shows Service URLs.
await expect(serverUrl).toBeVisible();
await expect(serviceUrls).toHaveCount(0);
await selectOption(modal, 'Mode', 'CLI');
await expect(serviceUrls).toBeVisible();
await expect(serverUrl).toHaveCount(0);
});
test('Pushover Emergency priority reveals retry/expire fields', async ({ page }) => {
await loginAndGotoSettings(page, 'notifications');
const modal = await openProvider(page, 'Pushover');
const retry = modal.locator('app-number-input').filter({ hasText: 'Retry' });
const expire = modal.locator('app-number-input').filter({ hasText: 'Expire' });
await expect(retry).toHaveCount(0);
await expect(expire).toHaveCount(0);
await selectOption(modal, 'Priority', 'Emergency');
await expect(retry).toBeVisible();
await expect(expire).toBeVisible();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect, type Page } from '@playwright/test';
import {
loginAndGotoSettings,
toggle,
selectOption,
ensureToggle,
ensureAccordionExpanded,
expectGuardOnLeave,
expectNoGuardOnLeave,
} from './helpers/ui';
// Behavior-parity spec for the Queue Cleaner settings form + stall-rule modal.
test.describe('Queue Cleaner UI', () => {
async function openStallModal(page: Page) {
await ensureToggle(toggle(page, 'Enabled'), true);
await ensureAccordionExpanded(
page,
'Stalled Download Rules',
page.getByRole('button', { name: 'Add Stall Rule' }),
);
await page.getByRole('button', { name: 'Add Stall Rule' }).click();
const modal = page.getByRole('dialog', { name: 'Add Stall Rule' });
await expect(modal).toBeVisible();
return modal;
}
test('master Enabled gates the form; advanced scheduling swaps fields', async ({ page }) => {
await loginAndGotoSettings(page, 'queue-cleaner');
const enabled = toggle(page, 'Enabled');
await ensureToggle(enabled, false);
await expect(toggle(page, 'Advanced Scheduling')).toHaveCount(0);
await ensureToggle(enabled, true);
const adv = toggle(page, 'Advanced Scheduling');
await expect(adv).toBeVisible();
await expect(page.locator('app-select').filter({ hasText: 'Schedule Unit' })).toBeVisible();
await ensureToggle(adv, true);
await expect(page.locator('app-input').filter({ hasText: 'Cron Expression' })).toBeVisible();
await expect(page.locator('app-select').filter({ hasText: 'Schedule Unit' })).toHaveCount(0);
});
test('stall modal shows name-required and completion-range validation', async ({ page }) => {
await loginAndGotoSettings(page, 'queue-cleaner');
const modal = await openStallModal(page);
const nameCard = modal.locator('app-input').filter({ hasText: 'Name' });
await expect(nameCard.locator('.input-error')).toBeVisible();
await nameCard.locator('input').fill('My Rule');
await expect(nameCard.locator('.input-error')).toHaveCount(0);
const maxCard = modal.locator('app-number-input').filter({ hasText: 'Max Completion' });
await modal.locator('app-number-input').filter({ hasText: 'Min Completion' }).locator('input').fill('80');
await maxCard.locator('input').fill('10');
await expect(maxCard.locator('.number-error')).toBeVisible();
await modal.getByRole('button', { name: 'Cancel' }).click();
await expect(modal).toBeHidden();
});
test('stall modal: reset-on-progress reveals size field; public disables delete-private', async ({ page }) => {
await loginAndGotoSettings(page, 'queue-cleaner');
const modal = await openStallModal(page);
const minProgress = modal.locator('app-size-input').filter({ hasText: 'Minimum Progress' });
await expect(minProgress).toHaveCount(0);
await modal.getByRole('switch', { name: 'Reset Strikes on Progress' }).click();
await expect(minProgress).toBeVisible();
await selectOption(modal, 'Privacy Type', 'Public');
await expect(modal.getByRole('switch', { name: 'Delete Private from Client' })).toBeDisabled();
await modal.getByRole('button', { name: 'Cancel' }).click();
});
test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => {
await loginAndGotoSettings(page, 'queue-cleaner');
const enabled = toggle(page, 'Enabled');
await enabled.click();
await expectGuardOnLeave(page);
await enabled.click();
await expectNoGuardOnLeave(page);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import {
loginAndGotoSettings,
toggle,
ensureToggle,
expectGuardOnLeave,
expectNoGuardOnLeave,
} from './helpers/ui';
// Behavior-parity spec for the Seeker settings form.
test.describe('Seeker UI', () => {
const interval = (page: import('@playwright/test').Page) =>
page.locator('app-select').filter({ hasText: 'Search Interval' });
const proactiveCard = (page: import('@playwright/test').Page) =>
page.locator('app-card').filter({ hasText: 'Proactive Search' });
const strategy = (page: import('@playwright/test').Page) =>
page.locator('app-select').filter({ hasText: 'Selection Strategy' });
test('Search Enabled gates the interval and the Proactive Search card', async ({ page }) => {
await loginAndGotoSettings(page, 'seeker');
const searchEnabled = toggle(page, 'Search Enabled');
await ensureToggle(searchEnabled, false);
await expect(interval(page)).toHaveCount(0);
await expect(toggle(page, 'Proactive Search')).toHaveCount(0);
await ensureToggle(searchEnabled, true);
await expect(interval(page)).toBeVisible();
await expect(toggle(page, 'Proactive Search')).toBeVisible();
});
test('Proactive Search gates the Selection Strategy select', async ({ page }) => {
await loginAndGotoSettings(page, 'seeker');
await ensureToggle(toggle(page, 'Search Enabled'), true);
const proactive = toggle(page, 'Proactive Search');
await ensureToggle(proactive, false);
await expect(strategy(page)).toHaveCount(0);
await ensureToggle(proactive, true);
await expect(strategy(page)).toBeVisible();
});
test('Round Robin toggle asks for confirmation', async ({ page }) => {
await loginAndGotoSettings(page, 'seeker');
await ensureToggle(toggle(page, 'Search Enabled'), true);
await ensureToggle(toggle(page, 'Proactive Search'), true);
await toggle(page, 'Round Robin').click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Dismiss without applying the change.
await dialog.getByRole('button').first().click();
await expect(dialog).toBeHidden();
});
test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => {
await loginAndGotoSettings(page, 'seeker');
const searchEnabled = toggle(page, 'Search Enabled');
await searchEnabled.click();
await expectGuardOnLeave(page);
await searchEnabled.click();
await expectNoGuardOnLeave(page);
});
});