mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 20:08:59 -04:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d34ddb841d | ||
|
|
65afcaf3a4 | ||
|
|
d784091ee4 | ||
|
|
81cd7583cd | ||
|
|
3f6c3321e9 | ||
|
|
be3db1616e | ||
|
|
9fd99bd5f5 | ||
|
|
8f1ea44466 | ||
|
|
6ec19a92b2 |
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
|
||||
@@ -71,7 +71,8 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: e2e
|
||||
run: npx playwright install --with-deps chromium
|
||||
timeout-minutes: 5
|
||||
run: npx playwright install chromium
|
||||
|
||||
- name: Wait for Keycloak
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed class AuthController : ControllerBase
|
||||
private readonly IPlexAuthService _plexAuthService;
|
||||
private readonly IOidcAuthService _oidcAuthService;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
|
||||
public AuthController(
|
||||
UsersContext usersContext,
|
||||
@@ -37,7 +38,8 @@ public sealed class AuthController : ControllerBase
|
||||
ITotpService totpService,
|
||||
IPlexAuthService plexAuthService,
|
||||
IOidcAuthService oidcAuthService,
|
||||
ILogger<AuthController> logger)
|
||||
ILogger<AuthController> logger,
|
||||
IWebHostEnvironment environment)
|
||||
{
|
||||
_usersContext = usersContext;
|
||||
_dataContext = dataContext;
|
||||
@@ -47,6 +49,7 @@ public sealed class AuthController : ControllerBase
|
||||
_plexAuthService = plexAuthService;
|
||||
_oidcAuthService = oidcAuthService;
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -497,7 +500,17 @@ public sealed class AuthController : ControllerBase
|
||||
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
|
||||
}
|
||||
|
||||
var pin = await _plexAuthService.RequestPin();
|
||||
string baseUrl = HttpContext.GetExternalBaseUrl();
|
||||
if (_environment.IsDevelopment())
|
||||
{
|
||||
string origin = Request.Headers.Origin.ToString();
|
||||
if (!string.IsNullOrEmpty(origin))
|
||||
{
|
||||
baseUrl = $"{origin}{Request.GetSafeBasePath()}";
|
||||
}
|
||||
}
|
||||
string forwardUrl = $"{baseUrl}/auth/plex/callback";
|
||||
PlexPinResult pin = await _plexAuthService.RequestPin(forwardUrl);
|
||||
|
||||
return Ok(new PlexPinStatusResponse
|
||||
{
|
||||
|
||||
+36
@@ -847,4 +847,40 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
|
||||
.SetTorrentLabel("hash1", "unlinked");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetClaimedPaths_Tests : DelugeServiceDCTests
|
||||
{
|
||||
public GetClaimedPaths_Tests(DelugeServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
|
||||
{
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new DelugeItemWrapper(new DownloadStatus
|
||||
{
|
||||
Hash = "hash1",
|
||||
Name = "Renamed Display",
|
||||
Trackers = new List<Tracker>(),
|
||||
DownloadLocation = "/downloads"
|
||||
});
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFiles("hash1")
|
||||
.Returns(new DelugeContents
|
||||
{
|
||||
Contents = new Dictionary<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "show/file1.mkv" } },
|
||||
{ "file2.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "show/file2.mkv" } }
|
||||
}
|
||||
});
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/show");
|
||||
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
|
||||
claimed.ShouldNotContain("/downloads/Renamed Display");
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
@@ -1343,4 +1343,71 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
|
||||
.AddTorrentTagAsync(Arg.Is<IEnumerable<string>>(h => h.Contains("hash1")), "unlinked");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetClaimedPaths_Tests : QBitServiceDCTests
|
||||
{
|
||||
public GetClaimedPaths_Tests(QBitServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UsesFileList_WhenDisplayNameDivergesFromDisk()
|
||||
{
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new QBitItemWrapper(
|
||||
new TorrentInfo { Hash = "hash1", Name = "Renamed Display Name", SavePath = "/downloads" },
|
||||
Array.Empty<TorrentTracker>(),
|
||||
false);
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentContentsAsync("hash1")
|
||||
.Returns(new[] { new TorrentContent { Index = 0, Name = "actual-folder/data.bin", Priority = TorrentContentPriority.Normal } });
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/actual-folder");
|
||||
claimed.ShouldNotContain("/downloads/Renamed Display Name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FallsBackToSavePathAndName_WhenFileListUnavailable()
|
||||
{
|
||||
// no files returned (e.g. metadata not yet fetched) — claim save path + name.
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new QBitItemWrapper(
|
||||
new TorrentInfo { Hash = "hash1", Name = "some-show", SavePath = "/downloads" },
|
||||
Array.Empty<TorrentTracker>(),
|
||||
false);
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentContentsAsync("hash1")
|
||||
.Returns(Array.Empty<TorrentContent>());
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/some-show");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiFileSharingFolder_ClaimsSingleRoot()
|
||||
{
|
||||
// both files live under one folder → one claimed entry, not the deep file paths.
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new QBitItemWrapper(
|
||||
new TorrentInfo { Hash = "hash1", Name = "show", SavePath = "/downloads" },
|
||||
Array.Empty<TorrentTracker>(),
|
||||
false);
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentContentsAsync("hash1")
|
||||
.Returns(new[]
|
||||
{
|
||||
new TorrentContent { Index = 0, Name = "show/file1.mkv", Priority = TorrentContentPriority.Normal },
|
||||
new TorrentContent { Index = 1, Name = "show/file2.mkv", Priority = TorrentContentPriority.Normal }
|
||||
});
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/show");
|
||||
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
|
||||
claimed.ShouldNotContain("/downloads/show/file1.mkv");
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -772,4 +772,32 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
|
||||
wrapper.Category.ShouldBe("unlinked");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetClaimedPaths_Tests : RTorrentServiceDCTests
|
||||
{
|
||||
public GetClaimedPaths_Tests(RTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClaimsBasePathAndDirectory()
|
||||
{
|
||||
// rTorrent resolves base_path (content root) and directory (its parent) itself;
|
||||
// no file lookup, and the display name is never involved.
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent
|
||||
{
|
||||
Hash = "HASH1",
|
||||
Name = "Renamed Display",
|
||||
BasePath = "/downloads/show",
|
||||
Directory = "/downloads"
|
||||
});
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/show");
|
||||
claimed.ShouldContain("/downloads");
|
||||
claimed.ShouldNotContain("/downloads/Renamed Display");
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -1001,4 +1001,36 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
|
||||
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
|
||||
}
|
||||
}
|
||||
|
||||
public class GetClaimedPaths_Tests : TransmissionServiceDCTests
|
||||
{
|
||||
public GetClaimedPaths_Tests(TransmissionServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DerivesRootFromFileList_SharedFolderDedupes()
|
||||
{
|
||||
// Transmission carries the files in the list response; the root is derived from them,
|
||||
// not the display name.
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new TransmissionItemWrapper(new TorrentInfo
|
||||
{
|
||||
HashString = "hash1",
|
||||
Name = "Renamed Display",
|
||||
DownloadDir = "/downloads",
|
||||
Files = new[]
|
||||
{
|
||||
new TransmissionTorrentFiles { Name = "show/file1.mkv" },
|
||||
new TransmissionTorrentFiles { Name = "show/file2.mkv" }
|
||||
}
|
||||
});
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/show");
|
||||
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
|
||||
claimed.ShouldNotContain("/downloads/Renamed Display");
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -708,4 +708,33 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
|
||||
await _fixture.ClientWrapper.Received(1).SetTorrentLabelAsync("hash1", "unlinked");
|
||||
}
|
||||
}
|
||||
|
||||
public class GetClaimedPaths_Tests : UTorrentServiceDCTests
|
||||
{
|
||||
public GetClaimedPaths_Tests(UTorrentServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DerivesRootFromFetchedFiles_SharedFolderDedupes()
|
||||
{
|
||||
var sut = _fixture.CreateSut();
|
||||
var wrapper = new UTorrentItemWrapper(
|
||||
new UTorrentItem { Hash = "hash1", Name = "Renamed Display", SavePath = "/downloads" },
|
||||
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" });
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentFilesAsync("hash1")
|
||||
.Returns(new List<UTorrentFile>
|
||||
{
|
||||
new UTorrentFile { Name = "show/file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 1000 },
|
||||
new UTorrentFile { Name = "show/file2.mkv", Priority = 1, Index = 1, Size = 1000, Downloaded = 1000 }
|
||||
});
|
||||
|
||||
IReadOnlyList<string> claimed = await sut.GetClaimedPathsAsync(new Domain.Entities.ITorrentItemWrapper[] { wrapper });
|
||||
|
||||
claimed.ShouldContain("/downloads/show");
|
||||
claimed.Count(p => p == "/downloads/show").ShouldBe(1);
|
||||
claimed.ShouldNotContain("/downloads/Renamed Display");
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -82,10 +82,33 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
|
||||
svc.LoginAsync().Returns(Task.CompletedTask);
|
||||
svc.GetSeedingDownloads().Returns([]);
|
||||
svc.GetAllTorrentsLite().Returns(torrents);
|
||||
svc.GetClaimedPathsAsync(Arg.Any<IReadOnlyList<ITorrentItemWrapper>>())
|
||||
.Returns(ci => Task.FromResult(BuildDefaultClaimedPaths(ci.Arg<IReadOnlyList<ITorrentItemWrapper>>())));
|
||||
_fixture.DownloadServiceFactory.GetDownloadService(clientConfig).Returns(svc);
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildDefaultClaimedPaths(IReadOnlyList<ITorrentItemWrapper> torrents)
|
||||
{
|
||||
HashSet<string> paths = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (ITorrentItemWrapper torrent in torrents)
|
||||
{
|
||||
if (string.IsNullOrEmpty(torrent.SavePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
paths.Add(torrent.SavePath.TrimEnd(Path.DirectorySeparatorChar));
|
||||
|
||||
if (!string.IsNullOrEmpty(torrent.Name))
|
||||
{
|
||||
paths.Add(Path.Combine(torrent.SavePath, torrent.Name).TrimEnd(Path.DirectorySeparatorChar));
|
||||
}
|
||||
}
|
||||
|
||||
return paths.ToList();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OrphanedFiles_NoEnabledClientConfigs_SkipsScan()
|
||||
{
|
||||
|
||||
@@ -22,7 +22,22 @@ public sealed record PlexAccountInfo
|
||||
|
||||
public interface IPlexAuthService
|
||||
{
|
||||
Task<PlexPinResult> RequestPin();
|
||||
/// <summary>
|
||||
/// Creates a Plex authentication PIN and builds the URL the user is sent to in order to authorize.
|
||||
/// </summary>
|
||||
/// <param name="forwardUrl">
|
||||
/// Optional URL Plex redirects the browser back to after authorization. When omitted, no redirect
|
||||
/// is added and the caller is expected to poll <see cref="CheckPin"/> instead.
|
||||
/// </param>
|
||||
Task<PlexPinResult> RequestPin(string? forwardUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a PIN has been authorized, returning the Plex auth token once it has.
|
||||
/// </summary>
|
||||
Task<PlexPinCheckResult> CheckPin(int pinId);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the Plex account associated with the given auth token.
|
||||
/// </summary>
|
||||
Task<PlexAccountInfo> GetAccount(string authToken);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public sealed class PlexAuthService : IPlexAuthService
|
||||
_clientIdentifier = GetOrCreateClientIdentifier();
|
||||
}
|
||||
|
||||
public async Task<PlexPinResult> RequestPin()
|
||||
public async Task<PlexPinResult> RequestPin(string? forwardUrl = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
|
||||
AddPlexHeaders(request);
|
||||
@@ -43,6 +43,11 @@ public sealed class PlexAuthService : IPlexAuthService
|
||||
|
||||
var authUrl = $"https://app.plex.tv/auth#?clientID={Uri.EscapeDataString(_clientIdentifier)}&code={Uri.EscapeDataString(pin.Code)}&context%5Bdevice%5D%5Bproduct%5D={Uri.EscapeDataString(PlexProduct)}";
|
||||
|
||||
if (!string.IsNullOrEmpty(forwardUrl))
|
||||
{
|
||||
authUrl += $"&forwardUrl={Uri.EscapeDataString(forwardUrl)}";
|
||||
}
|
||||
|
||||
return new PlexPinResult
|
||||
{
|
||||
PinId = pin.Id,
|
||||
|
||||
+2
-27
@@ -5,7 +5,6 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -155,33 +154,9 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (ITorrentItemWrapper torrent in torrents)
|
||||
foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents))
|
||||
{
|
||||
if (string.IsNullOrEmpty(torrent.SavePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string remappedSavePath = PathHelper.NormalizeAndRemap(
|
||||
torrent.SavePath,
|
||||
downloadClient.DownloadDirectorySource,
|
||||
downloadClient.DownloadDirectoryTarget
|
||||
).TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
claimedPaths.Add(remappedSavePath);
|
||||
|
||||
if (string.IsNullOrEmpty(torrent.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string contentPath = PathHelper.NormalizeAndRemap(
|
||||
Path.Combine(torrent.SavePath, torrent.Name),
|
||||
downloadClient.DownloadDirectorySource,
|
||||
downloadClient.DownloadDirectoryTarget
|
||||
);
|
||||
|
||||
claimedPaths.Add(contentPath.TrimEnd(Path.DirectorySeparatorChar));
|
||||
claimedPaths.Add(claimedPath);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Loaded {count} torrents | {name}", torrents.Count, downloadClient.Name);
|
||||
|
||||
+21
@@ -41,6 +41,27 @@ public partial class DelugeService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
|
||||
BuildClaimedPathsAsync(torrents, async torrent =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(torrent.Hash))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
DelugeContents? contents = await _client.GetTorrentFiles(torrent.Hash);
|
||||
List<string> relativePaths = [];
|
||||
ProcessFiles(contents?.Contents, (_, file) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(file.Path))
|
||||
{
|
||||
relativePaths.Add(file.Path);
|
||||
}
|
||||
});
|
||||
return relativePaths;
|
||||
});
|
||||
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
|
||||
downloads
|
||||
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
|
||||
|
||||
@@ -11,6 +11,7 @@ using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
@@ -77,6 +78,79 @@ public abstract class DownloadService : IDownloadService
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
|
||||
|
||||
protected async Task<IReadOnlyList<string>> BuildClaimedPathsAsync(
|
||||
IReadOnlyList<ITorrentItemWrapper> torrents,
|
||||
Func<ITorrentItemWrapper, Task<IReadOnlyCollection<string>>> resolveRelativeFilePaths)
|
||||
{
|
||||
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (ITorrentItemWrapper torrent in torrents)
|
||||
{
|
||||
IReadOnlyCollection<string> relativeFilePaths;
|
||||
try
|
||||
{
|
||||
relativeFilePaths = await resolveRelativeFilePaths(torrent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "failed to resolve files, falling back to name | {name}", torrent.Name);
|
||||
relativeFilePaths = [];
|
||||
}
|
||||
|
||||
foreach (string path in BuildClaimedPaths(torrent, relativeFilePaths))
|
||||
{
|
||||
claimed.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return claimed.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top-level entries a torrent occupies.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildClaimedPaths(ITorrentItemWrapper torrent, IReadOnlyCollection<string> relativeFilePaths)
|
||||
{
|
||||
List<string> claimed = [];
|
||||
if (string.IsNullOrEmpty(torrent.SavePath))
|
||||
{
|
||||
return claimed;
|
||||
}
|
||||
|
||||
claimed.Add(RemapAndTrim(torrent.SavePath));
|
||||
|
||||
IReadOnlyCollection<string> sources = relativeFilePaths;
|
||||
if (sources.Count == 0 && !string.IsNullOrEmpty(torrent.Name))
|
||||
{
|
||||
sources = [torrent.Name];
|
||||
}
|
||||
|
||||
foreach (string relativePath in sources)
|
||||
{
|
||||
string firstSegment = FirstSegment(relativePath);
|
||||
if (!string.IsNullOrEmpty(firstSegment))
|
||||
{
|
||||
claimed.Add(RemapAndTrim(Path.Combine(torrent.SavePath, firstSegment)));
|
||||
}
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
private static string FirstSegment(string relativePath)
|
||||
{
|
||||
string[] parts = relativePath.Replace('\\', '/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length > 0 ? parts[0] : string.Empty;
|
||||
}
|
||||
|
||||
protected string RemapAndTrim(string path) =>
|
||||
PathHelper
|
||||
.NormalizeAndRemap(path, _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget)
|
||||
.TrimEnd(Path.DirectorySeparatorChar);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ public interface IDownloadService : IDisposable
|
||||
/// <returns>A list of all torrents.</returns>
|
||||
Task<List<ITorrentItemWrapper>> GetAllTorrentsLite();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the on-disk paths claimed by the given torrents.
|
||||
/// </summary>
|
||||
/// <returns>The distinct, remapped paths claimed by the torrents.</returns>
|
||||
Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents);
|
||||
|
||||
/// <summary>
|
||||
/// Filters downloads that should be cleaned.
|
||||
/// </summary>
|
||||
|
||||
+13
@@ -48,6 +48,19 @@ public partial class QBitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
|
||||
BuildClaimedPathsAsync(torrents, async torrent =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(torrent.Hash))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
|
||||
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
|
||||
downloads
|
||||
|
||||
+26
@@ -32,6 +32,32 @@ public partial class RTorrentService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents)
|
||||
{
|
||||
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (ITorrentItemWrapper torrent in torrents)
|
||||
{
|
||||
if (torrent is not RTorrentItemWrapper wrapper)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(wrapper.Info.BasePath))
|
||||
{
|
||||
claimed.Add(RemapAndTrim(wrapper.Info.BasePath));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(wrapper.Info.Directory))
|
||||
{
|
||||
claimed.Add(RemapAndTrim(wrapper.Info.Directory));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(claimed.ToList());
|
||||
}
|
||||
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
|
||||
downloads
|
||||
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
|
||||
|
||||
+13
@@ -31,6 +31,19 @@ public partial class TransmissionService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
|
||||
BuildClaimedPathsAsync(torrents, torrent =>
|
||||
{
|
||||
IReadOnlyCollection<string> files = torrent is TransmissionItemWrapper { Info.Files.Length: > 0 } wrapper
|
||||
? wrapper.Info.Files
|
||||
.Select(f => f.Name)
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.ToList()
|
||||
: [];
|
||||
return Task.FromResult(files);
|
||||
});
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
|
||||
{
|
||||
|
||||
+13
@@ -36,6 +36,19 @@ public partial class UTorrentService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<IReadOnlyList<string>> GetClaimedPathsAsync(IReadOnlyList<ITorrentItemWrapper> torrents) =>
|
||||
BuildClaimedPathsAsync(torrents, async torrent =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(torrent.Hash))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);
|
||||
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
|
||||
});
|
||||
|
||||
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
|
||||
downloads
|
||||
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@angular/cdk/overlay-prebuilt.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"stylePreprocessorOptions": {
|
||||
|
||||
@@ -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
+1570
-1989
File diff suppressed because it is too large.
Load diff
+25
-23
@@ -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,39 +22,40 @@
|
||||
},
|
||||
"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",
|
||||
"rxjs": "~7.8.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"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,
|
||||
|
||||
@@ -166,6 +166,13 @@ export const routes: Routes = [
|
||||
'@features/auth/oidc-callback/oidc-callback.component'
|
||||
).then((m) => m.OidcCallbackComponent),
|
||||
},
|
||||
{
|
||||
path: 'plex/callback',
|
||||
loadComponent: () =>
|
||||
import(
|
||||
'@features/auth/plex-callback/plex-callback.component'
|
||||
).then((m) => m.PlexCallbackComponent),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '**', redirectTo: 'dashboard' },
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HttpInterceptorFn, HttpErrorResponse, HttpContextToken, HttpContext } f
|
||||
import { inject } from '@angular/core';
|
||||
import { catchError, switchMap, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
|
||||
const IS_RETRY = new HttpContextToken<boolean>(() => false);
|
||||
|
||||
@@ -24,7 +25,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
});
|
||||
return next(freshReq);
|
||||
}
|
||||
auth.logout();
|
||||
if (!auth.hasRefreshToken()) {
|
||||
auth.logout();
|
||||
}
|
||||
return throwError(() => new HttpErrorResponse({ status: 401 }));
|
||||
}),
|
||||
);
|
||||
@@ -39,9 +42,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
}
|
||||
|
||||
return next(req).pipe(
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
catchError((error) => {
|
||||
// Fallback: 401 catch for edge cases (e.g., token expired between check and send)
|
||||
if (error.status === 401 && token && !req.context.get(IS_RETRY)) {
|
||||
if ((error as ApiError).statusCode === 401 && token && !req.context.get(IS_RETRY)) {
|
||||
return auth.refreshToken().pipe(
|
||||
switchMap((result) => {
|
||||
if (result) {
|
||||
@@ -51,7 +54,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
});
|
||||
return next(retryReq);
|
||||
}
|
||||
auth.logout();
|
||||
if (!auth.isAuthenticated()) {
|
||||
auth.logout();
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, tap, of, catchError, finalize, shareReplay } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
|
||||
export interface AuthStatus {
|
||||
setupCompleted: boolean;
|
||||
@@ -213,8 +214,10 @@ export class AuthService {
|
||||
.post<TokenResponse>('/api/auth/refresh', { refreshToken: storedRefreshToken })
|
||||
.pipe(
|
||||
tap((tokens) => this.handleTokens(tokens)),
|
||||
catchError(() => {
|
||||
this.clearAuth();
|
||||
catchError((err) => {
|
||||
if ((err as ApiError).statusCode === 401) {
|
||||
this.clearAuth();
|
||||
}
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => {
|
||||
@@ -229,7 +232,12 @@ export class AuthService {
|
||||
logout(): void {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
this.http.post('/api/auth/logout', { refreshToken }).subscribe();
|
||||
// Best-effort server-side token revocation; the local session is cleared
|
||||
// regardless, so a failed call must not surface as an unhandled error.
|
||||
this.http
|
||||
.post('/api/auth/logout', { refreshToken })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe();
|
||||
}
|
||||
this.clearAuth();
|
||||
this.router.navigate(['/auth/login']);
|
||||
@@ -239,6 +247,11 @@ export class AuthService {
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
/** True while a refresh token is stored. Cleared only on a definitive refresh rejection. */
|
||||
hasRefreshToken(): boolean {
|
||||
return localStorage.getItem('refresh_token') !== null;
|
||||
}
|
||||
|
||||
/** Returns true if the access token is expired or will expire within the buffer period. */
|
||||
isTokenExpired(bufferSeconds = 30): boolean {
|
||||
const token = localStorage.getItem('access_token');
|
||||
|
||||
@@ -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 {
|
||||
@@ -86,8 +84,6 @@ export class DocumentationService {
|
||||
'unlinkedEnabled': 'enable-unlinked-download-handling',
|
||||
'unlinkedTargetCategory': 'target-category',
|
||||
'unlinkedUseTag': 'use-tag',
|
||||
'downloadDirectorySource': 'download-directory-source-and-local-directory-target',
|
||||
'downloadDirectoryTarget': 'download-directory-source-and-local-directory-target',
|
||||
'unlinkedIgnoredRootDir': 'ignored-root-directory',
|
||||
'unlinkedCategories': 'unlinked-categories',
|
||||
'deadTorrentEnabled': 'enable-dead-torrent',
|
||||
@@ -130,6 +126,8 @@ export class DocumentationService {
|
||||
'externalUrl': 'external-url',
|
||||
'username': 'username',
|
||||
'password': 'password',
|
||||
'downloadDirectorySource': 'download-directory-source-and-target',
|
||||
'downloadDirectoryTarget': 'download-directory-source-and-target',
|
||||
},
|
||||
'blacklist-sync': {
|
||||
'enabled': 'enable-blacklist-sync',
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
}
|
||||
|
||||
.retry-countdown {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -91,16 +91,16 @@
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
background: #e5a00d;
|
||||
color: var(--plex-brand-ink);
|
||||
background: var(--plex-brand);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d4920c;
|
||||
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
|
||||
background: var(--plex-brand-hover);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--plex-brand) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
|
||||
@@ -40,7 +40,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
// Plex
|
||||
plexLinked = this.auth.plexLinked;
|
||||
plexLoading = signal(false);
|
||||
plexPinId = signal(0);
|
||||
|
||||
// OIDC
|
||||
oidcEnabled = this.auth.oidcEnabled;
|
||||
@@ -88,9 +87,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearCountdown();
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
submitLogin(): void {
|
||||
@@ -166,17 +162,14 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
this.loginToken.set('');
|
||||
}
|
||||
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
startPlexLogin(): void {
|
||||
this.plexLoading.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.requestPlexPin().subscribe({
|
||||
next: (result) => {
|
||||
this.plexPinId.set(result.pinId);
|
||||
window.open(result.authUrl, '_blank');
|
||||
this.pollPlexPin();
|
||||
sessionStorage.setItem('plex_login_pin_id', String(result.pinId));
|
||||
window.location.href = result.authUrl;
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err.message || 'Failed to start Plex login');
|
||||
@@ -201,34 +194,6 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexPin(): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.error.set('Plex authorization timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.verifyPlexPin(this.plexPinId()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLoading.set(false);
|
||||
this.error.set(err.message || 'Plex authorization failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private startCountdown(seconds: number): void {
|
||||
this.clearCountdown();
|
||||
this.retryCountdown.set(seconds);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, OnInit, OnDestroy, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { SpinnerComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plex-callback',
|
||||
standalone: true,
|
||||
imports: [SpinnerComponent],
|
||||
template: `
|
||||
<div class="plex-callback">
|
||||
@if (error()) {
|
||||
<p class="plex-callback__error">{{ error() }}</p>
|
||||
<p class="plex-callback__redirect">Redirecting to login...</p>
|
||||
} @else {
|
||||
<app-spinner />
|
||||
<p class="plex-callback__message">Completing sign in...</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.plex-callback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plex-callback__message {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.plex-callback__error {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.plex-callback__redirect {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlexCallbackComponent implements OnInit, OnDestroy {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly error = signal('');
|
||||
|
||||
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
const stored = sessionStorage.getItem('plex_login_pin_id');
|
||||
sessionStorage.removeItem('plex_login_pin_id');
|
||||
const pinId = Number(stored);
|
||||
|
||||
if (!stored || Number.isNaN(pinId)) {
|
||||
this.handleError('Invalid Plex sign-in session');
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollPin(pinId);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroyed = true;
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
private pollPin(pinId: number): void {
|
||||
const deadline = Date.now() + 120_000;
|
||||
const poll = () => {
|
||||
this.auth.verifyPlexPin(pinId).subscribe({
|
||||
next: (result) => {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (result.completed) {
|
||||
this.router.navigate(['/dashboard']);
|
||||
} else if (Date.now() >= deadline) {
|
||||
this.handleError('Plex authorization timed out');
|
||||
} else {
|
||||
this.pollTimer = setTimeout(poll, 1000);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.handleError(err.message || 'Plex authorization failed');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
poll();
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(message: string): void {
|
||||
this.error.set(message);
|
||||
setTimeout(() => this.router.navigate(['/auth/login']), 3000);
|
||||
}
|
||||
}
|
||||
@@ -330,8 +330,8 @@
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: #1a1a2e;
|
||||
background: #e5a00d;
|
||||
color: var(--plex-brand-ink);
|
||||
background: var(--plex-brand);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
@@ -339,8 +339,8 @@
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #d4920c;
|
||||
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
|
||||
background: var(--plex-brand-hover);
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--plex-brand) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, DestroyRef } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ButtonComponent, InputComponent, SpinnerComponent, EmptyStateComponent } from '@ui';
|
||||
import { AuthService } from '@core/auth/auth.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { pollPlexPin } from '@shared/utils/plex-pin-poller';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import { tablerCheck, tablerCopy, tablerShieldLock } from '@ng-icons/tabler-icons';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
@@ -18,10 +19,11 @@ import { forkJoin, timer } from 'rxjs';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [provideIcons({ tablerCheck, tablerCopy, tablerShieldLock })],
|
||||
})
|
||||
export class SetupComponent implements OnDestroy {
|
||||
export class SetupComponent {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly connectionError = this.auth.connectionError;
|
||||
readonly retrying = signal(false);
|
||||
@@ -182,57 +184,47 @@ export class SetupComponent implements OnDestroy {
|
||||
|
||||
// Step 3: Plex linking
|
||||
startPlexLink(): void {
|
||||
// Open the popup synchronously on the click so popup blockers allow it, then
|
||||
// point it at the auth URL once the PIN request resolves.
|
||||
const authWindow = window.open('', '_blank');
|
||||
this.plexLinking.set(true);
|
||||
this.error.set('');
|
||||
|
||||
this.auth.requestSetupPlexPin().subscribe({
|
||||
next: (result) => {
|
||||
this.plexPinId.set(result.pinId);
|
||||
window.open(result.authUrl, '_blank');
|
||||
if (authWindow) {
|
||||
authWindow.location.href = result.authUrl;
|
||||
} else {
|
||||
window.open(result.authUrl, '_blank');
|
||||
}
|
||||
this.pollPlexPin();
|
||||
},
|
||||
error: (err) => {
|
||||
authWindow?.close();
|
||||
this.error.set(err.message || 'Failed to start Plex link');
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private pollPlexPin(): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
// Timeout after ~2 minutes
|
||||
clearInterval(this.plexPollTimer!);
|
||||
pollPlexPin({
|
||||
verify: () => this.auth.verifySetupPlexPin(this.plexPinId()),
|
||||
onCompleted: () => {
|
||||
this.plexLinked.set(true);
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
this.plexLinking.set(false);
|
||||
this.error.set((error as { message?: string })?.message || 'Plex linking failed');
|
||||
},
|
||||
onTimeout: () => {
|
||||
this.plexLinking.set(false);
|
||||
this.error.set('Plex authorization timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.auth.verifySetupPlexPin(this.plexPinId()).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinked.set(true);
|
||||
this.plexLinking.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.error.set(err.message || 'Plex linking failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
},
|
||||
destroyRef: this.destroyRef,
|
||||
});
|
||||
}
|
||||
|
||||
completeSetup(): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Support section
|
||||
@@ -7,7 +8,7 @@
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -182,7 +183,7 @@
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
@media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); }
|
||||
@include mobile { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
&__stat {
|
||||
@@ -432,7 +433,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--color-primary-subtle), rgba(59, 130, 246, 0.15), transparent);
|
||||
background: linear-gradient(90deg, var(--color-primary-subtle), rgba(var(--accent-rgb), 0.15), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -635,12 +636,12 @@
|
||||
gap: var(--space-2);
|
||||
min-width: 200px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
&__row {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
@@ -683,7 +684,7 @@
|
||||
}
|
||||
|
||||
// Mobile compact dashboard
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.card-inner {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
@@ -279,7 +280,7 @@
|
||||
}
|
||||
|
||||
// Tablet responsiveness
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
.toolbar__filters {
|
||||
app-input {
|
||||
min-width: 0;
|
||||
@@ -296,7 +297,7 @@
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.toolbar__filters {
|
||||
app-input {
|
||||
min-width: 0;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
@@ -248,7 +249,7 @@
|
||||
}
|
||||
|
||||
// Tablet responsiveness
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
.toolbar__filters {
|
||||
app-input {
|
||||
min-width: 0;
|
||||
@@ -261,7 +262,7 @@
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.toolbar__filters {
|
||||
app-input {
|
||||
min-width: 0;
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
@@ -234,7 +235,7 @@
|
||||
}
|
||||
|
||||
// Tablet
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
.score-row__main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -245,7 +246,7 @@
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.stats-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
@@ -388,7 +389,7 @@
|
||||
}
|
||||
|
||||
// Tablet
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
.list-row__main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -399,7 +400,7 @@
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.stats-bar {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
|
||||
+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"
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
@use 'responsive' as *;
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
@@ -125,14 +126,14 @@
|
||||
}
|
||||
|
||||
// Tablet
|
||||
@media (max-width: 1024px) {
|
||||
@include tablet {
|
||||
.upgrade-row__main {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@media (max-width: 768px) {
|
||||
@include mobile {
|
||||
.upgrade-row__main {
|
||||
flex-wrap: wrap;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
|
||||
+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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,346 +18,48 @@
|
||||
} @else if (!loader.loading() && account()) {
|
||||
<div class="settings-form">
|
||||
<!-- Change Password -->
|
||||
<app-card header="Change Password">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Password login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
[value]="currentPassword()"
|
||||
(valueChange)="currentPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
[value]="newPassword()"
|
||||
(valueChange)="newPassword.set($event)"
|
||||
/>
|
||||
|
||||
@if (newPassword()) {
|
||||
<div class="password-strength">
|
||||
<div class="password-strength__bar">
|
||||
<div class="password-strength__fill password-strength__fill--{{ newPasswordStrength() }}"></div>
|
||||
</div>
|
||||
<span class="password-strength__label password-strength__label--{{ newPasswordStrength() }}">{{ newPasswordStrength() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
[value]="confirmPassword()"
|
||||
(valueChange)="confirmPassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword() && !oidcExclusiveMode()"
|
||||
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword() || oidcExclusiveMode()"
|
||||
(clicked)="changePassword()"
|
||||
>
|
||||
@if (changingPassword()) {
|
||||
<app-spinner size="sm" /> Changing...
|
||||
} @else {
|
||||
Change Password
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
<app-change-password-card [oidcExclusiveMode]="oidcExclusiveMode()" />
|
||||
|
||||
<!-- Two-Factor Authentication -->
|
||||
<app-card header="Two-Factor Authentication">
|
||||
<div class="form-stack">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Status</span>
|
||||
@if (account()!.twoFactorEnabled) {
|
||||
<span class="status-value status-value--active">Active</span>
|
||||
} @else {
|
||||
<span class="status-value status-value--inactive">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (account()!.twoFactorEnabled) {
|
||||
<!-- 2FA Enabled: Regenerate or Disable -->
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">New Authenticator Setup</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">New Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
[value]="twoFaPassword()"
|
||||
(valueChange)="twoFaPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Authenticator Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
[value]="twoFaCode()"
|
||||
(valueChange)="twoFaCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa()"
|
||||
(clicked)="confirmRegenerate2fa()"
|
||||
>
|
||||
@if (regenerating2fa()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || disabling2fa()"
|
||||
(clicked)="confirmDisable2fa()"
|
||||
>
|
||||
@if (disabling2fa()) {
|
||||
<app-spinner size="sm" /> Disabling...
|
||||
} @else {
|
||||
Disable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- 2FA Disabled: Enable flow -->
|
||||
@if (enableSetup()) {
|
||||
<!-- QR code + verify flow -->
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">Set Up Authenticator</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-divider"></div>
|
||||
<app-input
|
||||
label="Verification Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code from your app"
|
||||
[value]="enableVerificationCode()"
|
||||
(valueChange)="enableVerificationCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="enableVerificationCode().length !== 6 || enabling2fa()"
|
||||
(clicked)="verifyEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Verifying...
|
||||
} @else {
|
||||
Verify & Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" (clicked)="cancelEnable2fa()">Cancel</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">Two-factor authentication adds an extra layer of security to your account.</p>
|
||||
<app-input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password to enable 2FA"
|
||||
[value]="enablePassword()"
|
||||
(valueChange)="enablePassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!enablePassword() || enabling2fa()"
|
||||
(clicked)="startEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Setting up...
|
||||
} @else {
|
||||
Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
<app-two-factor-card [enabled]="account()!.twoFactorEnabled" (changed)="onTwoFactorChanged()" />
|
||||
|
||||
<!-- API Key -->
|
||||
<app-card header="API Key">
|
||||
<div class="form-stack">
|
||||
<p class="section-hint">
|
||||
Use this API key to access the Cleanuparr API without authentication.
|
||||
Include it as the <code>X-Api-Key</code> header or <code>?apikey=</code> query parameter.
|
||||
</p>
|
||||
|
||||
<div class="api-key-row">
|
||||
<div class="api-key-display">
|
||||
@if (apiKeyRevealed()) {
|
||||
<code class="api-key-value">{{ apiKey() }}</code>
|
||||
} @else {
|
||||
<code class="api-key-value api-key-value--masked">{{ account()!.apiKeyPreview }}</code>
|
||||
}
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="revealApiKey()">
|
||||
{{ apiKeyRevealed() ? 'Hide' : 'Reveal' }}
|
||||
</app-button>
|
||||
@if (apiKeyRevealed()) {
|
||||
<app-button variant="ghost" size="sm" (clicked)="copyApiKey()">Copy</app-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="regeneratingApiKey()"
|
||||
(clicked)="confirmRegenerateApiKey()"
|
||||
>
|
||||
@if (regeneratingApiKey()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate API Key
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
<app-api-key-card [apiKeyPreview]="account()!.apiKeyPreview" />
|
||||
|
||||
<!-- Plex Integration -->
|
||||
<app-card header="Plex Integration">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Plex login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
@if (account()!.plexLinked) {
|
||||
<div class="status-row">
|
||||
<span class="status-label">Linked Account</span>
|
||||
<span class="status-value">{{ account()!.plexUsername }}</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="plexUnlinking() || oidcExclusiveMode()"
|
||||
(clicked)="confirmUnlinkPlex()"
|
||||
>
|
||||
@if (plexUnlinking()) {
|
||||
<app-spinner size="sm" /> Unlinking...
|
||||
} @else {
|
||||
Unlink Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="section-hint">
|
||||
Link your Plex account to enable signing in with Plex as an alternative to username and password.
|
||||
</p>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="plexLinking() || oidcExclusiveMode()"
|
||||
(clicked)="startPlexLink()"
|
||||
>
|
||||
@if (plexLinking()) {
|
||||
<app-spinner size="sm" /> Waiting for Plex...
|
||||
} @else {
|
||||
Link Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
<app-plex-integration-card
|
||||
[linked]="account()!.plexLinked"
|
||||
[username]="account()!.plexUsername ?? ''"
|
||||
[oidcExclusiveMode]="oidcExclusiveMode()"
|
||||
(changed)="onPlexChanged()"
|
||||
/>
|
||||
|
||||
<!-- 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 +92,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 +100,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>
|
||||
|
||||
@@ -8,65 +8,8 @@
|
||||
.form-divider { @include form-divider; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-notice {
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: var(--color-warning);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.section-notice { @include section-notice; }
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
code {
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
|
||||
&--active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
// QR code (2FA regeneration)
|
||||
.qr-section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
qrcode {
|
||||
background: #ffffff;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-entry {
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
@@ -1,111 +1,131 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } 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,
|
||||
CardComponent, ButtonComponent, InputComponent,
|
||||
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';
|
||||
import { ApiKeyCardComponent } from './api-key-card.component';
|
||||
import { ChangePasswordCardComponent } from './change-password-card.component';
|
||||
import { PlexIntegrationCardComponent } from './plex-integration-card.component';
|
||||
import { TwoFactorCardComponent } from './two-factor-card.component';
|
||||
|
||||
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,
|
||||
ToggleComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, LabelComponent, FormField,
|
||||
ApiKeyCardComponent, ChangePasswordCardComponent, PlexIntegrationCardComponent, TwoFactorCardComponent,
|
||||
],
|
||||
templateUrl: './account-settings.component.html',
|
||||
styleUrl: './account-settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
export class AccountSettingsComponent implements OnInit {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = signal(false);
|
||||
readonly account = signal<AccountInfo | null>(null);
|
||||
|
||||
// Change password
|
||||
readonly currentPassword = signal('');
|
||||
readonly newPassword = signal('');
|
||||
readonly confirmPassword = signal('');
|
||||
readonly changingPassword = signal(false);
|
||||
|
||||
// Password strength
|
||||
readonly newPasswordStrength = computed(() => {
|
||||
const pw = this.newPassword();
|
||||
if (!pw) return null;
|
||||
if (pw.length < 8) return 'weak';
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasNumber = /[0-9]/.test(pw);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw);
|
||||
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
if (pw.length >= 12 && score >= 3) return 'strong';
|
||||
if (pw.length >= 8 && score >= 2) return 'medium';
|
||||
return 'weak';
|
||||
private readonly accountResource = rxResource({
|
||||
stream: () => forkJoin([this.api.getInfo(), this.api.getOidcConfig()]),
|
||||
});
|
||||
|
||||
// 2FA regeneration
|
||||
readonly twoFaPassword = signal('');
|
||||
readonly twoFaCode = signal('');
|
||||
readonly regenerating2fa = signal(false);
|
||||
readonly newRecoveryCodes = signal<string[]>([]);
|
||||
readonly newQrCodeUri = signal('');
|
||||
readonly newTotpSecret = signal('');
|
||||
|
||||
// 2FA enable
|
||||
readonly enablePassword = signal('');
|
||||
readonly enableVerificationCode = signal('');
|
||||
readonly enabling2fa = signal(false);
|
||||
readonly enableSetup = signal(false);
|
||||
|
||||
// 2FA disable
|
||||
readonly disabling2fa = signal(false);
|
||||
|
||||
// API key
|
||||
readonly apiKey = signal('');
|
||||
readonly apiKeyRevealed = signal(false);
|
||||
readonly regeneratingApiKey = signal(false);
|
||||
|
||||
// Plex
|
||||
readonly plexLinking = signal(false);
|
||||
readonly plexUnlinking = signal(false);
|
||||
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = computed(() => !!this.accountResource.error());
|
||||
readonly account = computed(() => this.accountResource.hasValue() ? this.accountResource.value()[0] : 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,302 +139,25 @@ export class AccountSettingsComponent implements OnInit, OnDestroy {
|
||||
this.toast.error('Failed to link OIDC account');
|
||||
this.oidcExpanded.set(true);
|
||||
}
|
||||
this.loadAccount();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.plexPollTimer) {
|
||||
clearInterval(this.plexPollTimer);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
changePassword(): void {
|
||||
if (this.newPassword() !== this.confirmPassword()) {
|
||||
this.toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (this.newPassword().length < 8) {
|
||||
this.toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword.set(true);
|
||||
this.api.changePassword({
|
||||
currentPassword: this.currentPassword(),
|
||||
newPassword: this.newPassword(),
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Password changed successfully');
|
||||
this.currentPassword.set('');
|
||||
this.newPassword.set('');
|
||||
this.confirmPassword.set('');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to change password');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2FA regeneration
|
||||
async confirmRegenerate2fa(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate 2FA',
|
||||
message: 'This will invalidate your current authenticator setup and all existing recovery codes. You will need to set up your authenticator app again.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regenerating2fa.set(true);
|
||||
this.api.regenerate2fa({
|
||||
password: this.twoFaPassword(),
|
||||
totpCode: this.twoFaCode(),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.newRecoveryCodes.set(result.recoveryCodes);
|
||||
this.newQrCodeUri.set(result.qrCodeUri);
|
||||
this.newTotpSecret.set(result.secret);
|
||||
this.toast.success('2FA regenerated. Scan the QR code and save your recovery codes!');
|
||||
this.twoFaPassword.set('');
|
||||
this.twoFaCode.set('');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate 2FA. Check your password and code.');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
copyRecoveryCodes(): void {
|
||||
const codes = this.newRecoveryCodes().join('\n');
|
||||
navigator.clipboard.writeText(codes);
|
||||
this.toast.success('Recovery codes copied to clipboard');
|
||||
}
|
||||
|
||||
dismissRecoveryCodes(): void {
|
||||
this.newRecoveryCodes.set([]);
|
||||
this.newQrCodeUri.set('');
|
||||
this.newTotpSecret.set('');
|
||||
}
|
||||
|
||||
// 2FA enable flow
|
||||
startEnable2fa(): void {
|
||||
this.enabling2fa.set(true);
|
||||
this.api.enable2fa(this.enablePassword()).subscribe({
|
||||
next: (result) => {
|
||||
this.newQrCodeUri.set(result.qrCodeUri);
|
||||
this.newTotpSecret.set(result.secret);
|
||||
this.newRecoveryCodes.set(result.recoveryCodes);
|
||||
this.enableSetup.set(true);
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to start 2FA setup. Check your password.');
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
verifyEnable2fa(): void {
|
||||
this.enabling2fa.set(true);
|
||||
this.api.verifyEnable2fa(this.enableVerificationCode()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Two-factor authentication enabled');
|
||||
this.cancelEnable2fa();
|
||||
this.enabling2fa.set(false);
|
||||
this.loadAccount();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Invalid verification code');
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancelEnable2fa(): void {
|
||||
this.enableSetup.set(false);
|
||||
this.enablePassword.set('');
|
||||
this.enableVerificationCode.set('');
|
||||
this.newRecoveryCodes.set([]);
|
||||
this.newQrCodeUri.set('');
|
||||
this.newTotpSecret.set('');
|
||||
}
|
||||
|
||||
// 2FA disable flow
|
||||
async confirmDisable2fa(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Disable 2FA',
|
||||
message: 'This will remove two-factor authentication from your account. Your recovery codes will be deleted.',
|
||||
confirmLabel: 'Disable',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.disabling2fa.set(true);
|
||||
this.api.disable2fa(this.twoFaPassword(), this.twoFaCode()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Two-factor authentication disabled');
|
||||
this.twoFaPassword.set('');
|
||||
this.twoFaCode.set('');
|
||||
this.disabling2fa.set(false);
|
||||
this.loadAccount();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to disable 2FA. Check your password and code.');
|
||||
this.disabling2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// API key
|
||||
revealApiKey(): void {
|
||||
if (this.apiKeyRevealed()) {
|
||||
this.apiKeyRevealed.set(false);
|
||||
this.apiKey.set('');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.getApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
},
|
||||
error: () => this.toast.error('Failed to load API key'),
|
||||
});
|
||||
}
|
||||
|
||||
copyApiKey(): void {
|
||||
navigator.clipboard.writeText(this.apiKey());
|
||||
this.toast.success('API key copied to clipboard');
|
||||
}
|
||||
|
||||
async confirmRegenerateApiKey(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate API Key',
|
||||
message: 'This will invalidate the current API key. Any integrations using this key will stop working.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regeneratingApiKey.set(true);
|
||||
this.api.regenerateApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
this.toast.success('API key regenerated');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate API key');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
});
|
||||
onTwoFactorChanged(): void {
|
||||
this.accountResource.reload();
|
||||
}
|
||||
|
||||
// Plex
|
||||
startPlexLink(): void {
|
||||
this.plexLinking.set(true);
|
||||
this.api.linkPlex().subscribe({
|
||||
next: (result) => {
|
||||
window.open(result.authUrl, '_blank');
|
||||
this.pollPlexLink(result.pinId);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to start Plex linking');
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexLink(pinId: number): void {
|
||||
let attempts = 0;
|
||||
this.plexPollTimer = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 60) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.verifyPlexLink(pinId).subscribe({
|
||||
next: (result) => {
|
||||
if (result.completed) {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.success('Plex account linked');
|
||||
this.loadAccount();
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearInterval(this.plexPollTimer!);
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking failed');
|
||||
},
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async confirmUnlinkPlex(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Unlink Plex',
|
||||
message: 'This will remove your linked Plex account. You will no longer be able to log in with Plex.',
|
||||
confirmLabel: 'Unlink',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.plexUnlinking.set(true);
|
||||
this.api.unlinkPlex().subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Plex account unlinked');
|
||||
this.plexUnlinking.set(false);
|
||||
this.loadAccount();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to unlink Plex account');
|
||||
this.plexUnlinking.set(false);
|
||||
},
|
||||
});
|
||||
onPlexChanged(): void {
|
||||
this.accountResource.reload();
|
||||
}
|
||||
|
||||
// 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 +177,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 +225,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);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<app-card header="API Key">
|
||||
<div class="form-stack">
|
||||
<p class="section-hint">
|
||||
Use this API key to access the Cleanuparr API without authentication.
|
||||
Include it as the <code>X-Api-Key</code> header or <code>?apikey=</code> query parameter.
|
||||
</p>
|
||||
|
||||
<div class="api-key-row">
|
||||
<div class="api-key-display">
|
||||
@if (apiKeyRevealed()) {
|
||||
<code class="api-key-value">{{ apiKey() }}</code>
|
||||
} @else {
|
||||
<code class="api-key-value api-key-value--masked">{{ apiKeyPreview() }}</code>
|
||||
}
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="revealApiKey()">
|
||||
{{ apiKeyRevealed() ? 'Hide' : 'Reveal' }}
|
||||
</app-button>
|
||||
@if (apiKeyRevealed()) {
|
||||
<app-button variant="ghost" size="sm" (clicked)="copyApiKey()">Copy</app-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="regeneratingApiKey()"
|
||||
(clicked)="confirmRegenerateApiKey()"
|
||||
>
|
||||
@if (regeneratingApiKey()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate API Key
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -0,0 +1,50 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.form-stack { @include form-stack; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
code {
|
||||
background: var(--surface-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.api-key-display {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.api-key-value {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
|
||||
&--masked {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.api-key-actions {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, signal } from '@angular/core';
|
||||
import { CardComponent, ButtonComponent, SpinnerComponent } from '@ui';
|
||||
import { AccountApi } from '@core/api/account.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-api-key-card',
|
||||
standalone: true,
|
||||
imports: [CardComponent, ButtonComponent, SpinnerComponent],
|
||||
templateUrl: './api-key-card.component.html',
|
||||
styleUrl: './api-key-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ApiKeyCardComponent {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
readonly apiKeyPreview = input('');
|
||||
|
||||
readonly apiKey = signal('');
|
||||
readonly apiKeyRevealed = signal(false);
|
||||
readonly regeneratingApiKey = signal(false);
|
||||
|
||||
revealApiKey(): void {
|
||||
if (this.apiKeyRevealed()) {
|
||||
this.apiKeyRevealed.set(false);
|
||||
this.apiKey.set('');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.getApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
},
|
||||
error: () => this.toast.error('Failed to load API key'),
|
||||
});
|
||||
}
|
||||
|
||||
copyApiKey(): void {
|
||||
navigator.clipboard.writeText(this.apiKey()).then(
|
||||
() => this.toast.success('API key copied to clipboard'),
|
||||
() => this.toast.error('Failed to copy API key'),
|
||||
);
|
||||
}
|
||||
|
||||
async confirmRegenerateApiKey(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate API Key',
|
||||
message: 'This will invalidate the current API key. Any integrations using this key will stop working.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regeneratingApiKey.set(true);
|
||||
this.api.regenerateApiKey().subscribe({
|
||||
next: (result) => {
|
||||
this.apiKey.set(result.apiKey);
|
||||
this.apiKeyRevealed.set(true);
|
||||
this.toast.success('API key regenerated');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate API key');
|
||||
this.regeneratingApiKey.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<app-card header="Change Password">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Password login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter current password"
|
||||
[value]="currentPassword()"
|
||||
(valueChange)="currentPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="New Password"
|
||||
type="password"
|
||||
placeholder="Enter new password (min 8 characters)"
|
||||
[value]="newPassword()"
|
||||
(valueChange)="newPassword.set($event)"
|
||||
/>
|
||||
|
||||
@if (newPassword()) {
|
||||
<div class="password-strength">
|
||||
<div class="password-strength__bar">
|
||||
<div class="password-strength__fill password-strength__fill--{{ newPasswordStrength() }}"></div>
|
||||
</div>
|
||||
<span class="password-strength__label password-strength__label--{{ newPasswordStrength() }}">{{ newPasswordStrength() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
[value]="confirmPassword()"
|
||||
(valueChange)="confirmPassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[glowing]="!!currentPassword() && !!newPassword() && !!confirmPassword() && !oidcExclusiveMode()"
|
||||
[disabled]="!currentPassword() || !newPassword() || !confirmPassword() || changingPassword() || oidcExclusiveMode()"
|
||||
(clicked)="changePassword()"
|
||||
>
|
||||
@if (changingPassword()) {
|
||||
<app-spinner size="sm" /> Changing...
|
||||
} @else {
|
||||
Change Password
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -0,0 +1,42 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.form-stack { @include form-stack; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-notice { @include section-notice; }
|
||||
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--duration-normal) var(--ease-default),
|
||||
background-color var(--duration-normal) var(--ease-default);
|
||||
|
||||
&--weak { width: 33%; background-color: var(--color-error); }
|
||||
&--medium { width: 66%; background-color: var(--color-warning); }
|
||||
&--strong { width: 100%; background-color: var(--color-success); }
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: capitalize;
|
||||
min-width: 52px;
|
||||
|
||||
&--weak { color: var(--color-error); }
|
||||
&--medium { color: var(--color-warning); }
|
||||
&--strong { color: var(--color-success); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, signal, computed } from '@angular/core';
|
||||
import { CardComponent, InputComponent, ButtonComponent, SpinnerComponent } from '@ui';
|
||||
import { AccountApi } from '@core/api/account.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password-card',
|
||||
standalone: true,
|
||||
imports: [CardComponent, InputComponent, ButtonComponent, SpinnerComponent],
|
||||
templateUrl: './change-password-card.component.html',
|
||||
styleUrl: './change-password-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChangePasswordCardComponent {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly oidcExclusiveMode = input(false);
|
||||
|
||||
readonly currentPassword = signal('');
|
||||
readonly newPassword = signal('');
|
||||
readonly confirmPassword = signal('');
|
||||
readonly changingPassword = signal(false);
|
||||
|
||||
readonly newPasswordStrength = computed(() => {
|
||||
const pw = this.newPassword();
|
||||
if (!pw) return null;
|
||||
if (pw.length < 8) return 'weak';
|
||||
const hasUpper = /[A-Z]/.test(pw);
|
||||
const hasLower = /[a-z]/.test(pw);
|
||||
const hasNumber = /[0-9]/.test(pw);
|
||||
const hasSpecial = /[^A-Za-z0-9]/.test(pw);
|
||||
const score = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||
if (pw.length >= 12 && score >= 3) return 'strong';
|
||||
if (pw.length >= 8 && score >= 2) return 'medium';
|
||||
return 'weak';
|
||||
});
|
||||
|
||||
changePassword(): void {
|
||||
if (this.newPassword() !== this.confirmPassword()) {
|
||||
this.toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (this.newPassword().length < 8) {
|
||||
this.toast.error('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
this.changingPassword.set(true);
|
||||
this.api.changePassword({
|
||||
currentPassword: this.currentPassword(),
|
||||
newPassword: this.newPassword(),
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Password changed successfully');
|
||||
this.currentPassword.set('');
|
||||
this.newPassword.set('');
|
||||
this.confirmPassword.set('');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to change password');
|
||||
this.changingPassword.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<app-card header="Plex Integration">
|
||||
<div class="form-stack">
|
||||
@if (oidcExclusiveMode()) {
|
||||
<div class="section-notice">
|
||||
Plex login is disabled while OIDC exclusive mode is active.
|
||||
</div>
|
||||
}
|
||||
@if (linked()) {
|
||||
<div class="status-row">
|
||||
<span class="status-label">Linked Account</span>
|
||||
<span class="status-value">{{ username() }}</span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="plexUnlinking() || oidcExclusiveMode()"
|
||||
(clicked)="confirmUnlinkPlex()"
|
||||
>
|
||||
@if (plexUnlinking()) {
|
||||
<app-spinner size="sm" /> Unlinking...
|
||||
} @else {
|
||||
Unlink Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="section-hint">
|
||||
Link your Plex account to enable signing in with Plex as an alternative to username and password.
|
||||
</p>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="plexLinking() || oidcExclusiveMode()"
|
||||
(clicked)="startPlexLink()"
|
||||
>
|
||||
@if (plexLinking()) {
|
||||
<app-spinner size="sm" /> Waiting for Plex...
|
||||
} @else {
|
||||
Link Plex Account
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -0,0 +1,11 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.form-stack { @include form-stack; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-notice { @include section-notice; }
|
||||
|
||||
.section-hint { @include section-hint; }
|
||||
.status-row { @include status-row; }
|
||||
.status-label { @include status-label; }
|
||||
.status-value { @include status-value; }
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, output, signal, DestroyRef } from '@angular/core';
|
||||
import { CardComponent, ButtonComponent, SpinnerComponent } from '@ui';
|
||||
import { AccountApi } from '@core/api/account.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { pollPlexPin } from '@shared/utils/plex-pin-poller';
|
||||
|
||||
@Component({
|
||||
selector: 'app-plex-integration-card',
|
||||
standalone: true,
|
||||
imports: [CardComponent, ButtonComponent, SpinnerComponent],
|
||||
templateUrl: './plex-integration-card.component.html',
|
||||
styleUrl: './plex-integration-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PlexIntegrationCardComponent {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly linked = input(false);
|
||||
readonly username = input('');
|
||||
readonly oidcExclusiveMode = input(false);
|
||||
|
||||
/** Emitted after a successful link/unlink so the parent can reload account info. */
|
||||
readonly changed = output<void>();
|
||||
|
||||
readonly plexLinking = signal(false);
|
||||
readonly plexUnlinking = signal(false);
|
||||
|
||||
startPlexLink(): void {
|
||||
// Open the popup synchronously on the click so popup blockers allow it, then
|
||||
// point it at the auth URL once the PIN request resolves.
|
||||
const authWindow = window.open('', '_blank');
|
||||
this.plexLinking.set(true);
|
||||
this.api.linkPlex().subscribe({
|
||||
next: (result) => {
|
||||
if (authWindow) {
|
||||
authWindow.location.href = result.authUrl;
|
||||
} else {
|
||||
window.open(result.authUrl, '_blank');
|
||||
}
|
||||
this.pollPlexLink(result.pinId);
|
||||
},
|
||||
error: () => {
|
||||
authWindow?.close();
|
||||
this.toast.error('Failed to start Plex linking');
|
||||
this.plexLinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private pollPlexLink(pinId: number): void {
|
||||
pollPlexPin({
|
||||
verify: () => this.api.verifyPlexLink(pinId),
|
||||
onCompleted: () => {
|
||||
this.plexLinking.set(false);
|
||||
this.toast.success('Plex account linked');
|
||||
this.changed.emit();
|
||||
},
|
||||
onError: () => {
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking failed');
|
||||
},
|
||||
onTimeout: () => {
|
||||
this.plexLinking.set(false);
|
||||
this.toast.error('Plex linking timed out');
|
||||
},
|
||||
destroyRef: this.destroyRef,
|
||||
});
|
||||
}
|
||||
|
||||
async confirmUnlinkPlex(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Unlink Plex',
|
||||
message: 'This will remove your linked Plex account. You will no longer be able to log in with Plex.',
|
||||
confirmLabel: 'Unlink',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.plexUnlinking.set(true);
|
||||
this.api.unlinkPlex().subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Plex account unlinked');
|
||||
this.plexUnlinking.set(false);
|
||||
this.changed.emit();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to unlink Plex account');
|
||||
this.plexUnlinking.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<app-card header="Two-Factor Authentication">
|
||||
<div class="form-stack">
|
||||
<div class="status-row">
|
||||
<span class="status-label">Status</span>
|
||||
@if (enabled()) {
|
||||
<span class="status-value status-value--active">Active</span>
|
||||
} @else {
|
||||
<span class="status-value status-value--inactive">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (enabled()) {
|
||||
<!-- 2FA Enabled: Regenerate or Disable -->
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">New Authenticator Setup</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app to complete the setup.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">New Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="dismissRecoveryCodes()">Dismiss</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">To regenerate your 2FA, enter your current password and a valid authenticator code.</p>
|
||||
<app-input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
[value]="twoFaPassword()"
|
||||
(valueChange)="twoFaPassword.set($event)"
|
||||
/>
|
||||
<app-input
|
||||
label="Authenticator Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code"
|
||||
[value]="twoFaCode()"
|
||||
(valueChange)="twoFaCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || regenerating2fa() || disabling2fa()"
|
||||
(clicked)="confirmRegenerate2fa()"
|
||||
>
|
||||
@if (regenerating2fa()) {
|
||||
<app-spinner size="sm" /> Regenerating...
|
||||
} @else {
|
||||
Regenerate 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="destructive"
|
||||
[disabled]="!twoFaPassword() || twoFaCode().length !== 6 || disabling2fa() || regenerating2fa()"
|
||||
(clicked)="confirmDisable2fa()"
|
||||
>
|
||||
@if (disabling2fa()) {
|
||||
<app-spinner size="sm" /> Disabling...
|
||||
} @else {
|
||||
Disable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- 2FA Disabled: Enable flow -->
|
||||
@if (enableSetup()) {
|
||||
<!-- QR code + verify flow -->
|
||||
<div class="recovery-section">
|
||||
<p class="recovery-title">Set Up Authenticator</p>
|
||||
<p class="recovery-desc">Scan this QR code with your authenticator app.</p>
|
||||
<div class="qr-section">
|
||||
<div class="qr-code-wrapper">
|
||||
<qrcode [qrdata]="newQrCodeUri()" [width]="200" errorCorrectionLevel="M" [margin]="2" />
|
||||
</div>
|
||||
<details class="qr-manual-entry">
|
||||
<summary>Can't scan? Enter manually</summary>
|
||||
<div class="qr-manual-content">
|
||||
<p class="qr-manual-label">Secret key:</p>
|
||||
<code class="qr-secret">{{ newTotpSecret() }}</code>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@if (newRecoveryCodes().length > 0) {
|
||||
<div class="form-divider"></div>
|
||||
<p class="recovery-title">Recovery Codes</p>
|
||||
<p class="recovery-desc">Save these codes in a secure location. Each code can only be used once.</p>
|
||||
<div class="recovery-codes">
|
||||
@for (code of newRecoveryCodes(); track code) {
|
||||
<div class="recovery-code">{{ code }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="recovery-actions">
|
||||
<app-button variant="secondary" size="sm" (clicked)="copyRecoveryCodes()">Copy Codes</app-button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-divider"></div>
|
||||
<app-input
|
||||
label="Verification Code"
|
||||
type="text"
|
||||
placeholder="Enter 6-digit code from your app"
|
||||
[value]="enableVerificationCode()"
|
||||
(valueChange)="enableVerificationCode.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="enableVerificationCode().length !== 6 || enabling2fa()"
|
||||
(clicked)="verifyEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Verifying...
|
||||
} @else {
|
||||
Verify & Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" (clicked)="cancelEnable2fa()">Cancel</app-button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="form-divider"></div>
|
||||
<p class="section-hint">Two-factor authentication adds an extra layer of security to your account.</p>
|
||||
<app-input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password to enable 2FA"
|
||||
[value]="enablePassword()"
|
||||
(valueChange)="enablePassword.set($event)"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!enablePassword() || enabling2fa()"
|
||||
(clicked)="startEnable2fa()"
|
||||
>
|
||||
@if (enabling2fa()) {
|
||||
<app-spinner size="sm" /> Setting up...
|
||||
} @else {
|
||||
Enable 2FA
|
||||
}
|
||||
</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -0,0 +1,107 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.form-stack { @include form-stack; }
|
||||
.form-divider { @include form-divider; }
|
||||
.form-actions { @include form-actions; }
|
||||
|
||||
.section-hint { @include section-hint; }
|
||||
.status-row { @include status-row; }
|
||||
.status-label { @include status-label; }
|
||||
|
||||
.status-value {
|
||||
@include status-value;
|
||||
|
||||
&--active {
|
||||
color: var(--color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
qrcode {
|
||||
background: var(--surface-always-light);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-entry {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.qr-manual-content {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-manual-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.qr-secret {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recovery-section {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recovery-title {
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recovery-desc {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-codes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.recovery-code {
|
||||
font-family: monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recovery-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input, output, signal } from '@angular/core';
|
||||
import { CardComponent, InputComponent, ButtonComponent, SpinnerComponent } from '@ui';
|
||||
import { QRCodeComponent } from 'angularx-qrcode';
|
||||
import { AccountApi } from '@core/api/account.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-two-factor-card',
|
||||
standalone: true,
|
||||
imports: [CardComponent, InputComponent, ButtonComponent, SpinnerComponent, QRCodeComponent],
|
||||
templateUrl: './two-factor-card.component.html',
|
||||
styleUrl: './two-factor-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TwoFactorCardComponent {
|
||||
private readonly api = inject(AccountApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
readonly enabled = input(false);
|
||||
|
||||
/** Emitted after 2FA is enabled or disabled so the parent can reload account info. */
|
||||
readonly changed = output<void>();
|
||||
|
||||
// Regeneration + shared setup state
|
||||
readonly twoFaPassword = signal('');
|
||||
readonly twoFaCode = signal('');
|
||||
readonly regenerating2fa = signal(false);
|
||||
readonly newRecoveryCodes = signal<string[]>([]);
|
||||
readonly newQrCodeUri = signal('');
|
||||
readonly newTotpSecret = signal('');
|
||||
|
||||
// Enable flow
|
||||
readonly enablePassword = signal('');
|
||||
readonly enableVerificationCode = signal('');
|
||||
readonly enabling2fa = signal(false);
|
||||
readonly enableSetup = signal(false);
|
||||
|
||||
// Disable flow
|
||||
readonly disabling2fa = signal(false);
|
||||
|
||||
async confirmRegenerate2fa(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Regenerate 2FA',
|
||||
message: 'This will invalidate your current authenticator setup and all existing recovery codes. You will need to set up your authenticator app again.',
|
||||
confirmLabel: 'Regenerate',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.regenerating2fa.set(true);
|
||||
this.api.regenerate2fa({
|
||||
password: this.twoFaPassword(),
|
||||
totpCode: this.twoFaCode(),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
this.newRecoveryCodes.set(result.recoveryCodes);
|
||||
this.newQrCodeUri.set(result.qrCodeUri);
|
||||
this.newTotpSecret.set(result.secret);
|
||||
this.toast.success('2FA regenerated. Scan the QR code and save your recovery codes!');
|
||||
this.twoFaPassword.set('');
|
||||
this.twoFaCode.set('');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to regenerate 2FA. Check your password and code.');
|
||||
this.regenerating2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
copyRecoveryCodes(): void {
|
||||
const codes = this.newRecoveryCodes().join('\n');
|
||||
navigator.clipboard.writeText(codes).then(
|
||||
() => this.toast.success('Recovery codes copied to clipboard'),
|
||||
() => this.toast.error('Failed to copy recovery codes'),
|
||||
);
|
||||
}
|
||||
|
||||
dismissRecoveryCodes(): void {
|
||||
this.newRecoveryCodes.set([]);
|
||||
this.newQrCodeUri.set('');
|
||||
this.newTotpSecret.set('');
|
||||
}
|
||||
|
||||
startEnable2fa(): void {
|
||||
this.enabling2fa.set(true);
|
||||
this.api.enable2fa(this.enablePassword()).subscribe({
|
||||
next: (result) => {
|
||||
this.newQrCodeUri.set(result.qrCodeUri);
|
||||
this.newTotpSecret.set(result.secret);
|
||||
this.newRecoveryCodes.set(result.recoveryCodes);
|
||||
this.enableSetup.set(true);
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to start 2FA setup. Check your password.');
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
verifyEnable2fa(): void {
|
||||
this.enabling2fa.set(true);
|
||||
this.api.verifyEnable2fa(this.enableVerificationCode()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Two-factor authentication enabled');
|
||||
this.cancelEnable2fa();
|
||||
this.enabling2fa.set(false);
|
||||
this.changed.emit();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Invalid verification code');
|
||||
this.enabling2fa.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
cancelEnable2fa(): void {
|
||||
this.enableSetup.set(false);
|
||||
this.enablePassword.set('');
|
||||
this.enableVerificationCode.set('');
|
||||
this.newRecoveryCodes.set([]);
|
||||
this.newQrCodeUri.set('');
|
||||
this.newTotpSecret.set('');
|
||||
}
|
||||
|
||||
async confirmDisable2fa(): Promise<void> {
|
||||
const confirmed = await this.confirmService.confirm({
|
||||
title: 'Disable 2FA',
|
||||
message: 'This will remove two-factor authentication from your account. Your recovery codes will be deleted.',
|
||||
confirmLabel: 'Disable',
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
this.disabling2fa.set(true);
|
||||
this.api.disable2fa(this.twoFaPassword(), this.twoFaCode()).subscribe({
|
||||
next: () => {
|
||||
this.toast.success('Two-factor authentication disabled');
|
||||
this.twoFaPassword.set('');
|
||||
this.twoFaCode.set('');
|
||||
this.disabling2fa.set(false);
|
||||
this.changed.emit();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to disable 2FA. Check your password and code.');
|
||||
this.disabling2fa.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'),
|
||||
});
|
||||
|
||||
+5
-5
@@ -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"
|
||||
/>
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
</app-card>
|
||||
|
||||
<div class="form-actions">
|
||||
<app-button variant="primary" [glowing]="dirty()" [loading]="saving()" [disabled]="saving() || saved() || hasErrors()" (clicked)="save()">
|
||||
<app-button variant="primary" [glowing]="dirty()" [loading]="saving()" [disabled]="saving() || saved() || hasErrors() || !dirty()" (clicked)="save()">
|
||||
{{ saved() ? 'Saved!' : 'Save Settings' }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
+51
-37
@@ -1,76 +1,93 @@
|
||||
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';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
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);
|
||||
@@ -82,18 +99,15 @@ export class BlacklistSyncComponent implements OnInit, HasPendingChanges {
|
||||
setTimeout(() => this.saved.set(false), 1500);
|
||||
this.savedSnapshot.set(this.buildSnapshot());
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error('Failed to save blacklist sync settings');
|
||||
error: (err: ApiError) => {
|
||||
this.toast.error(err.statusCode === 400 ? err.message : 'Failed to save blacklist sync settings');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildSnapshot(): string {
|
||||
return JSON.stringify({
|
||||
enabled: this.enabled(),
|
||||
blacklistPath: this.blacklistPath(),
|
||||
});
|
||||
return JSON.stringify(this.model());
|
||||
}
|
||||
|
||||
readonly dirty = computed(() => {
|
||||
|
||||
+47
-112
@@ -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' }}
|
||||
@@ -328,60 +310,13 @@
|
||||
</div>
|
||||
|
||||
<!-- 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()"
|
||||
hint="A descriptive label for this rule"
|
||||
helpKey="download-cleaner:name" />
|
||||
<app-select label="Privacy Type" [options]="privacyTypeOptions" [(value)]="rulePrivacyType"
|
||||
hint="Which torrent types this rule applies to"
|
||||
helpKey="download-cleaner:privacyType" />
|
||||
<app-chip-input class="full-width" label="Categories" placeholder="Add category..."
|
||||
[(items)]="ruleCategories"
|
||||
[error]="ruleCategoriesError()"
|
||||
hint="One or more download client categories this rule applies to (e.g. tv-sonarr, radarr)"
|
||||
helpKey="download-cleaner:categories" #ruleChipInput />
|
||||
<app-chip-input class="full-width" label="Tracker Patterns" placeholder="Add tracker domain..."
|
||||
[(items)]="ruleTrackerPatterns"
|
||||
hint="Tracker domain suffixes to match (e.g. tracker.org). Empty means any tracker."
|
||||
helpKey="download-cleaner:trackerPatterns" #ruleChipInput />
|
||||
@if (isTagFilterableClient()) {
|
||||
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (Any)' : 'Tags (Any)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
|
||||
[(items)]="ruleTagsAny"
|
||||
[hint]="isSelectedClientTransmission() ? 'Torrent must have at least one of these labels. Empty means any labels.' : 'Torrent must have at least one of these tags. Empty means any tags.'"
|
||||
helpKey="download-cleaner:tagsAny" #ruleChipInput />
|
||||
<app-chip-input class="full-width" [label]="isSelectedClientTransmission() ? 'Labels (All)' : 'Tags (All)'" [placeholder]="isSelectedClientTransmission() ? 'Add label...' : 'Add tag...'"
|
||||
[(items)]="ruleTagsAll"
|
||||
[hint]="isSelectedClientTransmission() ? 'Torrent must have all of these labels. Empty means any labels.' : 'Torrent must have all of these tags. Empty means any tags.'"
|
||||
helpKey="download-cleaner:tagsAll" #ruleChipInput />
|
||||
}
|
||||
<app-number-input label="Max Ratio" [(value)]="ruleMaxRatio" [step]="0.1" [min]="-1"
|
||||
hint="Maximum ratio to seed before removing (-1 means disabled)"
|
||||
helpKey="download-cleaner:maxRatio" />
|
||||
<app-number-input label="Min Seed Time" [(value)]="ruleMinSeedTime" suffix="hours" [min]="0"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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()">
|
||||
{{ editingRule() ? 'Update' : 'Create' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
<app-seeding-rule-modal
|
||||
[rule]="editingRule()"
|
||||
[(visible)]="ruleModalVisible"
|
||||
[clientId]="selectedClientId()"
|
||||
[isTagFilterableClient]="isTagFilterableClient()"
|
||||
[isSelectedClientTransmission]="isSelectedClientTransmission()"
|
||||
[isSeedersFilterableClient]="isSeedersFilterableClient()"
|
||||
(saved)="onSeedingRuleSaved()"
|
||||
/>
|
||||
}
|
||||
+293
-372
@@ -1,11 +1,13 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, viewChildren, effect, untracked, linkedSignal } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { form, min, 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';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, BadgeComponent, SpinnerComponent,
|
||||
TooltipComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
@@ -19,10 +21,11 @@ import {
|
||||
createDefaultUnlinkedConfig, createDefaultDeadTorrentConfig, createDefaultOrphanedFilesConfig,
|
||||
} from '@shared/models/download-cleaner-config.model';
|
||||
import { ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
|
||||
import { ScheduleUnit, TorrentPrivacyType, DownloadClientTypeName } from '@shared/models/enums';
|
||||
import { ScheduleUnit, DownloadClientTypeName } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util';
|
||||
import { SeedingRuleModalComponent } from './seeding-rule-modal.component';
|
||||
|
||||
const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Seconds', value: ScheduleUnit.Seconds },
|
||||
@@ -30,11 +33,39 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Hours', value: ScheduleUnit.Hours },
|
||||
];
|
||||
|
||||
const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Public', value: TorrentPrivacyType.Public },
|
||||
{ label: 'Private', value: TorrentPrivacyType.Private },
|
||||
{ label: 'Both', value: TorrentPrivacyType.Both },
|
||||
];
|
||||
interface DownloadCleanerGlobalFormModel {
|
||||
enabled: boolean;
|
||||
useAdvancedScheduling: boolean;
|
||||
cronExpression: string;
|
||||
scheduleEvery: number;
|
||||
scheduleUnit: ScheduleUnit;
|
||||
ignoredDownloads: string[];
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -44,31 +75,31 @@ const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
|
||||
CdkDropList, CdkDrag, CdkDragHandle,
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, ModalComponent, BadgeComponent, SpinnerComponent,
|
||||
TooltipComponent,
|
||||
EmptyStateComponent, LoadingStateComponent, BadgeComponent, SpinnerComponent,
|
||||
TooltipComponent, FormField, SeedingRuleModalComponent,
|
||||
],
|
||||
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);
|
||||
private readonly chipInputs = viewChildren(ChipInputComponent);
|
||||
private readonly ruleChipInputs = viewChildren<ChipInputComponent>('ruleChipInput');
|
||||
|
||||
readonly ruleHasUncommittedInputs = computed(() =>
|
||||
this.ruleChipInputs().some(c => c.hasUncommittedInput())
|
||||
);
|
||||
private readonly seedingRuleModal = viewChild(SeedingRuleModalComponent);
|
||||
|
||||
private readonly savedSnapshot = signal('');
|
||||
private readonly orphanedFilesSnapshots = signal<Record<string, string>>({});
|
||||
|
||||
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 +113,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 +193,134 @@ 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);
|
||||
|
||||
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,111 +330,94 @@ 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 ---
|
||||
|
||||
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);
|
||||
} 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.ruleModalVisible.set(true);
|
||||
}
|
||||
|
||||
saveRule(): void {
|
||||
if (this.ruleNameError() || this.ruleCategoriesError() || this.ruleDisabledError() || this.ruleHasUncommittedInputs()) {
|
||||
return;
|
||||
}
|
||||
onSeedingRuleSaved(): void {
|
||||
const clientId = this.selectedClientId();
|
||||
if (!clientId) {
|
||||
return;
|
||||
if (clientId) {
|
||||
this.reloadSeedingRules(clientId);
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
const editing = this.editingRule();
|
||||
const request = editing?.id
|
||||
? this.api.updateSeedingRule(editing.id, dto)
|
||||
: this.api.createSeedingRule(clientId, dto);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Seeding rule updated' : 'Seeding rule created');
|
||||
this.ruleModalVisible.set(false);
|
||||
this.reloadSeedingRules(clientId);
|
||||
},
|
||||
error: (e: ApiError) => this.toast.error(e.statusCode === 400 ? e.message : 'Failed to save seeding rule'),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRule(rule: SeedingRule): Promise<void> {
|
||||
@@ -521,62 +493,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 +533,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 +565,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 +596,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 +603,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 +635,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(() => {
|
||||
@@ -724,6 +644,7 @@ export class DownloadCleanerComponent implements OnInit, HasPendingChanges {
|
||||
});
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.dirty() || this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty();
|
||||
return this.dirty() || this.unlinkedDirty() || this.deadTorrentDirty() || this.orphanedFilesDirty()
|
||||
|| !!this.seedingRuleModal()?.hasPendingChanges();
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<app-modal [title]="rule() ? 'Edit Seeding Rule' : 'Add Seeding Rule'" [(visible)]="visible" size="lg">
|
||||
<div class="form-grid">
|
||||
<app-input label="Rule Name" placeholder="My TV rule" [formField]="form.name"
|
||||
[error]="form.name().errors()[0]?.message"
|
||||
hint="A descriptive label for this rule"
|
||||
helpKey="download-cleaner:name" />
|
||||
<app-select label="Privacy Type" [options]="privacyTypeOptions" [formField]="form.privacyType"
|
||||
hint="Which torrent types this rule applies to"
|
||||
helpKey="download-cleaner:privacyType" />
|
||||
<app-chip-input class="full-width" label="Categories" placeholder="Add category..."
|
||||
[formField]="form.categories"
|
||||
[error]="form.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..."
|
||||
[formField]="form.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...'"
|
||||
[formField]="form.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...'"
|
||||
[formField]="form.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" [formField]="form.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" [formField]="form.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" [formField]="form.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" [formField]="form.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 (disabledError()) {
|
||||
<div class="category-error">{{ disabledError() }}</div>
|
||||
}
|
||||
<app-toggle class="full-width" label="Delete Source Files" [formField]="form.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)="visible.set(false)">Cancel</app-button>
|
||||
<app-button variant="primary" [loading]="saving()" [disabled]="form().invalid() || hasUncommittedInputs()" (clicked)="save()">
|
||||
{{ rule() ? 'Update' : 'Create' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.form-grid { @include form-grid; }
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.category-error {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-error);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, input, model, output, viewChildren } from '@angular/core';
|
||||
import { form, required, min, minLength, validate, FormField } from '@angular/forms/signals';
|
||||
import {
|
||||
ModalComponent, InputComponent, SelectComponent, ChipInputComponent,
|
||||
NumberInputComponent, ToggleComponent, ButtonComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { DownloadCleanerApi } from '@core/api/download-cleaner.api';
|
||||
import { ApiError } from '@core/interceptors/error.interceptor';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { SeedingRule } from '@shared/models/download-cleaner-config.model';
|
||||
import { TorrentPrivacyType } from '@shared/models/enums';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Public', value: TorrentPrivacyType.Public },
|
||||
{ label: 'Private', value: TorrentPrivacyType.Private },
|
||||
{ label: 'Both', value: TorrentPrivacyType.Both },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-seeding-rule-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ModalComponent, InputComponent, SelectComponent, ChipInputComponent,
|
||||
NumberInputComponent, ToggleComponent, ButtonComponent, FormField,
|
||||
],
|
||||
templateUrl: './seeding-rule-modal.component.html',
|
||||
styleUrl: './seeding-rule-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SeedingRuleModalComponent {
|
||||
private readonly api = inject(DownloadCleanerApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly rule = input<SeedingRule | null>(null);
|
||||
readonly visible = model(false);
|
||||
readonly clientId = input<string | null>(null);
|
||||
readonly isTagFilterableClient = input(false);
|
||||
readonly isSelectedClientTransmission = input(false);
|
||||
readonly isSeedersFilterableClient = input(false);
|
||||
readonly saved = output<void>();
|
||||
|
||||
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
|
||||
|
||||
private readonly ruleChipInputs = viewChildren<ChipInputComponent>('ruleChipInput');
|
||||
readonly hasUncommittedInputs = computed(() =>
|
||||
this.ruleChipInputs().some(c => c.hasUncommittedInput())
|
||||
);
|
||||
|
||||
readonly saving = signal(false);
|
||||
|
||||
/** JSON snapshot of the model as loaded when the modal opened, for dirty tracking. */
|
||||
private readonly openSnapshot = signal('');
|
||||
readonly hasPendingChanges = computed(() =>
|
||||
this.visible() && JSON.stringify(this.model()) !== this.openSnapshot());
|
||||
|
||||
private readonly defaults: SeedingRuleFormModel = {
|
||||
name: '', categories: [], trackerPatterns: [], tagsAny: [], tagsAll: [],
|
||||
privacyType: TorrentPrivacyType.Public, maxRatio: -1, minSeedTime: 0,
|
||||
maxSeedTime: -1, minSeeders: 0, deleteSourceFiles: true,
|
||||
};
|
||||
readonly model = signal<SeedingRuleFormModel>({ ...this.defaults });
|
||||
readonly form = form(this.model, (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.model();
|
||||
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 disabledError = computed(() =>
|
||||
this.form.maxSeedTime().errors().find(e => e.kind === 'disabled')?.message
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Populate the form from the input rule (or defaults) each time the modal opens.
|
||||
effect(() => {
|
||||
if (!this.visible()) {
|
||||
return;
|
||||
}
|
||||
const r = untracked(() => this.rule());
|
||||
untracked(() => {
|
||||
const next: SeedingRuleFormModel = r ? {
|
||||
name: r.name,
|
||||
categories: [...(r.categories ?? [])],
|
||||
trackerPatterns: [...(r.trackerPatterns ?? [])],
|
||||
tagsAny: [...(r.tagsAny ?? [])],
|
||||
tagsAll: [...(r.tagsAll ?? [])],
|
||||
privacyType: r.privacyType,
|
||||
maxRatio: r.maxRatio,
|
||||
minSeedTime: r.minSeedTime,
|
||||
maxSeedTime: r.maxSeedTime,
|
||||
minSeeders: r.minSeeders ?? 0,
|
||||
deleteSourceFiles: r.deleteSourceFiles,
|
||||
} : { ...this.defaults };
|
||||
this.model.set(next);
|
||||
this.openSnapshot.set(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form().invalid() || this.hasUncommittedInputs()) {
|
||||
return;
|
||||
}
|
||||
const clientId = this.clientId();
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const m = this.model();
|
||||
const sanitize = (list: string[]) => list.map(s => s.trim()).filter(s => s.length > 0);
|
||||
|
||||
const dto: Partial<SeedingRule> = {
|
||||
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.rule();
|
||||
const request = editing?.id
|
||||
? this.api.updateSeedingRule(editing.id, dto)
|
||||
: this.api.createSeedingRule(clientId, dto);
|
||||
|
||||
this.saving.set(true);
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Seeding rule updated' : 'Seeding rule created');
|
||||
this.saving.set(false);
|
||||
this.visible.set(false);
|
||||
this.saved.emit();
|
||||
},
|
||||
error: (e: ApiError) => {
|
||||
this.toast.error(e.statusCode === 400 ? e.message : 'Failed to save seeding rule');
|
||||
this.saving.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -56,42 +56,42 @@
|
||||
[(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"
|
||||
hint="Path prefix reported by the download client. Set when paths differ between the client's container and Cleanuparr (e.g. /downloads)"
|
||||
<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"
|
||||
hint="Actual path on the filesystem seen by Cleanuparr, replacing the source prefix (e.g. /mnt/data/downloads)"
|
||||
<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>
|
||||
<div modal-footer>
|
||||
|
||||
+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(() => {
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<app-modal
|
||||
[title]="editingProvider() ? ('Edit ' + modalType() + ' Provider') : ('Add ' + modalType() + ' Provider')"
|
||||
[(visible)]="visible"
|
||||
size="lg"
|
||||
>
|
||||
<div class="modal-form">
|
||||
<app-toggle label="Enabled" [formField]="modalForm.enabled"
|
||||
hint="Enable or disable this provider" />
|
||||
<app-input label="Name" placeholder="My Provider" [formField]="modalForm.name"
|
||||
hint="A unique name to identify this provider"
|
||||
[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" [formField]="modalForm.webhookUrl"
|
||||
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
|
||||
[error]="modalForm.webhookUrl().errors()[0]?.message"
|
||||
helpKey="notifications/discord:webhookUrl" />
|
||||
<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" [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" [formField]="modalForm.botToken"
|
||||
hint="Create a bot with BotFather and paste the API token"
|
||||
[error]="modalForm.botToken().errors()[0]?.message"
|
||||
helpKey="notifications/telegram:botToken" />
|
||||
<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]="modalForm.chatId().errors()[0]?.message"
|
||||
helpKey="notifications/telegram:chatId" />
|
||||
<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" [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" [formField]="modalForm.apiKey"
|
||||
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
|
||||
[error]="modalForm.apiKey().errors()[0]?.message"
|
||||
helpKey="notifications/notifiarr:apiKey" />
|
||||
<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" [formField]="modalForm.appriseMode"
|
||||
hint="API mode requires an external Apprise container. CLI mode uses the Apprise CLI directly."
|
||||
helpKey="notifications/apprise:mode" />
|
||||
@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]="modalForm.appriseUrl().errors()[0]?.message"
|
||||
helpKey="notifications/apprise:url" />
|
||||
<app-input label="Configuration Key" placeholder="Enter key" [formField]="modalForm.appriseKey"
|
||||
hint="The key that identifies your Apprise configuration on the server."
|
||||
[error]="modalForm.appriseKey().errors()[0]?.message"
|
||||
helpKey="notifications/apprise:key" />
|
||||
}
|
||||
@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]="modalForm.appriseServiceUrls().errors()[0]?.message"
|
||||
helpKey="notifications/apprise:serviceUrls" />
|
||||
}
|
||||
<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" [formField]="modalForm.ntfyServerUrl"
|
||||
hint="The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance."
|
||||
[error]="modalForm.ntfyServerUrl().errors()[0]?.message"
|
||||
helpKey="notifications/ntfy:serverUrl" />
|
||||
<app-chip-input label="Topics" placeholder="cleanuparr" [formField]="modalForm.ntfyTopics"
|
||||
hint="Enter the ntfy topics you want to publish to."
|
||||
[error]="modalForm.ntfyTopics().errors()[0]?.message"
|
||||
helpKey="notifications/ntfy:topics" />
|
||||
<app-select label="Authentication" [options]="ntfyAuthOptions" [formField]="modalForm.ntfyAuthType"
|
||||
hint="Choose how to authenticate with the ntfy server."
|
||||
helpKey="notifications/ntfy:authenticationType" />
|
||||
@if (modalForm.ntfyAuthType().value() === 'BasicAuth') {
|
||||
<app-input label="Username" placeholder="Enter username" [formField]="modalForm.ntfyUsername"
|
||||
hint="Your username for basic authentication."
|
||||
[error]="modalForm.ntfyUsername().errors()[0]?.message"
|
||||
helpKey="notifications/ntfy:username" />
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [formField]="modalForm.ntfyPassword"
|
||||
hint="Your password for basic authentication."
|
||||
[error]="modalForm.ntfyPassword().errors()[0]?.message"
|
||||
helpKey="notifications/ntfy:password" />
|
||||
}
|
||||
@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."
|
||||
[error]="modalForm.ntfyAccessToken().errors()[0]?.message"
|
||||
helpKey="notifications/ntfy:accessToken" />
|
||||
}
|
||||
<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" [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" [formField]="modalForm.pushoverApiToken"
|
||||
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
|
||||
[error]="modalForm.pushoverApiToken().errors()[0]?.message"
|
||||
helpKey="notifications/pushover:apiToken" />
|
||||
<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]="modalForm.pushoverUserKey().errors()[0]?.message"
|
||||
helpKey="notifications/pushover:userKey" />
|
||||
<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" [formField]="modalForm.pushoverPriority"
|
||||
hint="The priority level for notifications. Emergency priority will repeat until acknowledged."
|
||||
helpKey="notifications/pushover:priority" />
|
||||
@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)" [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" [formField]="modalForm.pushoverSound"
|
||||
hint="Choose a notification sound, or select Custom to enter your own."
|
||||
helpKey="notifications/pushover:sound" />
|
||||
@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" [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" [formField]="modalForm.gotifyServerUrl"
|
||||
hint="The base URL of your Gotify server instance."
|
||||
[error]="modalForm.gotifyServerUrl().errors()[0]?.message"
|
||||
helpKey="notifications/gotify:serverUrl" />
|
||||
<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]="modalForm.gotifyApplicationToken().errors()[0]?.message"
|
||||
helpKey="notifications/gotify:applicationToken" />
|
||||
<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" />
|
||||
}
|
||||
|
||||
<div class="event-flags">
|
||||
<h4 class="event-flags__title">Events</h4>
|
||||
<div class="event-flags__grid">
|
||||
<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" [formField]="modalForm.onStalledStrike"
|
||||
hint="Notify when a stalled download strike occurs"
|
||||
helpKey="notifications:onStalledStrike" />
|
||||
<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" [formField]="modalForm.onQueueItemDeleted"
|
||||
hint="Notify when a queue item is deleted"
|
||||
helpKey="notifications:onQueueItemDeleted" />
|
||||
<app-toggle label="Download Cleaned" [formField]="modalForm.onDownloadCleaned"
|
||||
hint="Notify when a download is cleaned"
|
||||
helpKey="notifications:onDownloadCleaned" />
|
||||
<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" [formField]="modalForm.onSearchTriggered"
|
||||
hint="Notify when a search is triggered by the Seeker"
|
||||
helpKey="notifications:onSearchTriggered" />
|
||||
<app-toggle label="Search Item Grabbed" [formField]="modalForm.onSearchItemGrabbed"
|
||||
hint="Notify when a search results in downloads being grabbed"
|
||||
helpKey="notifications:onSearchItemGrabbed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div modal-footer>
|
||||
<app-button variant="secondary" size="sm" [loading]="testing()" [disabled]="modalForm().invalid()" (clicked)="testNotification()">
|
||||
Test
|
||||
</app-button>
|
||||
<app-button variant="primary" size="sm" [loading]="saving()" [disabled]="modalForm().invalid()" (clicked)="saveProvider()">
|
||||
Save
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
@use 'settings-layout' as *;
|
||||
|
||||
.modal-form { @include modal-form; }
|
||||
|
||||
.event-flags {
|
||||
margin-top: var(--space-2);
|
||||
|
||||
&__title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, input, model, output, effect, untracked } from '@angular/core';
|
||||
import { form, validate, FormField } from '@angular/forms/signals';
|
||||
import {
|
||||
ButtonComponent, InputComponent, ToggleComponent, SelectComponent,
|
||||
ModalComponent, ChipInputComponent, NumberInputComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { NotificationApi } from '@core/api/notification.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import {
|
||||
NotificationProviderDto,
|
||||
CreateDiscordProviderRequest,
|
||||
CreateTelegramProviderRequest,
|
||||
CreateNotifiarrProviderRequest,
|
||||
CreateAppriseProviderRequest,
|
||||
CreateNtfyProviderRequest,
|
||||
CreatePushoverProviderRequest,
|
||||
CreateGotifyProviderRequest,
|
||||
} from '@shared/models/notification-provider.model';
|
||||
import {
|
||||
NotificationProviderType,
|
||||
AppriseMode,
|
||||
NtfyAuthenticationType,
|
||||
NtfyPriority,
|
||||
PushoverPriority,
|
||||
} from '@shared/models/enums';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGotifyPriority(value: string): number {
|
||||
const priority = Number.parseInt(value, 10);
|
||||
return Number.isNaN(priority) ? 5 : priority;
|
||||
}
|
||||
|
||||
const APPRISE_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'API', value: AppriseMode.Api },
|
||||
{ label: 'CLI', value: AppriseMode.Cli },
|
||||
];
|
||||
|
||||
const NTFY_AUTH_OPTIONS: SelectOption[] = [
|
||||
{ label: 'None', value: NtfyAuthenticationType.None },
|
||||
{ label: 'Basic Auth', value: NtfyAuthenticationType.BasicAuth },
|
||||
{ label: 'Access Token', value: NtfyAuthenticationType.AccessToken },
|
||||
];
|
||||
|
||||
const NTFY_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Min', value: NtfyPriority.Min },
|
||||
{ label: 'Low', value: NtfyPriority.Low },
|
||||
{ label: 'Default', value: NtfyPriority.Default },
|
||||
{ label: 'High', value: NtfyPriority.High },
|
||||
{ label: 'Max', value: NtfyPriority.Max },
|
||||
];
|
||||
|
||||
const GOTIFY_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '5 (Default)', value: '5' },
|
||||
{ label: '6', value: '6' },
|
||||
{ label: '7', value: '7' },
|
||||
{ label: '8', value: '8' },
|
||||
{ label: '9', value: '9' },
|
||||
{ label: '10', value: '10' },
|
||||
];
|
||||
|
||||
const PUSHOVER_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lowest', value: PushoverPriority.Lowest },
|
||||
{ label: 'Low', value: PushoverPriority.Low },
|
||||
{ label: 'Normal', value: PushoverPriority.Normal },
|
||||
{ label: 'High', value: PushoverPriority.High },
|
||||
{ label: 'Emergency', value: PushoverPriority.Emergency },
|
||||
];
|
||||
|
||||
const PUSHOVER_SOUND_OPTIONS: SelectOption[] = [
|
||||
{ label: '(Use default)', value: '' },
|
||||
{ label: 'Pushover (Default)', value: 'pushover' },
|
||||
{ label: 'Bike', value: 'bike' },
|
||||
{ label: 'Bugle', value: 'bugle' },
|
||||
{ label: 'Cash Register', value: 'cashregister' },
|
||||
{ label: 'Classical', value: 'classical' },
|
||||
{ label: 'Cosmic', value: 'cosmic' },
|
||||
{ label: 'Falling', value: 'falling' },
|
||||
{ label: 'Gamelan', value: 'gamelan' },
|
||||
{ label: 'Incoming', value: 'incoming' },
|
||||
{ label: 'Intermission', value: 'intermission' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Mechanical', value: 'mechanical' },
|
||||
{ label: 'Piano Bar', value: 'pianobar' },
|
||||
{ label: 'Siren', value: 'siren' },
|
||||
{ label: 'Space Alarm', value: 'spacealarm' },
|
||||
{ label: 'Tugboat', value: 'tugboat' },
|
||||
{ label: 'Alien (Long)', value: 'alien' },
|
||||
{ label: 'Climb (Long)', value: 'climb' },
|
||||
{ label: 'Persistent (Long)', value: 'persistent' },
|
||||
{ label: 'Echo (Long)', value: 'echo' },
|
||||
{ label: 'Up Down (Long)', value: 'updown' },
|
||||
{ label: 'Vibrate Only', value: 'vibrate' },
|
||||
{ label: 'Silent', value: 'none' },
|
||||
{ label: 'Custom...', value: '__custom__' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-notification-provider-modal',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ButtonComponent, InputComponent, ToggleComponent, SelectComponent,
|
||||
ModalComponent, ChipInputComponent, NumberInputComponent, FormField,
|
||||
],
|
||||
templateUrl: './notification-provider-modal.component.html',
|
||||
styleUrl: './notification-provider-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NotificationProviderModalComponent {
|
||||
private readonly api = inject(NotificationApi);
|
||||
private readonly toast = inject(ToastService);
|
||||
|
||||
readonly editingProvider = input<NotificationProviderDto | null>(null);
|
||||
readonly initialType = input<NotificationProviderType>(NotificationProviderType.Discord);
|
||||
readonly visible = model(false);
|
||||
readonly saved = output<void>();
|
||||
|
||||
readonly modalType = signal<NotificationProviderType>(NotificationProviderType.Discord);
|
||||
readonly testing = signal(false);
|
||||
readonly saving = signal(false);
|
||||
|
||||
readonly modalModel = signal<NotificationModalModel>(createDefaultModalModel());
|
||||
|
||||
/** JSON snapshot of the model as loaded when the modal opened, for dirty tracking. */
|
||||
private readonly openSnapshot = signal('');
|
||||
readonly hasPendingChanges = computed(() =>
|
||||
this.visible() && JSON.stringify(this.modalModel()) !== this.openSnapshot());
|
||||
|
||||
readonly modalForm = form(this.modalModel, (p) => {
|
||||
validate(p.name, () =>
|
||||
!this.modalModel().name.trim() ? { kind: 'required', message: 'Name is required' } : undefined);
|
||||
|
||||
// Discord
|
||||
validate(p.webhookUrl, () =>
|
||||
this.modalType() === NotificationProviderType.Discord && !this.modalModel().webhookUrl.trim()
|
||||
? { kind: 'required', message: 'Webhook URL is required' } : undefined);
|
||||
|
||||
// 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);
|
||||
|
||||
// Notifiarr
|
||||
validate(p.apiKey, () =>
|
||||
this.modalType() === NotificationProviderType.Notifiarr && !this.modalModel().apiKey.trim()
|
||||
? { kind: 'required', message: 'API key is required' } : undefined);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
validate(p.ntfyUsername, () =>
|
||||
this.modalType() === NotificationProviderType.Ntfy
|
||||
&& this.modalModel().ntfyAuthType === NtfyAuthenticationType.BasicAuth
|
||||
&& !this.modalModel().ntfyUsername.trim()
|
||||
? { kind: 'required', message: 'Username is required' } : undefined);
|
||||
validate(p.ntfyPassword, () =>
|
||||
this.modalType() === NotificationProviderType.Ntfy
|
||||
&& this.modalModel().ntfyAuthType === NtfyAuthenticationType.BasicAuth
|
||||
&& !this.modalModel().ntfyPassword.trim()
|
||||
? { kind: 'required', message: 'Password is required' } : undefined);
|
||||
validate(p.ntfyAccessToken, () =>
|
||||
this.modalType() === NotificationProviderType.Ntfy
|
||||
&& this.modalModel().ntfyAuthType === NtfyAuthenticationType.AccessToken
|
||||
&& !this.modalModel().ntfyAccessToken.trim()
|
||||
? { kind: 'required', message: 'Access token is required' } : undefined);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// 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)
|
||||
readonly gotifyPriorityOptions = GOTIFY_PRIORITY_OPTIONS;
|
||||
readonly appriseOptions = APPRISE_MODE_OPTIONS;
|
||||
readonly ntfyAuthOptions = NTFY_AUTH_OPTIONS;
|
||||
readonly ntfyPriorityOptions = NTFY_PRIORITY_OPTIONS;
|
||||
readonly pushoverPriorityOptions = PUSHOVER_PRIORITY_OPTIONS;
|
||||
readonly pushoverSoundOptions = PUSHOVER_SOUND_OPTIONS;
|
||||
|
||||
constructor() {
|
||||
// Populate the form from the input provider (or defaults) each time the modal opens.
|
||||
effect(() => {
|
||||
if (!this.visible()) {
|
||||
return;
|
||||
}
|
||||
const provider = untracked(() => this.editingProvider());
|
||||
const initialType = untracked(() => this.initialType());
|
||||
untracked(() => {
|
||||
const next = provider ? this.buildModelFromProvider(provider) : createDefaultModalModel();
|
||||
this.modalType.set(provider ? provider.type : initialType);
|
||||
this.modalModel.set(next);
|
||||
this.openSnapshot.set(JSON.stringify(next));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private buildModelFromProvider(provider: NotificationProviderDto): NotificationModalModel {
|
||||
const config = provider.configuration as ProviderConfiguration;
|
||||
const model = createDefaultModalModel();
|
||||
model.name = provider.name;
|
||||
model.enabled = provider.isEnabled;
|
||||
|
||||
switch (provider.type) {
|
||||
case NotificationProviderType.Discord:
|
||||
model.webhookUrl = config.webhookUrl ?? '';
|
||||
model.username = config.username ?? '';
|
||||
model.avatarUrl = config.avatarUrl ?? '';
|
||||
break;
|
||||
case NotificationProviderType.Telegram:
|
||||
model.botToken = config.botToken ?? '';
|
||||
model.chatId = config.chatId ?? '';
|
||||
model.topicId = config.topicId ?? '';
|
||||
model.sendSilently = config.sendSilently ?? false;
|
||||
break;
|
||||
case NotificationProviderType.Notifiarr:
|
||||
model.apiKey = config.apiKey ?? '';
|
||||
model.channelId = config.channelId ?? '';
|
||||
break;
|
||||
case NotificationProviderType.Apprise:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
model.gotifyServerUrl = config.serverUrl ?? '';
|
||||
model.gotifyApplicationToken = config.applicationToken ?? '';
|
||||
model.gotifyPriority = String(config.priority ?? 5);
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
private getEventFlags() {
|
||||
const m = this.modalModel();
|
||||
return {
|
||||
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: 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); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Telegram:
|
||||
this.api.testTelegram({
|
||||
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); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Notifiarr:
|
||||
this.api.testNotifiarr({
|
||||
apiKey: m.apiKey,
|
||||
channelId: m.channelId,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Apprise:
|
||||
this.api.testApprise({
|
||||
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); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Ntfy:
|
||||
this.api.testNtfy({
|
||||
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); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Pushover: {
|
||||
const sound = m.pushoverSound;
|
||||
this.api.testPushover({
|
||||
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); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Gotify:
|
||||
this.api.testGotify({
|
||||
serverUrl: m.gotifyServerUrl,
|
||||
applicationToken: m.gotifyApplicationToken,
|
||||
priority: parseGotifyPriority(m.gotifyPriority),
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.toast.error('Test failed');
|
||||
this.testing.set(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
saveProvider(): void {
|
||||
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();
|
||||
|
||||
switch (type) {
|
||||
case NotificationProviderType.Discord: {
|
||||
const request: CreateDiscordProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Telegram: {
|
||||
const request: CreateTelegramProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Notifiarr: {
|
||||
const request: CreateNotifiarrProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Apprise: {
|
||||
const request: CreateAppriseProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Ntfy: {
|
||||
const request: CreateNtfyProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Pushover: {
|
||||
const sound = m.pushoverSound;
|
||||
const request: CreatePushoverProviderRequest = {
|
||||
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);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Gotify: {
|
||||
const request: CreateGotifyProviderRequest = {
|
||||
name: m.name,
|
||||
serverUrl: m.gotifyServerUrl,
|
||||
applicationToken: m.gotifyApplicationToken,
|
||||
priority: parseGotifyPriority(m.gotifyPriority),
|
||||
isEnabled: m.enabled,
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateGotify(editing.id, request) : this.api.createGotify(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.onSaveError();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onSaveSuccess(editing: NotificationProviderDto | null): void {
|
||||
this.toast.success(editing ? 'Provider updated' : 'Provider added');
|
||||
this.visible.set(false);
|
||||
this.saving.set(false);
|
||||
this.saved.emit();
|
||||
}
|
||||
|
||||
private onSaveError(): void {
|
||||
this.toast.error('Failed to save provider');
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
+5
-205
@@ -95,209 +95,9 @@
|
||||
</app-modal>
|
||||
|
||||
<!-- Add/Edit Config Modal -->
|
||||
<app-modal
|
||||
[title]="editingProvider() ? ('Edit ' + modalType() + ' Provider') : ('Add ' + modalType() + ' Provider')"
|
||||
<app-notification-provider-modal
|
||||
[editingProvider]="editingProvider()"
|
||||
[initialType]="selectedType()"
|
||||
[(visible)]="modalVisible"
|
||||
size="lg"
|
||||
>
|
||||
<div class="modal-form">
|
||||
<app-toggle label="Enabled" [(checked)]="modalEnabled"
|
||||
hint="Enable or disable this provider" />
|
||||
<app-input label="Name" placeholder="My Provider" [(value)]="modalName"
|
||||
hint="A unique name to identify this provider"
|
||||
[error]="modalNameError()" />
|
||||
|
||||
<!-- Discord Fields -->
|
||||
@if (modalType() === 'Discord') {
|
||||
<app-input label="Webhook URL" placeholder="https://discord.com/api/webhooks/..." type="password" [revealable]="false" [(value)]="modalWebhookUrl"
|
||||
hint="Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations."
|
||||
[error]="discordWebhookError()"
|
||||
helpKey="notifications/discord:webhookUrl" />
|
||||
<app-input label="Username" placeholder="Cleanuparr" [(value)]="modalUsername"
|
||||
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"
|
||||
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"
|
||||
hint="Create a bot with BotFather and paste the API token"
|
||||
[error]="telegramBotTokenError()"
|
||||
helpKey="notifications/telegram:botToken" />
|
||||
<app-input label="Chat ID" placeholder="-1001234567890" [(value)]="modalChatId"
|
||||
hint="Start a conversation with the bot or add it to your group to get the chat ID"
|
||||
[error]="telegramChatIdError()"
|
||||
helpKey="notifications/telegram:chatId" />
|
||||
<app-input label="Topic ID" placeholder="Optional" [(value)]="modalTopicId"
|
||||
hint="Specify a Topic ID to send to a specific thread (supergroups only)"
|
||||
helpKey="notifications/telegram:topicId" />
|
||||
<app-toggle label="Send Silently" [(checked)]="modalSendSilently"
|
||||
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"
|
||||
hint="Your Notifiarr API key from your dashboard. Requires Passthrough integration."
|
||||
[error]="notifiarrApiKeyError()"
|
||||
helpKey="notifications/notifiarr:apiKey" />
|
||||
<app-input label="Channel ID" placeholder="Enter Discord channel ID" [(value)]="modalChannelId"
|
||||
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"
|
||||
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"
|
||||
hint="The URL of your Apprise server where notifications will be sent."
|
||||
[error]="appriseUrlError()"
|
||||
helpKey="notifications/apprise:url" />
|
||||
<app-input label="Configuration Key" placeholder="Enter key" [(value)]="modalAppriseKey"
|
||||
hint="The key that identifies your Apprise configuration on the server."
|
||||
[error]="appriseKeyError()"
|
||||
helpKey="notifications/apprise:key" />
|
||||
}
|
||||
@if (modalAppriseMode() === 'Cli') {
|
||||
<app-chip-input label="Service URLs" placeholder="discord://webhook_id/token" [(items)]="modalAppriseServiceUrls"
|
||||
hint="Add Apprise service URLs. Example: discord://webhook_id/token."
|
||||
[error]="appriseServiceUrlsError()"
|
||||
helpKey="notifications/apprise:serviceUrls" />
|
||||
}
|
||||
<app-input label="Tags" placeholder="all" [(value)]="modalAppriseTags"
|
||||
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"
|
||||
hint="The URL of your ntfy server. Use https://ntfy.sh for the public service or your self-hosted instance."
|
||||
[error]="ntfyServerUrlError()"
|
||||
helpKey="notifications/ntfy:serverUrl" />
|
||||
<app-chip-input label="Topics" placeholder="cleanuparr" [(items)]="modalNtfyTopics"
|
||||
hint="Enter the ntfy topics you want to publish to."
|
||||
[error]="ntfyTopicsError()"
|
||||
helpKey="notifications/ntfy:topics" />
|
||||
<app-select label="Authentication" [options]="ntfyAuthOptions" [(value)]="modalNtfyAuthType"
|
||||
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"
|
||||
hint="Your username for basic authentication."
|
||||
helpKey="notifications/ntfy:username" />
|
||||
<app-input label="Password" placeholder="Enter password" type="password" [revealable]="false" [(value)]="modalNtfyPassword"
|
||||
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"
|
||||
hint="Your access token for bearer token authentication."
|
||||
helpKey="notifications/ntfy:accessToken" />
|
||||
}
|
||||
<app-select label="Priority" [options]="ntfyPriorityOptions" [(value)]="modalNtfyPriority"
|
||||
hint="The priority level for notifications."
|
||||
helpKey="notifications/ntfy:priority" />
|
||||
<app-chip-input label="Tags" placeholder="warning" [(items)]="modalNtfyTags"
|
||||
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"
|
||||
hint="Your application API token from Pushover. Create one at pushover.net/apps/build."
|
||||
[error]="pushoverApiTokenError()"
|
||||
helpKey="notifications/pushover:apiToken" />
|
||||
<app-input label="User Key" placeholder="Enter user key" type="password" [revealable]="false" [(value)]="modalPushoverUserKey"
|
||||
hint="Your user/group key from your Pushover dashboard."
|
||||
[error]="pushoverUserKeyError()"
|
||||
helpKey="notifications/pushover:userKey" />
|
||||
<app-chip-input label="Devices" placeholder="myphone" [(items)]="modalPushoverDevices"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
hint="The base URL of your Gotify server instance."
|
||||
[error]="gotifyServerUrlError()"
|
||||
helpKey="notifications/gotify:serverUrl" />
|
||||
<app-input label="Application Token" placeholder="Enter application token" type="password" [revealable]="false" [(value)]="modalGotifyApplicationToken"
|
||||
hint="The application token from your Gotify server. Create one under Apps in the Gotify web UI."
|
||||
[error]="gotifyApplicationTokenError()"
|
||||
helpKey="notifications/gotify:applicationToken" />
|
||||
<app-select label="Priority" [options]="gotifyPriorityOptions" [(value)]="modalGotifyPriority"
|
||||
hint="Message priority. Higher priority may trigger more intrusive notifications on clients."
|
||||
helpKey="notifications/gotify:priority" />
|
||||
}
|
||||
|
||||
<div class="event-flags">
|
||||
<h4 class="event-flags__title">Events</h4>
|
||||
<div class="event-flags__grid">
|
||||
<app-toggle label="Failed Import Strike" [(checked)]="onFailedImportStrike"
|
||||
hint="Notify when a failed import strike occurs"
|
||||
helpKey="notifications:onFailedImportStrike" />
|
||||
<app-toggle label="Stalled Strike" [(checked)]="onStalledStrike"
|
||||
hint="Notify when a stalled download strike occurs"
|
||||
helpKey="notifications:onStalledStrike" />
|
||||
<app-toggle label="Slow Strike" [(checked)]="onSlowStrike"
|
||||
hint="Notify when a slow download strike occurs"
|
||||
helpKey="notifications:onSlowStrike" />
|
||||
<app-toggle label="Queue Item Deleted" [(checked)]="onQueueItemDeleted"
|
||||
hint="Notify when a queue item is deleted"
|
||||
helpKey="notifications:onQueueItemDeleted" />
|
||||
<app-toggle label="Download Cleaned" [(checked)]="onDownloadCleaned"
|
||||
hint="Notify when a download is cleaned"
|
||||
helpKey="notifications:onDownloadCleaned" />
|
||||
<app-toggle label="Category Changed" [(checked)]="onCategoryChanged"
|
||||
hint="Notify when a download's category is changed"
|
||||
helpKey="notifications:onCategoryChanged" />
|
||||
<app-toggle label="Search Triggered" [(checked)]="onSearchTriggered"
|
||||
hint="Notify when a search is triggered by the Seeker"
|
||||
helpKey="notifications:onSearchTriggered" />
|
||||
<app-toggle label="Search Item Grabbed" [(checked)]="onSearchItemGrabbed"
|
||||
hint="Notify when a search results in downloads being grabbed"
|
||||
helpKey="notifications:onSearchItemGrabbed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div modal-footer>
|
||||
<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()">
|
||||
Save
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
(saved)="onProviderSaved()"
|
||||
/>
|
||||
@@ -4,7 +4,6 @@
|
||||
:host { @include settings-page; }
|
||||
|
||||
.settings-form { @include settings-form; }
|
||||
.modal-form { @include modal-form; }
|
||||
.list-header { @include list-header; }
|
||||
|
||||
// Provider row uses item-row parts but with a two-row layout
|
||||
@@ -64,23 +63,6 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.event-flags {
|
||||
margin-top: var(--space-2);
|
||||
|
||||
&__title {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.selection-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -1,128 +1,48 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, viewChild } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
SelectComponent, ModalComponent, EmptyStateComponent, BadgeComponent,
|
||||
ChipInputComponent, NumberInputComponent, LoadingStateComponent,
|
||||
type SelectOption,
|
||||
CardComponent, ButtonComponent, ModalComponent, EmptyStateComponent,
|
||||
BadgeComponent, LoadingStateComponent,
|
||||
} from '@ui';
|
||||
import { NotificationApi } from '@core/api/notification.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { ThemeService } from '@core/services/theme.service';
|
||||
import {
|
||||
NotificationProviderDto,
|
||||
CreateDiscordProviderRequest,
|
||||
CreateTelegramProviderRequest,
|
||||
CreateNotifiarrProviderRequest,
|
||||
CreateAppriseProviderRequest,
|
||||
CreateNtfyProviderRequest,
|
||||
CreatePushoverProviderRequest,
|
||||
CreateGotifyProviderRequest,
|
||||
} from '@shared/models/notification-provider.model';
|
||||
import {
|
||||
NotificationProviderType,
|
||||
AppriseMode,
|
||||
NtfyAuthenticationType,
|
||||
NtfyPriority,
|
||||
PushoverPriority,
|
||||
} from '@shared/models/enums';
|
||||
import { NotificationProviderDto } from '@shared/models/notification-provider.model';
|
||||
import { NotificationProviderType } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
|
||||
const APPRISE_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'API', value: AppriseMode.Api },
|
||||
{ label: 'CLI', value: AppriseMode.Cli },
|
||||
];
|
||||
|
||||
const NTFY_AUTH_OPTIONS: SelectOption[] = [
|
||||
{ label: 'None', value: NtfyAuthenticationType.None },
|
||||
{ label: 'Basic Auth', value: NtfyAuthenticationType.BasicAuth },
|
||||
{ label: 'Access Token', value: NtfyAuthenticationType.AccessToken },
|
||||
];
|
||||
|
||||
const NTFY_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Min', value: NtfyPriority.Min },
|
||||
{ label: 'Low', value: NtfyPriority.Low },
|
||||
{ label: 'Default', value: NtfyPriority.Default },
|
||||
{ label: 'High', value: NtfyPriority.High },
|
||||
{ label: 'Max', value: NtfyPriority.Max },
|
||||
];
|
||||
|
||||
const GOTIFY_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '5 (Default)', value: '5' },
|
||||
{ label: '6', value: '6' },
|
||||
{ label: '7', value: '7' },
|
||||
{ label: '8', value: '8' },
|
||||
{ label: '9', value: '9' },
|
||||
{ label: '10', value: '10' },
|
||||
];
|
||||
|
||||
const PUSHOVER_PRIORITY_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Lowest', value: PushoverPriority.Lowest },
|
||||
{ label: 'Low', value: PushoverPriority.Low },
|
||||
{ label: 'Normal', value: PushoverPriority.Normal },
|
||||
{ label: 'High', value: PushoverPriority.High },
|
||||
{ label: 'Emergency', value: PushoverPriority.Emergency },
|
||||
];
|
||||
|
||||
const PUSHOVER_SOUND_OPTIONS: SelectOption[] = [
|
||||
{ label: '(Use default)', value: '' },
|
||||
{ label: 'Pushover (Default)', value: 'pushover' },
|
||||
{ label: 'Bike', value: 'bike' },
|
||||
{ label: 'Bugle', value: 'bugle' },
|
||||
{ label: 'Cash Register', value: 'cashregister' },
|
||||
{ label: 'Classical', value: 'classical' },
|
||||
{ label: 'Cosmic', value: 'cosmic' },
|
||||
{ label: 'Falling', value: 'falling' },
|
||||
{ label: 'Gamelan', value: 'gamelan' },
|
||||
{ label: 'Incoming', value: 'incoming' },
|
||||
{ label: 'Intermission', value: 'intermission' },
|
||||
{ label: 'Magic', value: 'magic' },
|
||||
{ label: 'Mechanical', value: 'mechanical' },
|
||||
{ label: 'Piano Bar', value: 'pianobar' },
|
||||
{ label: 'Siren', value: 'siren' },
|
||||
{ label: 'Space Alarm', value: 'spacealarm' },
|
||||
{ label: 'Tugboat', value: 'tugboat' },
|
||||
{ label: 'Alien (Long)', value: 'alien' },
|
||||
{ label: 'Climb (Long)', value: 'climb' },
|
||||
{ label: 'Persistent (Long)', value: 'persistent' },
|
||||
{ label: 'Echo (Long)', value: 'echo' },
|
||||
{ label: 'Up Down (Long)', value: 'updown' },
|
||||
{ label: 'Vibrate Only', value: 'vibrate' },
|
||||
{ label: 'Silent', value: 'none' },
|
||||
{ label: 'Custom...', value: '__custom__' },
|
||||
];
|
||||
import { NotificationProviderModalComponent } from './notification-provider-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notifications',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
ToggleComponent, SelectComponent, ModalComponent, EmptyStateComponent,
|
||||
BadgeComponent, ChipInputComponent, NumberInputComponent, LoadingStateComponent,
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, ModalComponent,
|
||||
EmptyStateComponent, BadgeComponent, LoadingStateComponent,
|
||||
NotificationProviderModalComponent,
|
||||
],
|
||||
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);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
private readonly providerModal = viewChild(NotificationProviderModalComponent);
|
||||
|
||||
readonly theme = this.themeService.theme;
|
||||
private readonly providersResource = rxResource({
|
||||
stream: () => this.api.getProviders(),
|
||||
});
|
||||
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = signal(false);
|
||||
readonly saving = signal(false);
|
||||
readonly providers = signal<NotificationProviderDto[]>([]);
|
||||
readonly loadError = computed(() => !!this.providersResource.error());
|
||||
readonly providers = computed(() =>
|
||||
this.providersResource.hasValue() ? (this.providersResource.value().providers ?? []) : [],
|
||||
);
|
||||
|
||||
// Selection modal
|
||||
readonly selectionModalVisible = signal(false);
|
||||
@@ -130,174 +50,7 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
// Config modal
|
||||
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('');
|
||||
|
||||
// Telegram fields
|
||||
readonly modalBotToken = signal('');
|
||||
readonly modalChatId = signal('');
|
||||
readonly modalTopicId = signal('');
|
||||
readonly modalSendSilently = signal(false);
|
||||
|
||||
// Notifiarr fields
|
||||
readonly modalApiKey = signal('');
|
||||
readonly modalChannelId = signal('');
|
||||
|
||||
// Apprise fields
|
||||
readonly modalAppriseMode = signal<unknown>(AppriseMode.Api);
|
||||
readonly modalAppriseUrl = signal('');
|
||||
readonly modalAppriseKey = signal('');
|
||||
readonly modalAppriseTags = signal('');
|
||||
readonly modalAppriseServiceUrls = signal<string[]>([]);
|
||||
|
||||
// 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[]>([]);
|
||||
|
||||
// Gotify fields
|
||||
readonly modalGotifyServerUrl = signal('');
|
||||
readonly modalGotifyApplicationToken = signal('');
|
||||
readonly modalGotifyPriority = signal<unknown>('5');
|
||||
|
||||
// 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[]>([]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Options (exposed for template)
|
||||
readonly gotifyPriorityOptions = GOTIFY_PRIORITY_OPTIONS;
|
||||
readonly appriseOptions = APPRISE_MODE_OPTIONS;
|
||||
readonly ntfyAuthOptions = NTFY_AUTH_OPTIONS;
|
||||
readonly ntfyPriorityOptions = NTFY_PRIORITY_OPTIONS;
|
||||
readonly pushoverPriorityOptions = PUSHOVER_PRIORITY_OPTIONS;
|
||||
readonly pushoverSoundOptions = PUSHOVER_SOUND_OPTIONS;
|
||||
readonly selectedType = signal<NotificationProviderType>(NotificationProviderType.Discord);
|
||||
|
||||
// Provider selection data
|
||||
readonly availableProviders = [
|
||||
@@ -310,28 +63,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 {
|
||||
@@ -341,377 +90,17 @@ export class NotificationsComponent implements OnInit, HasPendingChanges {
|
||||
onProviderTypeSelected(type: NotificationProviderType): void {
|
||||
this.selectionModalVisible.set(false);
|
||||
this.editingProvider.set(null);
|
||||
this.modalType.set(type);
|
||||
this.modalName.set('');
|
||||
this.modalEnabled.set(true);
|
||||
this.resetModalFields();
|
||||
this.resetEventFlags();
|
||||
this.selectedType.set(type);
|
||||
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;
|
||||
switch (provider.type) {
|
||||
case NotificationProviderType.Discord:
|
||||
this.modalWebhookUrl.set(config.webhookUrl ?? '');
|
||||
this.modalUsername.set(config.username ?? '');
|
||||
this.modalAvatarUrl.set(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);
|
||||
break;
|
||||
case NotificationProviderType.Notifiarr:
|
||||
this.modalApiKey.set(config.apiKey ?? '');
|
||||
this.modalChannelId.set(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()) : []);
|
||||
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 ?? []);
|
||||
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 ?? []);
|
||||
break;
|
||||
case NotificationProviderType.Gotify:
|
||||
this.modalGotifyServerUrl.set(config.serverUrl ?? '');
|
||||
this.modalGotifyApplicationToken.set(config.applicationToken ?? '');
|
||||
this.modalGotifyPriority.set(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);
|
||||
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() {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
testNotification(): void {
|
||||
const type = this.modalType();
|
||||
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,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Telegram:
|
||||
this.api.testTelegram({
|
||||
botToken: this.modalBotToken(),
|
||||
chatId: this.modalChatId(),
|
||||
topicId: this.modalTopicId() || undefined,
|
||||
sendSilently: this.modalSendSilently(),
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Notifiarr:
|
||||
this.api.testNotifiarr({
|
||||
apiKey: this.modalApiKey(),
|
||||
channelId: this.modalChannelId(),
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
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,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
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,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
case NotificationProviderType.Pushover: {
|
||||
const sound = this.modalPushoverSound() as string;
|
||||
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,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Gotify:
|
||||
this.api.testGotify({
|
||||
serverUrl: this.modalGotifyServerUrl(),
|
||||
applicationToken: this.modalGotifyApplicationToken(),
|
||||
priority: parseInt(this.modalGotifyPriority() as string, 10) || 5,
|
||||
providerId,
|
||||
}).subscribe({
|
||||
next: (r) => { this.toast.success(r.message || 'Test sent'); this.testing.set(false); },
|
||||
error: () => { this.toast.error('Test failed'); this.testing.set(false); },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
saveProvider(): void {
|
||||
if (this.hasModalErrors()) return;
|
||||
const type = this.modalType();
|
||||
const editing = this.editingProvider();
|
||||
this.saving.set(true);
|
||||
const eventFlags = this.getEventFlags();
|
||||
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateDiscord(editing.id, request) : this.api.createDiscord(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateTelegram(editing.id, request) : this.api.createTelegram(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Notifiarr: {
|
||||
const request: CreateNotifiarrProviderRequest = {
|
||||
name: this.modalName(),
|
||||
apiKey: this.modalApiKey(),
|
||||
channelId: this.modalChannelId(),
|
||||
isEnabled: this.modalEnabled(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateNotifiarr(editing.id, request) : this.api.createNotifiarr(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateApprise(editing.id, request) : this.api.createApprise(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateNtfy(editing.id, request) : this.api.createNtfy(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
case NotificationProviderType.Pushover: {
|
||||
const sound = this.modalPushoverSound() as string;
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updatePushover(editing.id, request) : this.api.createPushover(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
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(),
|
||||
...eventFlags,
|
||||
};
|
||||
const obs = editing ? this.api.updateGotify(editing.id, request) : this.api.createGotify(request);
|
||||
obs.subscribe({ next: () => this.onSaveSuccess(editing), error: () => this.onSaveError() });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private onSaveSuccess(editing: NotificationProviderDto | null): void {
|
||||
this.toast.success(editing ? 'Provider updated' : 'Provider added');
|
||||
this.modalVisible.set(false);
|
||||
this.saving.set(false);
|
||||
this.loadProviders();
|
||||
}
|
||||
|
||||
private onSaveError(): void {
|
||||
this.toast.error('Failed to save provider');
|
||||
this.saving.set(false);
|
||||
onProviderSaved(): void {
|
||||
this.providersResource.reload();
|
||||
}
|
||||
|
||||
async deleteProvider(provider: NotificationProviderDto): Promise<void> {
|
||||
@@ -726,13 +115,13 @@ 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'),
|
||||
});
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return false;
|
||||
return !!this.providerModal()?.hasPendingChanges();
|
||||
}
|
||||
}
|
||||
+27
-133
@@ -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>
|
||||
@@ -249,108 +244,7 @@
|
||||
}
|
||||
|
||||
<!-- 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()"
|
||||
helpKey="queue-cleaner:stallRule.name" />
|
||||
<app-toggle label="Enabled" [(checked)]="stallEnabled"
|
||||
hint="Enable this rule"
|
||||
helpKey="queue-cleaner:stallRule.enabled" />
|
||||
<app-number-input label="Max Strikes" [(value)]="stallMaxStrikes" [min]="3" [max]="5000"
|
||||
hint="Number of strikes before action is taken"
|
||||
[error]="stallMaxStrikesError()"
|
||||
helpKey="queue-cleaner:stallRule.maxStrikes" />
|
||||
<app-select label="Privacy Type" [options]="privacyTypeOptions" [value]="stallPrivacyType()"
|
||||
(valueChange)="onStallPrivacyTypeChange($event)"
|
||||
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="%"
|
||||
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="%"
|
||||
hint="Apply the rule to items with a completion percentage less than or equal to this value"
|
||||
[error]="stallCompletionError()"
|
||||
helpKey="queue-cleaner:stallRule.completionRange" />
|
||||
<app-toggle label="Reset Strikes on Progress" [(checked)]="stallResetOnProgress"
|
||||
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"
|
||||
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"
|
||||
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'"
|
||||
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()">
|
||||
{{ editingStallRule() ? 'Update' : 'Create' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
<app-stall-rule-modal [rule]="editingStallRule()" [(visible)]="stallModalVisible" (saved)="reloadStallRules()" />
|
||||
|
||||
<!-- 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()"
|
||||
helpKey="queue-cleaner:slowRule.name" />
|
||||
<app-toggle label="Enabled" [(checked)]="slowEnabled"
|
||||
hint="Enable this rule"
|
||||
helpKey="queue-cleaner:slowRule.enabled" />
|
||||
<app-number-input label="Max Strikes" [(value)]="slowMaxStrikes" [min]="3" [max]="5000"
|
||||
hint="Number of strikes before action is taken"
|
||||
[error]="slowMaxStrikesError()"
|
||||
helpKey="queue-cleaner:slowRule.maxStrikes" />
|
||||
<app-size-input label="Min Speed" [units]="speedUnits" [(value)]="slowMinSpeed"
|
||||
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"
|
||||
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)"
|
||||
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="%"
|
||||
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="%"
|
||||
hint="Apply the rule up to and including this completion percentage"
|
||||
[error]="slowCompletionError()"
|
||||
helpKey="queue-cleaner:slowRule.completionRange" />
|
||||
<app-size-input label="Ignore Above Size" [units]="sizeUnitsLarge" [(value)]="slowIgnoreAboveSize"
|
||||
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"
|
||||
hint="Reset strike count when torrent shows progress"
|
||||
helpKey="queue-cleaner:slowRule.resetStrikesOnProgress" />
|
||||
<app-toggle label="Change category instead of delete" [(checked)]="slowChangeCategory"
|
||||
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'"
|
||||
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()">
|
||||
{{ editingSlowRule() ? 'Update' : 'Create' }}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-modal>
|
||||
<app-slow-rule-modal [rule]="editingSlowRule()" [(visible)]="slowModalVisible" (saved)="reloadSlowRules()" />
|
||||
+213
-425
@@ -1,19 +1,22 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren, effect, untracked } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, viewChildren, effect, untracked } from '@angular/core';
|
||||
import { rxResource } from '@angular/core/rxjs-interop';
|
||||
import { form, required, min, max, validate, disabled, FormField } from '@angular/forms/signals';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
|
||||
BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
|
||||
SizeInputComponent,
|
||||
type SelectOption, type SizeUnit,
|
||||
BadgeComponent, EmptyStateComponent, LoadingStateComponent,
|
||||
type SelectOption,
|
||||
} from '@ui';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { QueueCleanerApi } from '@core/api/queue-cleaner.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { QueueCleanerConfig, ScheduleOptions } from '@shared/models/queue-cleaner-config.model';
|
||||
import { StallRule, SlowRule, CreateStallRuleDto, CreateSlowRuleDto } from '@shared/models/queue-rule.model';
|
||||
import { ScheduleUnit, PatternMode, TorrentPrivacyType } from '@shared/models/enums';
|
||||
import { StallRule, SlowRule } from '@shared/models/queue-rule.model';
|
||||
import { SlowRuleModalComponent } from './slow-rule-modal.component';
|
||||
import { StallRuleModalComponent } from './stall-rule-modal.component';
|
||||
import { ScheduleUnit, PatternMode } from '@shared/models/enums';
|
||||
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
|
||||
import { DeferredLoader } from '@shared/utils/loading.util';
|
||||
import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util';
|
||||
@@ -24,410 +27,275 @@ const PATTERN_MODE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Include', value: PatternMode.Include },
|
||||
];
|
||||
|
||||
const PRIVACY_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Public', value: TorrentPrivacyType.Public },
|
||||
{ label: 'Private', value: TorrentPrivacyType.Private },
|
||||
{ label: 'Both', value: TorrentPrivacyType.Both },
|
||||
];
|
||||
|
||||
const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
|
||||
{ label: 'Seconds', value: ScheduleUnit.Seconds },
|
||||
{ label: 'Minutes', value: ScheduleUnit.Minutes },
|
||||
{ label: 'Hours', value: ScheduleUnit.Hours },
|
||||
];
|
||||
|
||||
interface QueueCleanerFormModel {
|
||||
enabled: boolean;
|
||||
useAdvancedScheduling: boolean;
|
||||
cronExpression: string;
|
||||
scheduleEvery: number;
|
||||
scheduleUnit: ScheduleUnit;
|
||||
ignoredDownloads: string[];
|
||||
processNoContentId: boolean;
|
||||
failedMaxStrikes: number | null;
|
||||
failedIgnorePrivate: boolean;
|
||||
failedDeletePrivate: boolean;
|
||||
failedSkipNotFound: boolean;
|
||||
failedPatterns: string[];
|
||||
failedPatternMode: PatternMode;
|
||||
failedChangeCategory: boolean;
|
||||
metadataMaxStrikes: number | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-queue-cleaner',
|
||||
standalone: true,
|
||||
imports: [
|
||||
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
|
||||
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
|
||||
AccordionComponent, BadgeComponent, ModalComponent, EmptyStateComponent, LoadingStateComponent,
|
||||
SizeInputComponent, NgIcon,
|
||||
AccordionComponent, BadgeComponent, EmptyStateComponent, LoadingStateComponent,
|
||||
NgIcon, FormField, SlowRuleModalComponent, StallRuleModalComponent,
|
||||
],
|
||||
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);
|
||||
private readonly chipInputs = viewChildren(ChipInputComponent);
|
||||
private readonly stallModal = viewChild(StallRuleModalComponent);
|
||||
private readonly slowModal = viewChild(SlowRuleModalComponent);
|
||||
|
||||
private readonly savedSnapshot = signal('');
|
||||
|
||||
readonly patternModeOptions = PATTERN_MODE_OPTIONS;
|
||||
readonly privacyTypeOptions = PRIVACY_TYPE_OPTIONS;
|
||||
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
|
||||
readonly speedUnits: SizeUnit[] = [
|
||||
{ label: 'KB/s', value: 'KB' },
|
||||
{ label: 'MB/s', value: 'MB' },
|
||||
];
|
||||
readonly sizeUnits: SizeUnit[] = [
|
||||
{ label: 'KB', value: 'KB' },
|
||||
{ label: 'MB', value: 'MB' },
|
||||
];
|
||||
readonly sizeUnitsLarge: SizeUnit[] = [
|
||||
{ label: 'MB', value: 'MB' },
|
||||
{ label: 'GB', value: 'GB' },
|
||||
];
|
||||
private readonly configResource = rxResource({
|
||||
stream: () => this.api.getConfig(),
|
||||
});
|
||||
private readonly stallRulesResource = rxResource({
|
||||
stream: () => this.api.getStallRules(),
|
||||
defaultValue: [] as StallRule[],
|
||||
});
|
||||
private readonly slowRulesResource = rxResource({
|
||||
stream: () => this.api.getSlowRules(),
|
||||
defaultValue: [] as SlowRule[],
|
||||
});
|
||||
|
||||
readonly loader = new DeferredLoader();
|
||||
readonly loadError = 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);
|
||||
|
||||
// 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);
|
||||
|
||||
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 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.stallChangeCategory()) {
|
||||
untracked(() => this.stallDeletePrivate.set(false));
|
||||
if (this.configResource.isLoading()) {
|
||||
this.loader.start();
|
||||
} else {
|
||||
this.loader.stop();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (this.slowChangeCategory()) {
|
||||
untracked(() => this.slowDeletePrivate.set(false));
|
||||
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);
|
||||
} 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.stallModalVisible.set(true);
|
||||
}
|
||||
|
||||
saveStallRule(): void {
|
||||
if (this.stallNameError() || this.stallMaxStrikesError() || this.stallCompletionError()) return;
|
||||
|
||||
const changeCategory = this.stallChangeCategory();
|
||||
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(),
|
||||
changeCategory,
|
||||
};
|
||||
|
||||
const editing = this.editingStallRule();
|
||||
const request = editing?.id
|
||||
? this.api.updateStallRule(editing.id, dto)
|
||||
: this.api.createStallRule(dto);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Stall rule updated' : 'Stall rule created');
|
||||
this.stallModalVisible.set(false);
|
||||
this.loadStallRules();
|
||||
},
|
||||
error: (e: Error) => this.toast.error(e.message),
|
||||
});
|
||||
reloadStallRules(): void {
|
||||
this.stallRulesResource.reload();
|
||||
}
|
||||
|
||||
async deleteStallRule(rule: StallRule): Promise<void> {
|
||||
@@ -441,7 +309,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'),
|
||||
});
|
||||
@@ -450,68 +318,11 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
|
||||
// Slow rule CRUD
|
||||
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);
|
||||
} 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.slowModalVisible.set(true);
|
||||
}
|
||||
|
||||
saveSlowRule(): void {
|
||||
if (this.slowNameError() || this.slowMaxStrikesError() || this.slowCompletionError()) return;
|
||||
|
||||
const changeCategory = this.slowChangeCategory();
|
||||
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(),
|
||||
changeCategory,
|
||||
};
|
||||
|
||||
const editing = this.editingSlowRule();
|
||||
const request = editing?.id
|
||||
? this.api.updateSlowRule(editing.id, dto)
|
||||
: this.api.createSlowRule(dto);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(editing ? 'Slow rule updated' : 'Slow rule created');
|
||||
this.slowModalVisible.set(false);
|
||||
this.loadSlowRules();
|
||||
},
|
||||
error: (e: Error) => this.toast.error(e.message),
|
||||
});
|
||||
reloadSlowRules(): void {
|
||||
this.slowRulesResource.reload();
|
||||
}
|
||||
|
||||
async deleteSlowRule(rule: SlowRule): Promise<void> {
|
||||
@@ -525,7 +336,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 +345,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 +387,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(() => {
|
||||
@@ -600,16 +396,8 @@ 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);
|
||||
return this.dirty()
|
||||
|| !!this.stallModal()?.hasPendingChanges()
|
||||
|| !!this.slowModal()?.hasPendingChanges();
|
||||
}
|
||||
}
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user