Compare commits

...
179 changed files with 11516 additions and 26594 deletions

No files matched your search

+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
node-version: '26'
cache: 'npm'
cache-dependency-path: code/frontend/package-lock.json
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 26.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+3 -2
View File
@@ -63,7 +63,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
node-version: 26
- name: Install E2E dependencies
working-directory: e2e
@@ -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: |
+11 -5
View File
@@ -46,11 +46,13 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
- Always use **NSubstitute** for mocking in new tests (Moq is being phased out)
### Frontend
- **Angular 21** with TypeScript 5.9 (standalone components, zoneless, OnPush)
- **Angular 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
- **UI**: Custom glassmorphism design system with 33 custom components — no external UI frameworks
- **Icons**: @ng-icons/core + @ng-icons/tabler-icons
- **Design System**: 3-layer SCSS (`_variables` -> `_tokens` -> `_themes`), dark/light themes
- **State Management**: @ngrx/signals (Angular signals-based)
- **State Management**: Angular signals (`signal`/`computed`/`effect`) — `@ngrx/signals` was removed (it was unused)
- **Data fetching**: Angular 22 Resource API — `rxResource` from `@angular/core/rxjs-interop` (not manual `HttpClient.subscribe()`)
- **Forms**: Angular 22 Signal Forms — `form()` + `[formField]` from `@angular/forms/signals` (settings forms; a few not-yet-migrated forms still use per-field signals)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -69,7 +71,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 21 application
│ ├── frontend/ # Angular 22 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -98,6 +100,8 @@ Cleanuparr/
- All components must be **standalone** with **ChangeDetectionStrategy.OnPush**
- Use `input()` / `output()` function APIs (not `@Input()` / `@Output()` decorators)
- Use Angular **signals** for reactive state (`signal()`, `computed()`, `effect()`)
- **Data fetching**: use the **Resource API** (`rxResource`) with a reactive `params` + `stream`, not manual `HttpClient.subscribe()`; drive spinners/errors off `isLoading()`/`error()`
- **Forms**: use **Signal Forms** (`form()` + `[formField]`) with a single model signal + schema validators; keep the JSON-snapshot dirty tracking (`buildSnapshot()`/`hasPendingChanges()`), do NOT use Signal Forms `dirty()` for the unsaved-changes guard
- Follow the 3-layer SCSS design system (`_variables` -> `_tokens` -> `_themes`)
- **Do not introduce external UI frameworks** (no PrimeNG, Material, Tailwind, etc.)
- Component naming: `{feature}.component.ts`
@@ -183,7 +187,9 @@ make migrate-users name=YourMigrationName
- **Malware blocker** is a critical security feature - changes require careful testing
- **Cross-seed integration** allows keeping torrents that are actively seeding
- **Real-time updates** use SignalR - maintain websocket patterns when adding features
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 22)
- **Sidebar** stays dark purple in both themes - uses sidebar-specific CSS variables
- The project uses **Clean Architecture** - respect layer boundaries
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`) — keep this even with Signal Forms; Signal Forms `dirty()` means "touched", not "differs from saved"
- **Resource API** (`rxResource`): `value()` throws in the error state — always set a `defaultValue` (lists) or guard with `hasValue()` before reading
- **Signal Forms** (`[formField]`) owns `min`/`max`/`disabled`/`required` — set these via schema validators, not template bindings. Custom controls satisfy the contract via `model()` signals (`chip-input` exposes a `value` model; `size-input`'s numeric-min input is named `minValue` to avoid clashing with the field min)
+1 -1
View File
@@ -27,7 +27,7 @@ This helps us avoid redundant work, git conflicts, and contributions that may no
### Prerequisites
- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Node.js 18+](https://nodejs.org/)
- [Node.js 26+](https://nodejs.org/)
- [Git](https://git-scm.com/)
- (Optional) [Make](https://www.gnu.org/software/make/) for database migrations
- (Optional) IDE: [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio](https://visualstudio.microsoft.com/)
+1 -1
View File
@@ -1,5 +1,5 @@
# Build Angular frontend
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:26-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -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
{
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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,
@@ -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);
@@ -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>
@@ -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
@@ -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))))
@@ -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)
{
@@ -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))))
@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Shouldly;
using Xunit;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.MalwareBlocker;
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
+1
View File
@@ -53,6 +53,7 @@
}
],
"styles": [
"node_modules/@angular/cdk/overlay-prebuilt.css",
"src/styles.scss"
],
"stylePreprocessorOptions": {
+37
View File
@@ -0,0 +1,37 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'public/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
...angular.configs.tsRecommended,
prettier,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{ type: 'attribute', prefix: 'app', style: 'camelCase' },
],
'@angular-eslint/component-selector': [
'error',
{ type: 'element', prefix: 'app', style: 'kebab-case' },
],
},
},
{
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {},
},
);
+1570 -1989
View File
File diff suppressed because it is too large. Load diff
+25 -23
View File
@@ -5,7 +5,8 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development"
"watch": "ng build --watch --configuration development",
"lint": "eslint ."
},
"prettier": {
"printWidth": 100,
@@ -21,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"
}
}
+3 -3
View File
@@ -1,7 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideIcons } from '@ng-icons/core';
import {
tablerLayoutDashboard,
@@ -65,8 +64,9 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
provideHttpClient(
withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor]),
),
provideIcons({
tablerLayoutDashboard,
tablerFileText,
+7
View File
@@ -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' },
+2 -1
View File
@@ -1,4 +1,4 @@
import { Component, inject, OnInit } from '@angular/core';
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from '@core/services/theme.service';
import { AuthService } from '@core/auth/auth.service';
@@ -8,6 +8,7 @@ import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ToastContainerComponent, ConfirmDialogComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<router-outlet />
<app-toast-container />
@@ -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 {
@@ -101,7 +101,7 @@
}
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -200,7 +200,7 @@
<!-- Filter drawer -->
<app-drawer title="Filter searches" [(visible)]="drawerOpen">
<div class="filter-group">
<label class="filter-group__label">Instance</label>
<span class="filter-group__label">Instance</span>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -209,7 +209,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Cycle</label>
<span class="filter-group__label">Cycle</span>
<app-select
[value]="draft().cycleFilter"
[options]="cycleFilterOptions"
@@ -222,7 +222,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<span class="filter-group__label">Status</span>
<div class="chip-group">
@for (opt of statusOptions; track opt.value) {
<button
@@ -237,7 +237,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Search type</label>
<span class="filter-group__label">Search type</span>
<app-select
[value]="draft().searchType"
[options]="searchTypeOptions"
@@ -246,7 +246,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Search reason</label>
<span class="filter-group__label">Search reason</span>
<app-select
[value]="draft().searchReason"
[options]="searchReasonOptions"
@@ -255,7 +255,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Grabbed</label>
<span class="filter-group__label">Grabbed</span>
<app-select
[value]="draft().grabbed"
[options]="triStateOptions"
@@ -1,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);
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -10,7 +12,9 @@ import type { SelectOption } from '@ui';
import type { BadgeSeverity } from '@ui/badge/badge.component';
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
import { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api';
import type { SearchEventsQuery } from '@core/api/search-stats.api';
import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models';
import type { PaginatedResult } from '@core/models/pagination.model';
import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models';
import { AppHubService } from '@core/realtime/app-hub.service';
import { ToastService } from '@core/services/toast.service';
@@ -41,7 +45,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
grabbed: 'any',
};
const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string }> = [
const STATUS_OPTIONS: readonly { value: SearchCommandStatus; label: string }[] = [
{ value: SearchCommandStatus.Started, label: 'Started' },
{ value: SearchCommandStatus.Completed, label: 'Completed' },
{ value: SearchCommandStatus.Failed, label: 'Failed' },
@@ -70,7 +74,7 @@ const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string
styleUrl: './searches-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchesTabComponent implements OnInit {
export class SearchesTabComponent {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-searches';
private readonly api = inject(SearchStatsApi);
@@ -78,10 +82,13 @@ export class SearchesTabComponent implements OnInit {
private readonly toast = inject(ToastService);
private readonly pagination = inject(PaginationService);
private initialLoad = true;
private latestLoadToken = 0;
readonly summary = signal<SearchStatsSummary | null>(null);
readonly loading = signal(false);
private readonly summaryResource = rxResource({
stream: (): Observable<SearchStatsSummary | null> => this.api.getSummary(),
defaultValue: null,
});
readonly summary = computed(() => this.summaryResource.value());
readonly sortedInstanceStats = computed(() =>
[...(this.summary()?.perInstanceStats ?? [])].sort((a, b) => {
@@ -91,7 +98,12 @@ export class SearchesTabComponent implements OnInit {
);
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = signal<SelectOption[]>([]);
readonly instanceOptions = computed<SelectOption[]>(() => {
return [
{ label: 'All Instances', value: '' },
...(this.summaryResource.value()?.perInstanceStats ?? []).map((st) => ({ label: st.instanceName, value: st.instanceId })),
];
});
readonly searchQuery = signal('');
@@ -103,11 +115,46 @@ export class SearchesTabComponent implements OnInit {
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
readonly drawerOpen = signal(false);
readonly events = signal<SearchEvent[]>([]);
readonly eventsTotalRecords = signal(0);
readonly eventsPage = signal(1);
readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50));
private readonly eventsParams = computed<SearchEventsQuery>(() => {
const instanceId = this.selectedInstanceId() || undefined;
const search = this.searchQuery() || undefined;
const a = this.applied();
let cycleId: string | undefined;
if (a.cycleFilter === 'current' && instanceId) {
const instance = this.summaryResource.value()?.perInstanceStats.find((s) => s.instanceId === instanceId);
cycleId = instance?.currentCycleId ?? undefined;
}
const triToBool = (v: TriState): boolean | undefined => (v === 'any' ? undefined : v === 'true');
return {
page: this.eventsPage(),
pageSize: this.pageSize(),
instanceId,
cycleId,
search,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
searchStatus: a.statuses.length ? a.statuses : undefined,
searchType: a.searchType || undefined,
searchReason: a.searchReason || undefined,
grabbed: triToBool(a.grabbed),
};
});
private readonly eventsResource = rxResource({
params: () => this.eventsParams(),
stream: ({ params }) => this.api.getEvents(params),
defaultValue: { items: [], page: 1, pageSize: 50, totalCount: 0, totalPages: 0 } as PaginatedResult<SearchEvent>,
});
readonly events = computed(() => this.eventsResource.value().items);
readonly eventsTotalRecords = computed(() => this.eventsResource.value().totalCount);
readonly sortOptions: SelectOption[] = [
{ label: 'Timestamp', value: SearchEventsSortBy.Timestamp },
{ label: 'Title', value: SearchEventsSortBy.Title },
@@ -166,45 +213,43 @@ export class SearchesTabComponent implements OnInit {
this.initialLoad = false;
return;
}
untracked(() => {
this.loadSummary();
this.loadEvents();
});
this.summaryResource.reload();
this.eventsResource.reload();
});
effect(() => {
if (this.summaryResource.error()) {
this.toast.error('Failed to load search stats');
}
});
effect(() => {
if (this.eventsResource.error()) {
this.toast.error('Failed to load search events');
}
});
}
ngOnInit(): void {
this.loadSummary();
this.loadEvents();
}
onSearchFilterChange(): void {
this.eventsPage.set(1);
this.loadEvents();
}
onEventsPageChange(page: number): void {
this.eventsPage.set(page);
this.loadEvents();
}
onSortByChange(value: SearchEventsSortBy): void {
this.sortBy.set(value);
this.eventsPage.set(1);
this.loadEvents();
}
onSortOrderChange(value: SortDirection): void {
this.sortDirection.set(value);
this.eventsPage.set(1);
this.loadEvents();
}
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
SearchesTabComponent.PAGE_SIZE_KEY,
this.pageSize,
this.eventsPage,
() => this.loadEvents(),
);
openFilters(): void {
@@ -222,7 +267,6 @@ export class SearchesTabComponent implements OnInit {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.eventsPage.set(1);
this.loadEvents();
}
toggleStatus(value: SearchCommandStatus): void {
@@ -249,8 +293,8 @@ export class SearchesTabComponent implements OnInit {
}
refresh(): void {
this.loadSummary();
this.loadEvents();
this.summaryResource.reload();
this.eventsResource.reload();
}
searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' {
@@ -325,62 +369,4 @@ export class SearchesTabComponent implements OnInit {
const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
return `${diffMinutes}m`;
}
private loadSummary(): void {
this.api.getSummary().subscribe({
next: (summary) => {
this.summary.set(summary);
this.instanceOptions.set([
{ label: 'All Instances', value: '' },
...summary.perInstanceStats.map(s => ({
label: s.instanceName,
value: s.instanceId,
})),
]);
},
error: () => this.toast.error('Failed to load search stats'),
});
}
private loadEvents(): void {
this.loading.set(true);
const loadToken = ++this.latestLoadToken;
const instanceId = this.selectedInstanceId() || undefined;
const search = this.searchQuery() || undefined;
const a = this.applied();
let cycleId: string | undefined;
if (a.cycleFilter === 'current' && instanceId) {
const instance = this.summary()?.perInstanceStats.find(s => s.instanceId === instanceId);
cycleId = instance?.currentCycleId ?? undefined;
}
const triToBool = (v: TriState): boolean | undefined => v === 'any' ? undefined : v === 'true';
this.api.getEvents({
page: this.eventsPage(),
pageSize: this.pageSize(),
instanceId,
cycleId,
search,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
searchStatus: a.statuses.length ? a.statuses : undefined,
searchType: a.searchType || undefined,
searchReason: a.searchReason || undefined,
grabbed: triToBool(a.grabbed),
}).subscribe({
next: (result) => {
if (loadToken !== this.latestLoadToken) return;
this.events.set(result.items);
this.eventsTotalRecords.set(result.totalCount);
this.loading.set(false);
},
error: () => {
if (loadToken !== this.latestLoadToken) return;
this.loading.set(false);
this.toast.error('Failed to load search events');
},
});
}
}
@@ -7,8 +7,6 @@ import { SearchesTabComponent } from './searches-tab/searches-tab.component';
import { QualityTabComponent } from './quality-tab/quality-tab.component';
import { UpgradesTabComponent } from './upgrades-tab/upgrades-tab.component';
type SeekerTab = 'searches' | 'quality' | 'upgrades';
@Component({
selector: 'app-seeker-stats',
standalone: true,
@@ -1,5 +1,5 @@
<!-- Toolbar -->
<div class="toolbar" stickyAware>
<div class="toolbar" appStickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -86,7 +86,7 @@
<!-- Filter drawer -->
<app-drawer title="Filter upgrades" [(visible)]="drawerOpen">
<div class="filter-group">
<label class="filter-group__label">Instance</label>
<span class="filter-group__label">Instance</span>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -95,7 +95,7 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Time range</label>
<span class="filter-group__label">Time range</span>
<app-select
[value]="draft().timeRange"
[options]="timeRangeOptions"
@@ -1,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);
@@ -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'),
});
@@ -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>
@@ -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(() => {
@@ -19,26 +19,26 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="dcForm.enabled"
hint="When enabled, the download cleaner will run according to the schedule"
helpKey="download-cleaner:enabled" />
@if (enabled()) {
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
@if (dcForm.enabled().value()) {
<app-toggle label="Advanced Scheduling" [formField]="dcForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="download-cleaner:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (dcForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="dcForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="dcForm.cronExpression().errors()[0]?.message"
helpKey="download-cleaner:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="dcForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="download-cleaner:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="dcForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="dcForm.scheduleEvery().errors()[0]?.message"
helpKey="download-cleaner:scheduleEvery" />
</div>
}
@@ -49,7 +49,7 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="dcForm.ignoredDownloads"
helpKey="download-cleaner:ignoredDownloads"
/>
}
@@ -61,7 +61,7 @@
</div>
</app-card>
@if (enabled()) {
@if (dcForm.enabled().value()) {
@if (clientOptions().length > 0) {
<app-card [header]="selectedClient()?.downloadClientName ?? 'Download Client'">
<div class="form-stack">
@@ -163,18 +163,15 @@
<app-accordion header="Unlinked Downloads" subtitle="Clean up orphaned downloads" [(expanded)]="unlinkedExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.unlinkedConfig?.enabled ?? false"
(checkedChange)="updateUnlinkedField('enabled', $event)"
<app-toggle label="Enabled" [formField]="unlinkedForm.enabled"
hint="Enable management of downloads that have no hardlinks"
helpKey="download-cleaner:unlinkedEnabled" />
@if (client.unlinkedConfig?.enabled) {
<app-input label="Target Category" placeholder="unlinked" [value]="client.unlinkedConfig?.targetCategory ?? ''"
(valueChange)="updateUnlinkedField('targetCategory', $event)"
@if (unlinkedForm.enabled().value()) {
<app-input label="Target Category" placeholder="unlinked" [formField]="unlinkedForm.targetCategory"
hint="Category to move unlinked downloads to. You have to create a seeding rule for this category if you want to remove the downloads."
helpKey="download-cleaner:unlinkedTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.unlinkedConfig?.useTag ?? false"
(checkedChange)="updateUnlinkedField('useTag', $event)"
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [formField]="unlinkedForm.useTag"
[featureId]="isSelectedClientTransmission() ? 'unlinked-transmission-label': ''"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, adds a tag instead of changing the category'"
helpKey="download-cleaner:unlinkedUseTag" />
@@ -183,19 +180,17 @@
<div class="form-divider"></div>
<app-chip-input label="Ignored Root Directories" placeholder="Add directory path..."
[items]="client.unlinkedConfig?.ignoredRootDirs ?? []"
(itemsChange)="updateUnlinkedField('ignoredRootDirs', $event)"
[formField]="unlinkedForm.ignoredRootDirs"
hint="Root directories to ignore when checking for unlinked downloads (used for cross-seed)"
helpKey="download-cleaner:unlinkedIgnoredRootDir" />
<app-chip-input label="Unlinked Categories" placeholder="Add category..."
[items]="client.unlinkedConfig?.categories ?? []"
(itemsChange)="updateUnlinkedField('categories', $event)"
[formField]="unlinkedForm.categories"
hint="Categories to check for unlinked downloads"
[error]="unlinkedCategoriesError()"
[error]="unlinkedForm.categories().errors()[0]?.message"
helpKey="download-cleaner:unlinkedCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || !!unlinkedCategoriesError()" (clicked)="saveUnlinkedConfig()">
<app-button variant="primary" [glowing]="unlinkedDirty()" [loading]="unlinkedSaving()" [disabled]="unlinkedSaving() || unlinkedSaved() || !unlinkedDirty() || unlinkedForm.categories().invalid()" (clicked)="saveUnlinkedConfig()">
{{ unlinkedSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
@@ -205,38 +200,33 @@
@if (isDeadTorrentCapableClient()) {
<app-accordion header="Dead Torrents" subtitle="Triage torrents with no seeders" featureId="dead-torrent" [(expanded)]="deadTorrentExpanded">
<div class="form-stack">
<app-toggle label="Enabled" [checked]="client.deadTorrentConfig?.enabled ?? false"
(checkedChange)="updateDeadTorrentField('enabled', $event)"
<app-toggle label="Enabled" [formField]="deadTorrentForm.enabled"
hint="When enabled, torrents reporting no seeders (or whose tracker is unreachable) for a number of consecutive runs are moved to a target category/tag"
helpKey="download-cleaner:deadTorrentEnabled" />
@if (client.deadTorrentConfig?.enabled) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [value]="client.deadTorrentConfig?.targetCategory ?? ''"
(valueChange)="updateDeadTorrentField('targetCategory', $event)"
@if (deadTorrentForm.enabled().value()) {
<app-input label="Target Category" placeholder="cleanuparr-dead" [formField]="deadTorrentForm.targetCategory"
hint="Category/tag dead torrents are moved to. Create a seeding rule for this category to control what happens next."
helpKey="download-cleaner:deadTorrentTargetCategory" />
@if (isTagFilterableClient()) {
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [checked]="client.deadTorrentConfig?.useTag ?? false"
(checkedChange)="updateDeadTorrentField('useTag', $event)"
<app-toggle [label]="isSelectedClientTransmission() ? 'Use Label Instead' : 'Use Tag Instead'" [formField]="deadTorrentForm.useTag"
[hint]="isSelectedClientTransmission() ? 'When enabled, adds a label instead of changing the category' : 'When enabled, uses a tag instead of category'"
helpKey="download-cleaner:deadTorrentUseTag" />
}
<app-number-input label="Strikes" [value]="client.deadTorrentConfig?.maxStrikes ?? null" [min]="3" [step]="1"
(valueChange)="updateDeadTorrentField('maxStrikes', $event ?? 0)"
[error]="deadTorrentStrikesError()"
<app-number-input label="Strikes" [formField]="deadTorrentForm.maxStrikes" [step]="1"
[error]="deadTorrentForm.maxStrikes().errors()[0]?.message"
hint="Consecutive runs with no seeders before moving the torrent (minimum 3). Set this high enough to ride out tracker downtime — e.g. with an hourly cleaner, 168 ≈ 1 week. Strikes reset once seeders are found again."
helpKey="download-cleaner:deadTorrentStrikes" />
<div class="form-divider"></div>
<app-chip-input label="Categories" placeholder="Add category..."
[items]="client.deadTorrentConfig?.categories ?? []"
(itemsChange)="updateDeadTorrentField('categories', $event)"
[formField]="deadTorrentForm.categories"
hint="Categories to scan for dead torrents"
[error]="deadTorrentCategoriesError()"
[error]="deadTorrentForm.categories().errors()[0]?.message"
helpKey="download-cleaner:deadTorrentCategories" />
}
<div class="form-actions">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || !!deadTorrentCategoriesError() || !!deadTorrentStrikesError()" (clicked)="saveDeadTorrentConfig()">
<app-button variant="primary" [glowing]="deadTorrentDirty()" [loading]="deadTorrentSaving()" [disabled]="deadTorrentSaving() || deadTorrentSaved() || !deadTorrentDirty() || deadTorrentForm.categories().invalid() || deadTorrentForm.maxStrikes().invalid()" (clicked)="saveDeadTorrentConfig()">
{{ deadTorrentSaved() ? 'Saved!' : 'Save' }}
</app-button>
</div>
@@ -248,55 +238,47 @@
<div class="form-stack">
<app-toggle
label="Enabled"
[checked]="client.orphanedFilesConfig?.enabled ?? false"
(checkedChange)="updateOrphanedFilesField('enabled', $event)"
[formField]="orphanedFilesForm.enabled"
hint="Enable orphaned files scanning for this download client"
helpKey="download-cleaner:orphanedFilesEnabled"
/>
@if (client.orphanedFilesConfig?.enabled) {
@if (orphanedFilesForm.enabled().value()) {
<div class="form-divider"></div>
<app-chip-input
label="Scan Directories"
placeholder="Add directory path..."
hint="Absolute paths to scan for orphaned files. Each top-level entry is checked against your active torrents."
[items]="client.orphanedFilesConfig?.scanDirectories ?? []"
(itemsChange)="updateOrphanedFilesField('scanDirectories', $event)"
[error]="orphanedFilesScanDirsError()"
[formField]="orphanedFilesForm.scanDirectories"
[error]="orphanedFilesForm.scanDirectories().errors()[0]?.message"
helpKey="download-cleaner:orphanedFilesScanDirectories"
/>
<app-input
label="Orphaned Directory"
placeholder="/mnt/data/orphaned"
hint="Where orphaned files are moved."
[value]="client.orphanedFilesConfig?.orphanedDirectory ?? ''"
(valueChange)="updateOrphanedFilesField('orphanedDirectory', $event)"
[error]="orphanedFilesOrphanedDirError()"
[formField]="orphanedFilesForm.orphanedDirectory"
[error]="orphanedFilesForm.orphanedDirectory().errors()[0]?.message"
helpKey="download-cleaner:orphanedFilesOrphanedDirectory"
/>
<app-chip-input
label="Exclude Patterns"
placeholder="Add glob pattern (e.g. *.nfo)..."
hint="File or directory names matching these patterns are never considered orphaned (e.g. *.nfo, .DS_Store)"
[items]="client.orphanedFilesConfig?.excludePatterns ?? []"
(itemsChange)="updateOrphanedFilesField('excludePatterns', $event)"
[formField]="orphanedFilesForm.excludePatterns"
helpKey="download-cleaner:orphanedFilesExcludePatterns"
/>
<app-number-input
label="Min File Age"
suffix="hours"
[min]="0"
hint="Skip files or folders modified less than this many hours ago. Protects active downloads. Set to 0 to disable the age check."
[value]="client.orphanedFilesConfig?.minFileAgeHours ?? 24"
(valueChange)="updateOrphanedFilesField('minFileAgeHours', $event ?? 24)"
[formField]="orphanedFilesForm.minFileAgeHours"
helpKey="download-cleaner:orphanedFilesMinFileAgeHours"
/>
<app-number-input
label="Purge Orphaned After"
suffix="hours"
[min]="1"
hint="Permanently delete entries from the Orphaned Directory after this many hours. Leave empty to keep them indefinitely."
[value]="client.orphanedFilesConfig?.purgeAfterHours ?? null"
(valueChange)="updateOrphanedFilesField('purgeAfterHours', $event ?? undefined)"
[formField]="orphanedFilesForm.purgeAfterHours"
helpKey="download-cleaner:orphanedFilesPurgeAfterHours"
/>
}
@@ -305,7 +287,7 @@
variant="primary"
[glowing]="orphanedFilesDirty()"
[loading]="orphanedFilesSaving()"
[disabled]="orphanedFilesSaving() || orphanedFilesSaved() || !orphanedFilesDirty() || !!orphanedFilesScanDirsError() || !!orphanedFilesOrphanedDirError()"
[disabled]="orphanedFilesSaving() || orphanedFilesSaved() || !orphanedFilesDirty() || orphanedFilesForm.scanDirectories().invalid() || orphanedFilesForm.orphanedDirectory().invalid()"
(clicked)="saveOrphanedFilesConfig()"
>
{{ orphanedFilesSaved() ? 'Saved!' : 'Save' }}
@@ -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()"
/>
}
@@ -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();
}
}
@@ -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>
@@ -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);
}
@@ -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);
},
});
}
}
@@ -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>
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -23,163 +25,167 @@ const TYPE_OPTIONS: SelectOption[] = [
{ label: 'rTorrent', value: DownloadClientTypeName.rTorrent },
];
interface DownloadClientFormModel {
enabled: boolean;
name: string;
typeName: DownloadClientTypeName;
host: string;
username: string;
password: string;
urlBase: string;
externalUrl: string;
downloadDirectorySource: string;
downloadDirectoryTarget: string;
}
@Component({
selector: 'app-download-clients',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ModalComponent, EmptyStateComponent,
BadgeComponent, LoadingStateComponent,
BadgeComponent, LoadingStateComponent, FormField,
],
templateUrl: './download-clients.component.html',
styleUrl: './download-clients.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DownloadClientsComponent implements OnInit, HasPendingChanges {
export class DownloadClientsComponent implements HasPendingChanges {
private readonly api = inject(DownloadClientApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
private readonly clientsResource = rxResource({
stream: () => this.api.getConfig(),
});
readonly typeOptions = TYPE_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.clientsResource.error());
readonly saving = signal(false);
readonly clients = signal<ClientConfig[]>([]);
readonly clients = computed(() =>
this.clientsResource.hasValue() ? (this.clientsResource.value().clients ?? []) : [],
);
// Modal
readonly modalVisible = signal(false);
readonly editingClient = signal<ClientConfig | null>(null);
readonly modalEnabled = signal(true);
readonly modalName = signal('');
readonly modalTypeName = signal<unknown>(DownloadClientTypeName.qBittorrent);
readonly modalHost = signal('');
readonly modalUsername = signal('');
readonly modalPassword = signal('');
readonly modalUrlBase = signal('');
readonly modalExternalUrl = signal('');
readonly modalDownloadDirectorySource = signal('');
readonly modalDownloadDirectoryTarget = signal('');
readonly testing = signal(false);
// Modal validation
readonly modalNameError = computed(() => {
if (!this.modalName().trim()) {
return 'Name is required';
}
return undefined;
readonly clientModel = signal<DownloadClientFormModel>({
enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent,
host: '', username: '', password: '', urlBase: '', externalUrl: '',
downloadDirectorySource: '', downloadDirectoryTarget: '',
});
readonly modalHostError = computed(() => {
if (!this.modalHost().trim()) {
return 'Host is required';
}
return undefined;
readonly clientForm = form(this.clientModel, (p) => {
required(p.name, { message: 'Name is required' });
required(p.host, { message: 'Host is required' });
});
readonly hasModalErrors = computed(() => !!(
this.modalNameError() || this.modalHostError()
));
readonly hasModalErrors = computed(() => this.clientForm().invalid());
readonly showUsernameField = computed(() => {
return this.modalTypeName() !== DownloadClientTypeName.Deluge;
return this.clientModel().typeName !== DownloadClientTypeName.Deluge;
});
readonly showPasswordField = computed(() => true);
readonly usernameHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Username for HTTP Basic Auth';
}
return 'Username for authentication';
});
readonly passwordHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Password for HTTP Basic Auth';
}
return 'Password for authentication';
});
readonly urlBaseHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
return 'Path to the XMLRPC endpoint. Usually RPC2 for rTorrent or plugins/httprpc/action.php for ruTorrent.';
}
return 'Optional URL base path, leave blank for default';
});
onClientTypeChange(value: unknown): void {
this.modalTypeName.set(value);
if (value === DownloadClientTypeName.Deluge) {
this.modalUsername.set('');
// typeName is owned by [formField]; here we only apply type-specific defaults,
// guarded so they never clobber values already loaded when editing a client.
onClientTypeChange(): void {
const m = this.clientModel();
const patch: Partial<DownloadClientFormModel> = {};
if (m.typeName === DownloadClientTypeName.Deluge && m.username !== '') {
patch.username = '';
}
if (value === DownloadClientTypeName.Transmission) {
this.modalUrlBase.set('transmission');
if (m.typeName === DownloadClientTypeName.Transmission && !m.urlBase) {
patch.urlBase = 'transmission';
}
if (value === DownloadClientTypeName.rTorrent) {
this.modalUrlBase.set('plugins/httprpc/action.php');
if (m.typeName === DownloadClientTypeName.rTorrent && !m.urlBase) {
patch.urlBase = 'plugins/httprpc/action.php';
}
if (Object.keys(patch).length > 0) {
this.clientModel.update((mm) => ({ ...mm, ...patch }));
}
}
ngOnInit(): void {
this.loadClients();
}
private loadClients(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
this.clients.set(config.clients ?? []);
this.loader.stop();
},
error: () => {
constructor() {
effect(() => {
if (this.clientsResource.error()) {
this.toast.error('Failed to load download clients');
}
});
effect(() => {
if (this.clientsResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadClients();
this.clientsResource.reload();
}
openAddModal(): void {
this.editingClient.set(null);
this.modalEnabled.set(true);
this.modalName.set('');
this.modalTypeName.set(DownloadClientTypeName.qBittorrent);
this.modalHost.set('');
this.modalUsername.set('');
this.modalPassword.set('');
this.modalUrlBase.set('');
this.modalExternalUrl.set('');
this.modalDownloadDirectorySource.set('');
this.modalDownloadDirectoryTarget.set('');
this.clientModel.set({
enabled: true, name: '', typeName: DownloadClientTypeName.qBittorrent,
host: '', username: '', password: '', urlBase: '', externalUrl: '',
downloadDirectorySource: '', downloadDirectoryTarget: '',
});
this.modalVisible.set(true);
}
openEditModal(client: ClientConfig): void {
this.editingClient.set(client);
this.modalEnabled.set(client.enabled);
this.modalName.set(client.name);
this.modalTypeName.set(client.typeName);
this.modalHost.set(client.host);
this.modalUsername.set(client.username);
this.modalPassword.set(client.password ?? '');
this.modalUrlBase.set(client.urlBase);
this.modalExternalUrl.set(client.externalUrl ?? '');
this.modalDownloadDirectorySource.set(client.downloadDirectorySource ?? '');
this.modalDownloadDirectoryTarget.set(client.downloadDirectoryTarget ?? '');
this.clientModel.set({
enabled: client.enabled,
name: client.name,
typeName: client.typeName,
host: client.host,
username: client.username,
password: client.password ?? '',
urlBase: client.urlBase,
externalUrl: client.externalUrl ?? '',
downloadDirectorySource: client.downloadDirectorySource ?? '',
downloadDirectoryTarget: client.downloadDirectoryTarget ?? '',
});
this.modalVisible.set(true);
}
testConnection(): void {
const m = this.clientModel();
const request: TestDownloadClientRequest = {
typeName: this.modalTypeName() as DownloadClientTypeName,
typeName: m.typeName,
type: DownloadClientType.Torrent,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
host: m.host,
username: m.username,
password: m.password,
urlBase: m.urlBase,
clientId: this.editingClient()?.id,
};
this.testing.set(true);
@@ -196,32 +202,33 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
}
saveClient(): void {
if (this.hasModalErrors()) {
if (this.clientForm().invalid()) {
return;
}
const editing = this.editingClient();
const m = this.clientModel();
this.saving.set(true);
if (editing) {
const client: ClientConfig = {
...editing,
enabled: this.modalEnabled(),
name: this.modalName(),
typeName: this.modalTypeName() as DownloadClientTypeName,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword() || undefined,
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
enabled: m.enabled,
name: m.name,
typeName: m.typeName,
host: m.host,
username: m.username,
password: m.password || undefined,
urlBase: m.urlBase,
externalUrl: m.externalUrl || undefined,
downloadDirectorySource: m.downloadDirectorySource || null,
downloadDirectoryTarget: m.downloadDirectoryTarget || null,
};
this.api.update(editing.id, client).subscribe({
next: () => {
this.toast.success('Client updated');
this.modalVisible.set(false);
this.saving.set(false);
this.loadClients();
this.clientsResource.reload();
},
error: () => {
this.toast.error('Failed to update client');
@@ -230,24 +237,24 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
});
} else {
const dto: CreateDownloadClientDto = {
enabled: this.modalEnabled(),
name: this.modalName(),
enabled: m.enabled,
name: m.name,
type: DownloadClientType.Torrent,
typeName: this.modalTypeName() as DownloadClientTypeName,
host: this.modalHost(),
username: this.modalUsername(),
password: this.modalPassword(),
urlBase: this.modalUrlBase(),
externalUrl: this.modalExternalUrl() || undefined,
downloadDirectorySource: this.modalDownloadDirectorySource() || null,
downloadDirectoryTarget: this.modalDownloadDirectoryTarget() || null,
typeName: m.typeName,
host: m.host,
username: m.username,
password: m.password,
urlBase: m.urlBase,
externalUrl: m.externalUrl || undefined,
downloadDirectorySource: m.downloadDirectorySource || null,
downloadDirectoryTarget: m.downloadDirectoryTarget || null,
};
this.api.create(dto).subscribe({
next: () => {
this.toast.success('Client added');
this.modalVisible.set(false);
this.saving.set(false);
this.loadClients();
this.clientsResource.reload();
},
error: () => {
this.toast.error('Failed to add client');
@@ -271,7 +278,7 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
this.api.delete(client.id).subscribe({
next: () => {
this.toast.success('Client deleted');
this.loadClients();
this.clientsResource.reload();
},
error: () => this.toast.error('Failed to delete client'),
});
@@ -19,13 +19,13 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Dry Run Mode" [(checked)]="dryRun"
<app-toggle label="Dry Run Mode" [formField]="genForm.dryRun"
hint="When enabled, actions will be logged without being executed (e.g. download removal)"
helpKey="general:dryRun" />
<app-toggle label="Display Support Banner" [(checked)]="displaySupportBanner"
<app-toggle label="Display Support Banner" [formField]="genForm.displaySupportBanner"
hint="Show the support section on the dashboard with links to GitHub and sponsors"
helpKey="general:displaySupportBanner" />
<app-toggle label="Status Check" [(checked)]="statusCheckEnabled"
<app-toggle label="Status Check" [formField]="genForm.statusCheckEnabled"
hint="When enabled, Cleanuparr will periodically check for new versions. Disable this if your environment has restricted outbound network access."
helpKey="general:statusCheckEnabled" />
@@ -35,7 +35,7 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="genForm.ignoredDownloads"
helpKey="general:ignoredDownloads"
/>
</div>
@@ -43,18 +43,18 @@
<app-card header="Authentication">
<div class="form-stack">
<app-toggle label="Disable Authentication for Local Addresses" [(checked)]="authDisableLocalAuth"
<app-toggle label="Disable Authentication for Local Addresses" [formField]="genForm.authDisableLocalAuth"
hint="When enabled, requests from local network addresses (localhost, 192.168.x.x, 10.x.x.x, 172.16-31.x.x) will bypass authentication"
helpKey="general:auth.disableLocalAuth" />
@if (authDisableLocalAuth()) {
<app-toggle label="Trust Forwarded Headers" [(checked)]="authTrustForwardedHeaders"
@if (genForm.authDisableLocalAuth().value()) {
<app-toggle label="Trust Forwarded Headers" [formField]="genForm.authTrustForwardedHeaders"
hint="When behind a reverse proxy, trust X-Forwarded-For and X-Real-IP headers to determine the client's real IP address. Only enable this if you are using a reverse proxy."
helpKey="general:auth.trustForwardedHeaders" />
<app-chip-input
label="Additional Trusted Networks"
placeholder="e.g. 192.168.1.0/24"
hint="Add custom IP addresses or CIDR ranges that should bypass authentication"
[(items)]="authTrustedNetworks"
[formField]="genForm.authTrustedNetworks"
helpKey="general:auth.trustedNetworks"
/>
}
@@ -64,15 +64,15 @@
<app-card header="HTTP Settings">
<div class="form-stack">
<div class="form-row">
<app-number-input label="Max Retries" [(value)]="httpMaxRetries" [min]="0" [max]="5"
<app-number-input label="Max Retries" [formField]="genForm.httpMaxRetries"
hint="Number of retry attempts for failed HTTPS requests"
[error]="httpMaxRetriesError()"
[error]="genForm.httpMaxRetries().errors()[0]?.message"
helpKey="general:httpMaxRetries" />
<app-number-input label="Timeout" [(value)]="httpTimeout" [min]="5" [max]="100" suffix="seconds"
<app-number-input label="Timeout" [formField]="genForm.httpTimeout" suffix="seconds"
hint="Timeout duration for HTTP requests in seconds"
[error]="httpTimeoutError()"
[error]="genForm.httpTimeout().errors()[0]?.message"
helpKey="general:httpTimeout" />
<app-select label="Certificate Validation" [options]="certOptions" [(value)]="httpCertificateValidation"
<app-select label="Certificate Validation" [options]="certOptions" [formField]="genForm.httpCertificateValidation"
hint="Enable or disable certificate validation for HTTPS requests"
helpKey="general:httpCertificateValidation" />
</div>
@@ -81,9 +81,9 @@
<app-card header="State Management">
<div class="form-stack">
<app-number-input label="Strike Inactivity Window" [(value)]="strikeInactivityWindowHours" [min]="1" [max]="168" suffix="hours"
<app-number-input label="Strike Inactivity Window" [formField]="genForm.strikeInactivityWindowHours" suffix="hours"
hint="Strikes for a download are cleared after it goes this many hours without receiving a new strike. As long as new strikes keep occurring, all strikes for that download are retained."
[error]="strikeInactivityWindowHoursError()"
[error]="genForm.strikeInactivityWindowHours().errors()[0]?.message"
helpKey="general:strikeInactivityWindowHours" />
<div class="form-divider"></div>
@@ -102,41 +102,41 @@
<app-accordion header="Logging" subtitle="Log file settings" [(expanded)]="logExpanded">
<div class="form-stack">
<app-select label="Log Level" [options]="logLevelOptions" [(value)]="logLevel"
<app-select label="Log Level" [options]="logLevelOptions" [formField]="genForm.logLevel"
hint="Select the minimum log level to display"
helpKey="general:log.level" />
<div class="form-divider"></div>
<div class="form-row">
<app-number-input label="Rolling Size" [(value)]="logRollingSizeMB" [min]="0" [max]="100" suffix="MB"
<app-number-input label="Rolling Size" [formField]="genForm.logRollingSizeMB" suffix="MB"
hint="Maximum size of each log file in megabytes (0 = disabled)"
[error]="logRollingSizeError()"
[error]="genForm.logRollingSizeMB().errors()[0]?.message"
helpKey="general:log.rollingSizeMB" />
<app-number-input label="Retained Files" [(value)]="logRetainedFileCount" [min]="0" [max]="50"
<app-number-input label="Retained Files" [formField]="genForm.logRetainedFileCount"
hint="Number of old log files to retain (0 = unlimited)"
[error]="logRetainedFileCountError()"
[error]="genForm.logRetainedFileCount().errors()[0]?.message"
helpKey="general:log.retainedFileCount" />
<app-number-input label="Time Limit" [(value)]="logTimeLimitHours" [min]="0" [max]="1440" suffix="hours"
<app-number-input label="Time Limit" [formField]="genForm.logTimeLimitHours" suffix="hours"
hint="Maximum age of old log files in hours (0 = unlimited)"
[error]="logTimeLimitError()"
[error]="genForm.logTimeLimitHours().errors()[0]?.message"
helpKey="general:log.timeLimitHours" />
</div>
<div class="form-divider"></div>
<app-toggle label="Archive Enabled" [(checked)]="logArchiveEnabled"
<app-toggle label="Archive Enabled" [formField]="genForm.logArchiveEnabled"
hint="Enable archiving of old log files"
helpKey="general:log.archiveEnabled" />
@if (logArchiveEnabled()) {
@if (genForm.logArchiveEnabled().value()) {
<div class="form-row">
<app-number-input label="Archive Retained Count" [(value)]="logArchiveRetainedCount" [min]="0" [max]="100"
<app-number-input label="Archive Retained Count" [formField]="genForm.logArchiveRetainedCount"
hint="Number of archive files to retain (0 = unlimited)"
[error]="logArchiveRetainedError()"
[error]="genForm.logArchiveRetainedCount().errors()[0]?.message"
helpKey="general:log.archiveRetainedCount" />
<app-number-input label="Archive Time Limit" [(value)]="logArchiveTimeLimitHours" [min]="0" [max]="1440" suffix="hours"
<app-number-input label="Archive Time Limit" [formField]="genForm.logArchiveTimeLimitHours" suffix="hours"
hint="Maximum age of archive files in hours (0 = unlimited)"
[error]="logArchiveTimeLimitError()"
[error]="genForm.logArchiveTimeLimitHours().errors()[0]?.message"
helpKey="general:log.archiveTimeLimitHours" />
</div>
}
@@ -1,7 +1,9 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, viewChildren } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, viewChildren } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, min, max, validate, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, ToggleComponent, InputComponent,
CardComponent, ButtonComponent, ToggleComponent,
NumberInputComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
type SelectOption,
@@ -29,19 +31,40 @@ const LOG_LEVEL_OPTIONS: SelectOption[] = [
{ label: 'Fatal', value: LogEventLevel.Fatal },
];
interface GeneralSettingsFormModel {
displaySupportBanner: boolean;
dryRun: boolean;
httpMaxRetries: number | null;
httpTimeout: number | null;
httpCertificateValidation: CertificateValidationType;
statusCheckEnabled: boolean;
ignoredDownloads: string[];
strikeInactivityWindowHours: number | null;
authDisableLocalAuth: boolean;
authTrustForwardedHeaders: boolean;
authTrustedNetworks: string[];
logLevel: LogEventLevel;
logRollingSizeMB: number | null;
logRetainedFileCount: number | null;
logTimeLimitHours: number | null;
logArchiveEnabled: boolean;
logArchiveRetainedCount: number | null;
logArchiveTimeLimitHours: number | null;
}
@Component({
selector: 'app-general-settings',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent,
ToggleComponent, NumberInputComponent, SelectComponent, ChipInputComponent,
AccordionComponent, EmptyStateComponent, LoadingStateComponent,
AccordionComponent, EmptyStateComponent, LoadingStateComponent, FormField,
],
templateUrl: './general-settings.component.html',
styleUrl: './general-settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
export class GeneralSettingsComponent implements HasPendingChanges {
private readonly api = inject(GeneralConfigApi);
private readonly toast = inject(ToastService);
private readonly confirmService = inject(ConfirmService);
@@ -49,190 +72,163 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
private readonly savedSnapshot = signal('');
private readonly configResource = rxResource({
stream: () => this.api.get(),
});
readonly certOptions = CERT_OPTIONS;
readonly logLevelOptions = LOG_LEVEL_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
// Form state
readonly displaySupportBanner = signal(true);
readonly dryRun = signal(false);
readonly httpMaxRetries = signal<number | null>(3);
readonly httpTimeout = signal<number | null>(30);
readonly httpCertificateValidation = signal<unknown>(CertificateValidationType.Enabled);
readonly statusCheckEnabled = signal(true);
readonly ignoredDownloads = signal<string[]>([]);
readonly strikeInactivityWindowHours = signal<number | null>(24);
// UI-only state (not part of the form model)
readonly purgingStrikes = signal(false);
// Auth
readonly authDisableLocalAuth = signal(false);
readonly authTrustForwardedHeaders = signal(false);
readonly authTrustedNetworks = signal<string[]>([]);
// Logging
readonly logLevel = signal<unknown>(LogEventLevel.Information);
readonly logRollingSizeMB = signal<number | null>(10);
readonly logRetainedFileCount = signal<number | null>(5);
readonly logTimeLimitHours = signal<number | null>(168);
readonly logArchiveEnabled = signal(false);
readonly logArchiveRetainedCount = signal<number | null>(3);
readonly logArchiveTimeLimitHours = signal<number | null>(720);
readonly logExpanded = signal(false);
readonly httpMaxRetriesError = computed(() => {
const v = this.httpMaxRetries();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 5) return 'Maximum value is 5';
return undefined;
private readonly model = signal<GeneralSettingsFormModel>({
displaySupportBanner: true,
dryRun: false,
httpMaxRetries: 3,
httpTimeout: 30,
httpCertificateValidation: CertificateValidationType.Enabled,
statusCheckEnabled: true,
ignoredDownloads: [],
strikeInactivityWindowHours: 24,
authDisableLocalAuth: false,
authTrustForwardedHeaders: false,
authTrustedNetworks: [],
logLevel: LogEventLevel.Information,
logRollingSizeMB: 10,
logRetainedFileCount: 5,
logTimeLimitHours: 168,
logArchiveEnabled: false,
logArchiveRetainedCount: 3,
logArchiveTimeLimitHours: 720,
});
readonly httpTimeoutError = computed(() => {
const v = this.httpTimeout();
if (v == null) return 'This field is required';
if (v < 1) return 'Minimum value is 1';
if (v > 100) return 'Maximum value is 100';
return undefined;
readonly genForm = form(this.model, (p) => {
required(p.httpMaxRetries, { message: 'This field is required' });
min(p.httpMaxRetries, 0, { message: 'Minimum value is 0' });
max(p.httpMaxRetries, 5, { message: 'Maximum value is 5' });
required(p.httpTimeout, { message: 'This field is required' });
min(p.httpTimeout, 5, { message: 'Minimum value is 5' });
max(p.httpTimeout, 100, { message: 'Maximum value is 100' });
required(p.strikeInactivityWindowHours, { message: 'This field is required' });
min(p.strikeInactivityWindowHours, 1, { message: 'Minimum value is 1' });
max(p.strikeInactivityWindowHours, 168, { message: 'Maximum value is 168 hours (7 days)' });
required(p.logRollingSizeMB, { message: 'This field is required' });
min(p.logRollingSizeMB, 0, { message: 'Minimum value is 0' });
max(p.logRollingSizeMB, 100, { message: 'Maximum value is 100 MB' });
required(p.logRetainedFileCount, { message: 'This field is required' });
min(p.logRetainedFileCount, 0, { message: 'Minimum value is 0' });
max(p.logRetainedFileCount, 50, { message: 'Maximum value is 50' });
required(p.logTimeLimitHours, { message: 'This field is required' });
min(p.logTimeLimitHours, 0, { message: 'Minimum value is 0' });
max(p.logTimeLimitHours, 1440, { message: 'Maximum value is 1440 hours (60 days)' });
required(p.logArchiveRetainedCount, { message: 'This field is required' });
min(p.logArchiveRetainedCount, 0, { message: 'Minimum value is 0' });
max(p.logArchiveRetainedCount, 100, { message: 'Maximum value is 100' });
validate(p.logArchiveRetainedCount, () => this.bothZeroError());
required(p.logArchiveTimeLimitHours, { message: 'This field is required' });
min(p.logArchiveTimeLimitHours, 0, { message: 'Minimum value is 0' });
max(p.logArchiveTimeLimitHours, 1440, { message: 'Maximum value is 1440 hours (60 days)' });
validate(p.logArchiveTimeLimitHours, () => this.bothZeroError());
});
readonly logRollingSizeError = computed(() => {
const v = this.logRollingSizeMB();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 100) return 'Maximum value is 100 MB';
return undefined;
});
readonly logRetainedFileCountError = computed(() => {
const v = this.logRetainedFileCount();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 50) return 'Maximum value is 50';
return undefined;
});
readonly logTimeLimitError = computed(() => {
const v = this.logTimeLimitHours();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 1440) return 'Maximum value is 1440 hours (60 days)';
return undefined;
});
readonly logArchiveRetentionBothZeroError = computed(() =>
this.logArchiveEnabled() && this.logArchiveRetainedCount() === 0 && this.logArchiveTimeLimitHours() === 0
? 'Retained count and time limit cannot both be 0 when archiving is enabled'
: undefined
);
readonly logArchiveRetainedError = computed(() => {
const v = this.logArchiveRetainedCount();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 100) return 'Maximum value is 100';
return this.logArchiveRetentionBothZeroError();
});
readonly logArchiveTimeLimitError = computed(() => {
const v = this.logArchiveTimeLimitHours();
if (v == null) return 'This field is required';
if (v < 0) return 'Minimum value is 0';
if (v > 1440) return 'Maximum value is 1440 hours (60 days)';
return this.logArchiveRetentionBothZeroError();
});
readonly strikeInactivityWindowHoursError = computed(() => {
const v = this.strikeInactivityWindowHours();
if (v == null) return 'This field is required';
if (v < 1) return 'Minimum value is 1';
if (v > 168) return 'Maximum value is 168 hours (7 days)';
return undefined;
});
readonly hasErrors = computed(() => !!(
this.strikeInactivityWindowHoursError() ||
this.httpMaxRetriesError() ||
this.httpTimeoutError() ||
this.logRollingSizeError() ||
this.logRetainedFileCountError() ||
this.logTimeLimitError() ||
this.logArchiveRetainedError() ||
this.logArchiveTimeLimitError() ||
this.chipInputs().some(c => c.hasUncommittedInput())
));
ngOnInit(): void {
this.loadConfig();
private bothZeroError() {
const m = this.model();
return m.logArchiveEnabled && m.logArchiveRetainedCount === 0 && m.logArchiveTimeLimitHours === 0
? { kind: 'bothZero', message: 'Retained count and time limit cannot both be 0 when archiving is enabled' }
: undefined;
}
private loadConfig(): void {
this.loader.start();
this.api.get().subscribe({
next: (config) => {
this.displaySupportBanner.set(config.displaySupportBanner);
this.dryRun.set(config.dryRun);
this.httpMaxRetries.set(config.httpMaxRetries);
this.httpTimeout.set(config.httpTimeout);
this.httpCertificateValidation.set(config.httpCertificateValidation);
this.statusCheckEnabled.set(config.statusCheckEnabled);
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.strikeInactivityWindowHours.set(config.strikeInactivityWindowHours);
if (config.auth) {
this.authDisableLocalAuth.set(config.auth.disableAuthForLocalAddresses);
this.authTrustForwardedHeaders.set(config.auth.trustForwardedHeaders);
this.authTrustedNetworks.set(config.auth.trustedNetworks ?? []);
}
if (config.log) {
this.logLevel.set(config.log.level);
this.logRollingSizeMB.set(config.log.rollingSizeMB);
this.logRetainedFileCount.set(config.log.retainedFileCount);
this.logTimeLimitHours.set(config.log.timeLimitHours);
this.logArchiveEnabled.set(config.log.archiveEnabled);
this.logArchiveRetainedCount.set(config.log.archiveRetainedCount);
this.logArchiveTimeLimitHours.set(config.log.archiveTimeLimitHours);
}
this.loader.stop();
readonly hasErrors = computed(() =>
this.genForm().invalid() || this.chipInputs().some(c => c.hasUncommittedInput())
);
constructor() {
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.model.set({
displaySupportBanner: config.displaySupportBanner,
dryRun: config.dryRun,
httpMaxRetries: config.httpMaxRetries,
httpTimeout: config.httpTimeout,
httpCertificateValidation: config.httpCertificateValidation,
statusCheckEnabled: config.statusCheckEnabled,
ignoredDownloads: config.ignoredDownloads ?? [],
strikeInactivityWindowHours: config.strikeInactivityWindowHours,
authDisableLocalAuth: config.auth?.disableAuthForLocalAddresses ?? false,
authTrustForwardedHeaders: config.auth?.trustForwardedHeaders ?? false,
authTrustedNetworks: config.auth?.trustedNetworks ?? [],
logLevel: config.log?.level ?? LogEventLevel.Information,
logRollingSizeMB: config.log?.rollingSizeMB ?? 10,
logRetainedFileCount: config.log?.retainedFileCount ?? 5,
logTimeLimitHours: config.log?.timeLimitHours ?? 168,
logArchiveEnabled: config.log?.archiveEnabled ?? false,
logArchiveRetainedCount: config.log?.archiveRetainedCount ?? 3,
logArchiveTimeLimitHours: config.log?.archiveTimeLimitHours ?? 720,
});
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load general settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
save(): void {
const m = this.model();
const config: GeneralConfig = {
displaySupportBanner: this.displaySupportBanner(),
dryRun: this.dryRun(),
httpMaxRetries: this.httpMaxRetries() ?? 3,
httpTimeout: this.httpTimeout() ?? 30,
httpCertificateValidation: this.httpCertificateValidation() as CertificateValidationType,
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours() ?? 24,
ignoredDownloads: this.ignoredDownloads(),
displaySupportBanner: m.displaySupportBanner,
dryRun: m.dryRun,
httpMaxRetries: m.httpMaxRetries ?? 3,
httpTimeout: m.httpTimeout ?? 30,
httpCertificateValidation: m.httpCertificateValidation as CertificateValidationType,
statusCheckEnabled: m.statusCheckEnabled,
strikeInactivityWindowHours: m.strikeInactivityWindowHours ?? 24,
ignoredDownloads: m.ignoredDownloads,
auth: {
disableAuthForLocalAddresses: this.authDisableLocalAuth(),
trustForwardedHeaders: this.authTrustForwardedHeaders(),
trustedNetworks: this.authTrustedNetworks(),
disableAuthForLocalAddresses: m.authDisableLocalAuth,
trustForwardedHeaders: m.authTrustForwardedHeaders,
trustedNetworks: m.authTrustedNetworks,
},
log: {
level: this.logLevel() as LogEventLevel,
rollingSizeMB: this.logRollingSizeMB() ?? 10,
retainedFileCount: this.logRetainedFileCount() ?? 5,
timeLimitHours: this.logTimeLimitHours() ?? 168,
archiveEnabled: this.logArchiveEnabled(),
archiveRetainedCount: this.logArchiveRetainedCount() ?? 3,
archiveTimeLimitHours: this.logArchiveTimeLimitHours() ?? 720,
level: m.logLevel as LogEventLevel,
rollingSizeMB: m.logRollingSizeMB ?? 10,
retainedFileCount: m.logRetainedFileCount ?? 5,
timeLimitHours: m.logTimeLimitHours ?? 168,
archiveEnabled: m.logArchiveEnabled,
archiveRetainedCount: m.logArchiveRetainedCount ?? 3,
archiveTimeLimitHours: m.logArchiveTimeLimitHours ?? 720,
},
};
@@ -253,26 +249,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
}
private buildSnapshot(): string {
return JSON.stringify({
displaySupportBanner: this.displaySupportBanner(),
dryRun: this.dryRun(),
httpMaxRetries: this.httpMaxRetries(),
httpTimeout: this.httpTimeout(),
httpCertificateValidation: this.httpCertificateValidation(),
statusCheckEnabled: this.statusCheckEnabled(),
strikeInactivityWindowHours: this.strikeInactivityWindowHours(),
ignoredDownloads: this.ignoredDownloads(),
authDisableLocalAuth: this.authDisableLocalAuth(),
authTrustForwardedHeaders: this.authTrustForwardedHeaders(),
authTrustedNetworks: this.authTrustedNetworks(),
logLevel: this.logLevel(),
logRollingSizeMB: this.logRollingSizeMB(),
logRetainedFileCount: this.logRetainedFileCount(),
logTimeLimitHours: this.logTimeLimitHours(),
logArchiveEnabled: this.logArchiveEnabled(),
logArchiveRetainedCount: this.logArchiveRetainedCount(),
logArchiveTimeLimitHours: this.logArchiveTimeLimitHours(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -19,42 +19,41 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="mbForm.enabled"
hint="When enabled, the Malware blocker will run according to the schedule"
helpKey="malware-blocker:enabled" />
@if (enabled()) {
<app-toggle label="Ignore Private Torrents" [(checked)]="ignorePrivate"
@if (mbForm.enabled().value()) {
<app-toggle label="Ignore Private Torrents" [formField]="mbForm.ignorePrivate"
hint="When enabled, private torrents will not be processed"
helpKey="malware-blocker:ignorePrivate" />
<app-toggle label="Delete Private from Client" [(checked)]="deletePrivate"
[disabled]="deletePrivateDisabled()"
<app-toggle label="Delete Private from Client" [formField]="mbForm.deletePrivate" [forceDisabled]="mbForm.deletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="malware-blocker:deletePrivate" />
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
<app-toggle label="Process downloads with no content ID" [formField]="mbForm.processNoContentId"
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
helpKey="malware-blocker:processNoContentId" />
<app-toggle label="Delete if any file is blocked" featureId="delete-if-any-malware" [(checked)]="deleteIfAnyFileBlocked"
<app-toggle label="Delete if any file is blocked" featureId="delete-if-any-malware" [formField]="mbForm.deleteIfAnyFileBlocked"
hint="When enabled, the entire download will be removed if any file in it matches the blocklist. When disabled, the download is only removed when all of its files match."
helpKey="malware-blocker:deleteIfAnyFileBlocked" />
<div class="form-divider"></div>
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
<app-toggle label="Advanced Scheduling" [formField]="mbForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="malware-blocker:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (mbForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="mbForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="mbForm.cronExpression().errors()[0]?.message"
helpKey="malware-blocker:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="mbForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="malware-blocker:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="mbForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="mbForm.scheduleEvery().errors()[0]?.message"
helpKey="malware-blocker:scheduleEvery" />
</div>
}
@@ -65,39 +64,36 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="mbForm.ignoredDownloads"
helpKey="malware-blocker:ignoredDownloads"
/>
}
</div>
</app-card>
@if (enabled()) {
@if (mbForm.enabled().value()) {
<app-accordion header="Arr Blocklists" subtitle="Per-application blocklist configuration" [(expanded)]="arrExpanded" [error]="noBlocklistError()">
@for (name of arrNames; track name) {
@for (arr of arrFields; track arr.label) {
<div class="arr-blocklist">
<h4 class="arr-blocklist__title">{{ capitalize(name) }}</h4>
<h4 class="arr-blocklist__title">{{ arr.label }}</h4>
<div class="form-stack">
<app-toggle
label="Enabled"
[checked]="arrBlocklists()[name].enabled"
(checkedChange)="updateArrBlocklist(name, 'enabled', $event)"
[hint]="'When enabled, the ' + capitalize(name) + ' blocklist will be used for content filtering'"
[formField]="arr.field.enabled"
[hint]="'When enabled, the ' + arr.label + ' blocklist will be used for content filtering'"
/>
@if (arrBlocklists()[name].enabled) {
@if (arr.field.enabled().value()) {
<app-input
label="Blocklist Path"
[value]="arrBlocklists()[name].blocklistPath"
(valueChange)="updateArrBlocklist(name, 'blocklistPath', $event)"
[formField]="arr.field.blocklistPath"
placeholder="Local file path or URL"
hint="Path to the blocklist file or URL"
[error]="blocklistPathError(name)"
[error]="arr.field.blocklistPath().errors()[0]?.message"
/>
<app-select
label="Blocklist Type"
[options]="blocklistTypeOptions"
[value]="arrBlocklists()[name].blocklistType"
(valueChange)="updateArrBlocklist(name, 'blocklistType', $event)"
[formField]="arr.field.blocklistType"
hint="Type of blocklist: Blacklist (block matches) or Whitelist (only allow matches)"
/>
}
@@ -1,4 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, computed, viewChildren, effect, untracked } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChildren, effect, untracked } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { form, required, validate, disabled, FormField } from '@angular/forms/signals';
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
import {
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
@@ -27,53 +29,72 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
const ARR_NAMES = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'] as const;
interface ArrBlocklistFormModel {
enabled: boolean;
blocklistPath: string;
blocklistType: BlocklistType;
}
interface MalwareBlockerFormModel {
enabled: boolean;
ignorePrivate: boolean;
deletePrivate: boolean;
processNoContentId: boolean;
deleteIfAnyFileBlocked: boolean;
useAdvancedScheduling: boolean;
cronExpression: string;
scheduleEvery: number;
scheduleUnit: ScheduleUnit;
ignoredDownloads: string[];
sonarr: ArrBlocklistFormModel;
radarr: ArrBlocklistFormModel;
lidarr: ArrBlocklistFormModel;
readarr: ArrBlocklistFormModel;
whisparr: ArrBlocklistFormModel;
}
@Component({
selector: 'app-malware-blocker',
standalone: true,
imports: [
PageHeaderComponent, CardComponent, ButtonComponent, InputComponent,
ToggleComponent, SelectComponent, ChipInputComponent, AccordionComponent,
EmptyStateComponent, LoadingStateComponent,
EmptyStateComponent, LoadingStateComponent, FormField,
],
templateUrl: './malware-blocker.component.html',
styleUrl: './malware-blocker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
export class MalwareBlockerComponent implements HasPendingChanges {
private readonly api = inject(MalwareBlockerApi);
private readonly toast = inject(ToastService);
private readonly chipInputs = viewChildren(ChipInputComponent);
private readonly savedSnapshot = signal('');
readonly blocklistTypeOptions = BLOCKLIST_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly arrNames = ARR_NAMES;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
readonly saving = signal(false);
readonly saved = signal(false);
readonly enabled = signal(false);
readonly useAdvancedScheduling = signal(false);
readonly cronExpression = signal('');
readonly scheduleEvery = signal<unknown>(5);
readonly scheduleUnit = signal<unknown>(ScheduleUnit.Seconds);
readonly ignoredDownloads = signal<string[]>([]);
readonly ignorePrivate = signal(false);
readonly deletePrivate = signal(false);
readonly processNoContentId = signal(false);
readonly deleteIfAnyFileBlocked = signal(false);
readonly arrExpanded = signal(false);
readonly scheduleIntervalOptions = computed(() => {
const unit = this.scheduleUnit() as ScheduleUnit;
const values = MalwareScheduleOptions[unit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
private readonly configResource = rxResource({
stream: () => this.api.getConfig(),
});
// Per-arr blocklist settings
readonly arrBlocklists = signal<Record<string, { enabled: boolean; blocklistPath: string; blocklistType: unknown }>>({
readonly blocklistTypeOptions = BLOCKLIST_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly loader = new DeferredLoader();
readonly loadError = computed(() => !!this.configResource.error());
readonly saving = signal(false);
readonly saved = signal(false);
readonly arrExpanded = signal(false);
private readonly model = signal<MalwareBlockerFormModel>({
enabled: false,
ignorePrivate: false,
deletePrivate: false,
processNoContentId: false,
deleteIfAnyFileBlocked: false,
useAdvancedScheduling: false,
cronExpression: '',
scheduleEvery: 5,
scheduleUnit: ScheduleUnit.Seconds,
ignoredDownloads: [],
sonarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
radarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
lidarr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
@@ -81,154 +102,158 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
whisparr: { enabled: false, blocklistPath: '', blocklistType: BlocklistType.Blacklist },
});
readonly deletePrivateDisabled = computed(() => this.ignorePrivate());
constructor() {
effect(() => {
const unit = this.scheduleUnit();
const options = MalwareScheduleOptions[unit as ScheduleUnit] ?? [];
const current = this.scheduleEvery();
if (options.length > 0 && !options.includes(current as number)) {
untracked(() => this.scheduleEvery.set(options[0]));
readonly mbForm = form(this.model, (p) => {
validate(p.scheduleEvery, ({ value, valueOf }) => {
if (!valueOf(p.enabled) || valueOf(p.useAdvancedScheduling)) {
return undefined;
}
const options = MalwareScheduleOptions[valueOf(p.scheduleUnit)] ?? [];
return options.includes(value()) ? undefined : { kind: 'schedule', message: 'Please select a value' };
});
effect(() => {
const ignorePrivate = this.ignorePrivate();
if (ignorePrivate) {
untracked(() => this.deletePrivate.set(false));
}
validate(p.cronExpression, ({ value, valueOf }) => {
return valueOf(p.enabled) && valueOf(p.useAdvancedScheduling) && !value().trim()
? { kind: 'required', message: 'Cron expression is required' }
: undefined;
});
}
readonly scheduleEveryError = computed(() => {
if (this.useAdvancedScheduling()) return undefined;
const unit = this.scheduleUnit() as ScheduleUnit;
const options = MalwareScheduleOptions[unit] ?? [];
if (!options.includes(this.scheduleEvery() as number)) return 'Please select a value';
return undefined;
disabled(p.deletePrivate, () => this.model().ignorePrivate);
required(p.sonarr.blocklistPath, { when: () => this.model().sonarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.radarr.blocklistPath, { when: () => this.model().radarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.lidarr.blocklistPath, { when: () => this.model().lidarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.readarr.blocklistPath, { when: () => this.model().readarr.enabled, message: 'Path is required when blocklist is enabled' });
required(p.whisparr.blocklistPath, { when: () => this.model().whisparr.enabled, message: 'Path is required when blocklist is enabled' });
validate(p.enabled, () => {
const m = this.model();
if (!m.enabled) {
return undefined;
}
const hasAnyEnabled = ARR_NAMES.some((name) => m[name].enabled);
return hasAnyEnabled ? undefined : { kind: 'noBlocklist', message: 'At least one blocklist must be configured' };
});
});
readonly cronError = computed(() => {
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required';
return undefined;
readonly arrFields = [
{ label: 'Sonarr', field: this.mbForm.sonarr },
{ label: 'Radarr', field: this.mbForm.radarr },
{ label: 'Lidarr', field: this.mbForm.lidarr },
{ label: 'Readarr', field: this.mbForm.readarr },
{ label: 'Whisparr', field: this.mbForm.whisparr },
];
readonly scheduleIntervalOptions = computed(() => {
const values = MalwareScheduleOptions[this.model().scheduleUnit] ?? [];
return values.map(v => ({ label: `${v}`, value: v }));
});
blocklistPathError(arrName: string): string | undefined {
const bl = this.arrBlocklists()[arrName];
if (bl?.enabled && !bl.blocklistPath?.trim()) {
return 'Path is required when blocklist is enabled';
}
return undefined;
}
readonly noBlocklistError = computed(() =>
this.mbForm.enabled().errors().find((e) => e.kind === 'noBlocklist')?.message
);
readonly noBlocklistError = computed(() => {
if (!this.enabled()) return undefined;
const blocklists = this.arrBlocklists();
const hasAnyEnabled = ARR_NAMES.some(name => blocklists[name]?.enabled);
if (!hasAnyEnabled) {
return 'At least one blocklist must be configured';
}
return undefined;
});
readonly hasErrors = computed(() => {
if (this.noBlocklistError()) return true;
if (this.scheduleEveryError()) return true;
if (this.cronError()) return true;
if (this.chipInputs().some(c => c.hasUncommittedInput())) return true;
for (const name of ARR_NAMES) {
if (this.blocklistPathError(name)) return true;
}
return false;
});
readonly hasErrors = computed(() =>
this.mbForm().invalid() || this.chipInputs().some((c) => c.hasUncommittedInput())
);
private config: MalwareBlockerConfig | null = null;
ngOnInit(): void {
this.loadConfig();
}
constructor() {
effect(() => {
const unit = this.model().scheduleUnit;
const options = MalwareScheduleOptions[unit] ?? [];
const current = this.model().scheduleEvery;
if (options.length > 0 && !options.includes(current)) {
untracked(() => this.model.update(m => ({ ...m, scheduleEvery: options[0] })));
}
});
private loadConfig(): void {
this.loader.start();
this.api.getConfig().subscribe({
next: (config) => {
effect(() => {
const m = this.model();
// Guard on the current value: model.update always creates a new object, so writing unconditionally re-triggers this effect forever (infinite loop / page freeze).
if (m.ignorePrivate && m.deletePrivate) {
untracked(() => this.model.update(mm => ({ ...mm, deletePrivate: false })));
}
});
effect(() => {
const config = this.configResource.hasValue() ? this.configResource.value() : undefined;
if (!config) {
return;
}
untracked(() => {
this.config = config;
this.enabled.set(config.enabled);
this.useAdvancedScheduling.set(config.useAdvancedScheduling);
this.cronExpression.set(config.cronExpression);
const parsed = parseCronToJobSchedule(config.cronExpression);
if (parsed) {
this.scheduleEvery.set(parsed.every);
this.scheduleUnit.set(parsed.type);
}
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
this.ignorePrivate.set(config.ignorePrivate);
this.deletePrivate.set(config.deletePrivate);
this.processNoContentId.set(config.processNoContentId);
this.deleteIfAnyFileBlocked.set(config.deleteIfAnyFileBlocked);
const blocklists: Record<string, any> = {};
for (const name of ARR_NAMES) {
const bl = config[name];
blocklists[name] = {
enabled: bl.enabled,
blocklistPath: bl.blocklistPath,
blocklistType: bl.blocklistType,
};
}
this.arrBlocklists.set(blocklists);
this.loader.stop();
this.model.set({
enabled: config.enabled,
ignorePrivate: config.ignorePrivate,
deletePrivate: config.deletePrivate,
processNoContentId: config.processNoContentId,
deleteIfAnyFileBlocked: config.deleteIfAnyFileBlocked,
useAdvancedScheduling: config.useAdvancedScheduling,
cronExpression: config.cronExpression,
scheduleEvery: parsed?.every ?? 5,
scheduleUnit: parsed?.type ?? ScheduleUnit.Seconds,
ignoredDownloads: config.ignoredDownloads ?? [],
sonarr: this.toArrModel(config.sonarr),
radarr: this.toArrModel(config.radarr),
lidarr: this.toArrModel(config.lidarr),
readarr: this.toArrModel(config.readarr),
whisparr: this.toArrModel(config.whisparr),
});
this.savedSnapshot.set(this.buildSnapshot());
},
error: () => {
});
});
effect(() => {
if (this.configResource.error()) {
this.toast.error('Failed to load malware blocker settings');
}
});
effect(() => {
if (this.configResource.isLoading()) {
this.loader.start();
} else {
this.loader.stop();
this.loadError.set(true);
},
}
});
}
updateArrBlocklist(arrName: string, field: string, value: any): void {
this.arrBlocklists.update((current) => ({
...current,
[arrName]: { ...current[arrName], [field]: value },
}));
}
capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
private toArrModel(b: BlocklistSettings): ArrBlocklistFormModel {
return { enabled: b.enabled, blocklistPath: b.blocklistPath, blocklistType: b.blocklistType };
}
retry(): void {
this.loadError.set(false);
this.loadConfig();
this.configResource.reload();
}
save(): void {
if (!this.config) return;
if (!this.config) {
return;
}
const jobSchedule = { every: (this.scheduleEvery() as number) ?? 5, type: this.scheduleUnit() as ScheduleUnit };
const cronExpression = this.useAdvancedScheduling()
? this.cronExpression()
const m = this.model();
const jobSchedule = { every: m.scheduleEvery ?? 5, type: m.scheduleUnit };
const cronExpression = m.useAdvancedScheduling
? m.cronExpression
: generateCronExpression(jobSchedule);
const blocklists = this.arrBlocklists();
const config: MalwareBlockerConfig = {
...this.config,
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
enabled: m.enabled,
useAdvancedScheduling: m.useAdvancedScheduling,
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
sonarr: { enabled: blocklists['sonarr'].enabled, blocklistPath: blocklists['sonarr'].blocklistPath, blocklistType: blocklists['sonarr'].blocklistType as BlocklistType },
radarr: { enabled: blocklists['radarr'].enabled, blocklistPath: blocklists['radarr'].blocklistPath, blocklistType: blocklists['radarr'].blocklistType as BlocklistType },
lidarr: { enabled: blocklists['lidarr'].enabled, blocklistPath: blocklists['lidarr'].blocklistPath, blocklistType: blocklists['lidarr'].blocklistType as BlocklistType },
readarr: { enabled: blocklists['readarr'].enabled, blocklistPath: blocklists['readarr'].blocklistPath, blocklistType: blocklists['readarr'].blocklistType as BlocklistType },
whisparr: { enabled: blocklists['whisparr'].enabled, blocklistPath: blocklists['whisparr'].blocklistPath, blocklistType: blocklists['whisparr'].blocklistType as BlocklistType },
ignoredDownloads: m.ignoredDownloads,
ignorePrivate: m.ignorePrivate,
deletePrivate: m.deletePrivate,
processNoContentId: m.processNoContentId,
deleteIfAnyFileBlocked: m.deleteIfAnyFileBlocked,
sonarr: { ...m.sonarr },
radarr: { ...m.radarr },
lidarr: { ...m.lidarr },
readarr: { ...m.readarr },
whisparr: { ...m.whisparr },
};
this.saving.set(true);
@@ -250,19 +275,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
}
private buildSnapshot(): string {
return JSON.stringify({
enabled: this.enabled(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression: this.cronExpression(),
scheduleEvery: this.scheduleEvery(),
scheduleUnit: this.scheduleUnit(),
ignoredDownloads: this.ignoredDownloads(),
ignorePrivate: this.ignorePrivate(),
deletePrivate: this.deletePrivate(),
processNoContentId: this.processNoContentId(),
deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(),
arrBlocklists: this.arrBlocklists(),
});
return JSON.stringify(this.model());
}
readonly dirty = computed(() => {
@@ -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>
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -19,30 +19,30 @@
<div class="settings-form">
<app-card header="General">
<div class="form-stack">
<app-toggle label="Enabled" [(checked)]="enabled"
<app-toggle label="Enabled" [formField]="qcForm.enabled"
hint="When enabled, the queue cleaner will run according to the schedule"
helpKey="queue-cleaner:enabled" />
@if (enabled()) {
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
@if (qcForm.enabled().value()) {
<app-toggle label="Process downloads with no content ID" [formField]="qcForm.processNoContentId"
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
helpKey="queue-cleaner:processNoContentId" />
<div class="form-divider"></div>
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
<app-toggle label="Advanced Scheduling" [formField]="qcForm.useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="queue-cleaner:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
@if (qcForm.useAdvancedScheduling().value()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [formField]="qcForm.cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
[error]="qcForm.cronExpression().errors()[0]?.message"
helpKey="queue-cleaner:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [formField]="qcForm.scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="queue-cleaner:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
<app-select label="Every" [options]="scheduleIntervalOptions()" [formField]="qcForm.scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
[error]="qcForm.scheduleEvery().errors()[0]?.message"
helpKey="queue-cleaner:scheduleEvery" />
</div>
}
@@ -51,55 +51,50 @@
label="Ignored Downloads"
placeholder="Add download pattern..."
hint="Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)"
[(items)]="ignoredDownloads"
[formField]="qcForm.ignoredDownloads"
helpKey="queue-cleaner:ignoredDownloads"
/>
}
</div>
</app-card>
@if (enabled()) {
@if (qcForm.enabled().value()) {
<app-accordion header="Failed Import" subtitle="Settings for handling failed imports" [(expanded)]="failedExpanded">
<div class="form-stack">
<app-number-input label="Max Strikes" [(value)]="failedMaxStrikes" [min]="0" [max]="5000"
<app-number-input label="Max Strikes" [formField]="qcForm.failedMaxStrikes"
hint="Number of strikes before action is taken (0 to disable, min 3 to enable)"
[error]="failedMaxStrikesError()"
[error]="qcForm.failedMaxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:failedImport.maxStrikes" />
<div class="form-divider"></div>
<app-toggle label="Ignore Private Torrents" [(checked)]="failedIgnorePrivate"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Ignore Private Torrents" [formField]="qcForm.failedIgnorePrivate" [forceDisabled]="qcForm.failedIgnorePrivate().disabled()"
hint="When enabled, private torrents will not be checked for being failed imports"
helpKey="queue-cleaner:failedImport.ignorePrivate" />
<app-toggle label="Change category instead of delete" [(checked)]="failedChangeCategory"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Change category instead of delete" [formField]="qcForm.failedChangeCategory" [forceDisabled]="qcForm.failedChangeCategory().disabled()"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:failedImport.changeCategory" />
@if (!failedChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="failedDeletePrivate"
[disabled]="failedDeletePrivateDisabled()"
@if (!qcForm.failedChangeCategory().value()) {
<app-toggle label="Delete Private from Client" [formField]="qcForm.failedDeletePrivate" [forceDisabled]="qcForm.failedDeletePrivate().disabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:failedImport.deletePrivate" />
}
<app-toggle label="Skip if Not Found in Client" [(checked)]="failedSkipNotFound"
[disabled]="failedSubFieldsDisabled()"
<app-toggle label="Skip if Not Found in Client" [formField]="qcForm.failedSkipNotFound" [forceDisabled]="qcForm.failedSkipNotFound().disabled()"
hint="Skip failed import check for torrents not found in any enabled torrent client"
helpKey="queue-cleaner:failedImport.skipIfNotFoundInClient" />
<div class="form-divider"></div>
<app-select label="Pattern Mode" [options]="patternModeOptions" [(value)]="failedPatternMode"
[disabled]="failedSubFieldsDisabled()"
<app-select label="Pattern Mode" [options]="patternModeOptions" [formField]="qcForm.failedPatternMode" [forceDisabled]="qcForm.failedPatternMode().disabled()"
hint="Choose how the patterns are applied to failed imports"
helpKey="queue-cleaner:failedImport.pattern-mode" />
<app-chip-input
[label]="patternLabel()"
placeholder="Add a pattern..."
[hint]="patternHint()"
[error]="failedPatternsError()"
[disabled]="failedSubFieldsDisabled()"
[(items)]="failedPatterns"
[error]="qcForm.failedPatterns().errors()[0]?.message"
[formField]="qcForm.failedPatterns"
[forceDisabled]="qcForm.failedPatterns().disabled()"
helpKey="queue-cleaner:failedImport.patterns"
/>
</div>
@@ -107,9 +102,9 @@
<app-accordion header="Downloading Metadata" subtitle="Settings for stalled metadata downloads (qBittorrent only)" [(expanded)]="metadataExpanded">
<div class="form-stack">
<app-number-input label="Max Strikes" [(value)]="metadataMaxStrikes" [min]="0" [max]="5000"
<app-number-input label="Max Strikes" [formField]="qcForm.metadataMaxStrikes"
hint="Number of strikes before action is taken (0 to disable, min 3 to enable)"
[error]="metadataMaxStrikesError()"
[error]="qcForm.metadataMaxStrikes().errors()[0]?.message"
helpKey="queue-cleaner:downloadingMetadataMaxStrikes" />
</div>
</app-accordion>
@@ -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()" />
@@ -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