mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
Upgrade frontend to Angular v22 (#644)
This commit is contained in:
1 parent
9fd99bd5f5
commit
be3db1616e
91 files changed
+5073
-3974
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
);
|
||||
Generated
+1626
-1469
File diff suppressed because it is too large.
Load diff
+25
-20
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+7
-7
@@ -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"
|
||||
|
||||
+70
-84
@@ -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,
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
+51
-68
@@ -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'),
|
||||
});
|
||||
|
||||
+4
-4
@@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
+48
-35
@@ -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(() => {
|
||||
|
||||
+52
-70
@@ -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., "0 0/5 * ? * * *" 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>
|
||||
|
||||
+349
-330
@@ -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(() => {
|
||||
|
||||
+13
-13
@@ -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>
|
||||
|
||||
+113
-106
@@ -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(() => {
|
||||
|
||||
+23
-27
@@ -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., "0 0/5 * ? * * *" 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)"
|
||||
/>
|
||||
}
|
||||
|
||||
+169
-156
@@ -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(() => {
|
||||
|
||||
+67
-65
@@ -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>
|
||||
|
||||
+349
-367
@@ -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'),
|
||||
});
|
||||
|
||||
+58
-67
@@ -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., "0 0/5 * ? * * *" 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>
|
||||
|
||||
+346
-353
@@ -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">×</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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -68,6 +68,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user