Compare commits

..
Author SHA1 Message Date
Flaminel ad8c5f23cf fixed some early returns causing a retry 2026-06-17 15:47:06 +03:00
Flaminel 9eebeed990 fixed log again 2026-06-17 15:46:32 +03:00
Flaminel a79a60a339 fixed ignored downloads bloating with each run 2026-06-17 15:27:32 +03:00
Flaminel d1bd9fddcc retried webhook malware scans while the torrent metadata is not yet available 2026-06-17 15:06:25 +03:00
Flaminel 96823adcc3 reworked webhooks controller to ProblemDetails errors and [controller] route 2026-06-17 12:14:31 +03:00
Flaminel e0e88147aa added more retries 2026-06-17 12:05:45 +03:00
Flaminel f278a0dad0 fixed log 2026-06-17 12:05:35 +03:00
Flaminel f61300b869 fixed docs link 2026-06-16 23:51:01 +03:00
Flaminel 561c05778c showed only the instance id on the arr card instead of the full webhook url 2026-06-16 23:47:11 +03:00
Flaminel 60d273991d used an enum for arr webhook event type instead of hardcoded strings 2026-06-16 23:43:18 +03:00
Flaminel ddb1042ca5 mapped malware-blocker triggerMode help key to its docs anchor 2026-06-16 23:29:07 +03:00
Flaminel 9a31e86ad8 removed unused method 2026-06-16 23:21:56 +03:00
Flaminel 614e97313e passed WebhookScanTarget to ScheduleMalwareBlockerWebhookRetry instead of individual fields 2026-06-16 23:21:56 +03:00
Flaminel a34a3d3c7e made MalwareBlocker webhook retries conditional on the download not being found 2026-06-16 23:21:56 +03:00
Flaminel f9588d89c0 changed some stuff 2026-06-16 23:21:56 +03:00
Flaminel eacd9346a5 generalized MalwareBlockerTriggerMode to JobTriggerMode 2026-06-16 23:21:56 +03:00
Flaminel 0561c64ddf added tests for MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel 304a8e78ee documented MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel e008b64a1d added MalwareBlocker webhook frontend (trigger mode and per-instance URL) 2026-06-16 23:21:56 +03:00
Flaminel 4f7e2d33b4 added webhook-triggered targeted MalwareBlocker scan 2026-06-16 23:21:56 +03:00
Flaminel b1b19e5f29 added content-id filter to arr queue fetch 2026-06-16 23:21:56 +03:00
Flaminel 40ab0e9fad added MalwareBlocker trigger mode config and migration 2026-06-16 23:21:56 +03:00
223 changed files with 30099 additions and 11793 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: '26'
node-version: '24'
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: 26.x
node-version: 24.x
cache: yarn
cache-dependency-path: docs/yarn.lock
+2 -3
View File
@@ -63,7 +63,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 26
node-version: 22
- name: Install E2E dependencies
working-directory: e2e
@@ -71,8 +71,7 @@ jobs:
- name: Install Playwright browsers
working-directory: e2e
timeout-minutes: 5
run: npx playwright install chromium
run: npx playwright install --with-deps chromium
- name: Wait for Keycloak
run: |
+5 -11
View File
@@ -46,13 +46,11 @@ 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 22** with TypeScript 6.0, Node 26 (standalone components, zoneless, OnPush)
- **Angular 21** with TypeScript 5.9 (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**: 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)
- **State Management**: @ngrx/signals (Angular signals-based)
- **Real-time Updates**: @microsoft/signalr 10.0.0
- **PWA**: Service Worker support enabled
@@ -71,7 +69,7 @@ Cleanuparr/
│ │ ├── Cleanuparr.Persistence/ # SQLite data access
│ │ ├── Cleanuparr.Persistence.Tests/
│ │ └── Cleanuparr.Shared/ # Shared utilities
│ ├── frontend/ # Angular 22 application
│ ├── frontend/ # Angular 21 application
│ ├── e2e/ # Playwright E2E tests
│ ├── Dockerfile # Multi-stage Docker build
│ ├── entrypoint.sh # Docker entrypoint
@@ -100,8 +98,6 @@ 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`
@@ -187,9 +183,7 @@ 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 22)
- Use `@ng-icons/core` + `@ng-icons/tabler-icons` for icons (NOT `angular-tabler-icons` which doesn't support Angular 21)
- **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()`) — 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)
- **Settings dirty tracking** uses JSON snapshot comparison (`buildSnapshot()` + `hasPendingChanges()`)
+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 26+](https://nodejs.org/)
- [Node.js 18+](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:26-alpine AS frontend-build
FROM --platform=$BUILDPLATFORM node:25-alpine AS frontend-build
WORKDIR /app
# Copy package files first for better layer caching
@@ -0,0 +1,164 @@
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Api.Features.Webhooks.Controllers;
using Cleanuparr.Api.Tests.TestHelpers;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Shouldly;
namespace Cleanuparr.Api.Tests.Features.Webhooks;
public class WebhooksControllerTests : IDisposable
{
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagement;
private readonly WebhooksController _controller;
private Guid _sonarrInstanceId;
private Guid _lidarrInstanceId;
public WebhooksControllerTests()
{
_dataContext = CreateDataContext();
_jobManagement = Substitute.For<IJobManagementService>();
var logger = Substitute.For<ILogger<WebhooksController>>();
_controller = new WebhooksController(logger, _dataContext, _jobManagement);
ControllerTestContext.Attach(_controller);
}
public void Dispose()
{
_dataContext.Dispose();
GC.SuppressFinalize(this);
}
private DataContext CreateDataContext()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<DataContext>().UseSqlite(connection).Options;
var context = new DataContext(options);
context.Database.EnsureCreated();
var sonarrInstance = new ArrInstance { Enabled = true, Name = "Sonarr", Url = new Uri("http://sonarr:8989"), ApiKey = "key" };
var lidarrInstance = new ArrInstance { Enabled = true, Name = "Lidarr", Url = new Uri("http://lidarr:8686"), ApiKey = "key" };
_sonarrInstanceId = sonarrInstance.Id;
_lidarrInstanceId = lidarrInstance.Id;
context.ArrConfigs.AddRange(
new ArrConfig { Type = InstanceType.Sonarr, Instances = [sonarrInstance] },
new ArrConfig { Type = InstanceType.Lidarr, Instances = [lidarrInstance] }
);
context.ContentBlockerConfigs.Add(new ContentBlockerConfig
{
Enabled = true,
TriggerMode = JobTriggerMode.Both,
IgnoredDownloads = [],
});
context.SaveChanges();
return context;
}
private void SetConfig(bool enabled, JobTriggerMode mode)
{
var config = _dataContext.ContentBlockerConfigs.First();
config.Enabled = enabled;
config.TriggerMode = mode;
_dataContext.SaveChanges();
}
private static ArrWebhookPayload GrabPayload(string? downloadId = "HASH123", long seriesId = 42) => new()
{
EventType = "Grab",
DownloadId = downloadId,
Series = new ArrWebhookContent { Id = seriesId },
};
[Fact]
public async Task TestEvent_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, new ArrWebhookPayload { EventType = "Test" });
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ValidGrab_SchedulesTargetedScan()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.Received(1)
.TriggerMalwareBlockerWebhook(_sonarrInstanceId, "HASH123", 42, InstanceType.Sonarr);
}
[Fact]
public async Task UnknownInstance_ReturnsNotFound()
{
var result = await _controller.TriggerMalwareBlocker(Guid.NewGuid(), GrabPayload());
var notFound = result.ShouldBeOfType<ObjectResult>();
notFound.StatusCode.ShouldBe(StatusCodes.Status404NotFound);
notFound.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task NonSonarrRadarrInstance_ReturnsUnprocessable()
{
var result = await _controller.TriggerMalwareBlocker(_lidarrInstanceId, GrabPayload());
var unprocessable = result.ShouldBeOfType<ObjectResult>();
unprocessable.StatusCode.ShouldBe(StatusCodes.Status422UnprocessableEntity);
unprocessable.Value.ShouldBeOfType<ProblemDetails>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task Disabled_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: false, JobTriggerMode.Both);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task ScheduleOnlyMode_ReturnsOk_AndDoesNotSchedule()
{
SetConfig(enabled: true, JobTriggerMode.Schedule);
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload());
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
[Fact]
public async Task EmptyDownloadId_ReturnsOk_AndDoesNotSchedule()
{
var result = await _controller.TriggerMalwareBlocker(_sonarrInstanceId, GrabPayload(downloadId: null));
result.ShouldBeOfType<OkResult>();
await _jobManagement.DidNotReceive()
.TriggerMalwareBlockerWebhook(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<long>(), Arg.Any<InstanceType>());
}
}
@@ -28,7 +28,6 @@ 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,
@@ -38,8 +37,7 @@ public sealed class AuthController : ControllerBase
ITotpService totpService,
IPlexAuthService plexAuthService,
IOidcAuthService oidcAuthService,
ILogger<AuthController> logger,
IWebHostEnvironment environment)
ILogger<AuthController> logger)
{
_usersContext = usersContext;
_dataContext = dataContext;
@@ -49,7 +47,6 @@ public sealed class AuthController : ControllerBase
_plexAuthService = plexAuthService;
_oidcAuthService = oidcAuthService;
_logger = logger;
_environment = environment;
}
[HttpGet("status")]
@@ -500,17 +497,7 @@ public sealed class AuthController : ControllerBase
return this.ProblemResult(StatusCodes.Status400BadRequest, "Plex login is not available");
}
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);
var pin = await _plexAuthService.RequestPin();
return Ok(new PlexPinStatusResponse
{
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
@@ -8,6 +9,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
@@ -35,6 +38,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.TriggerMode = TriggerMode;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
@@ -82,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
bool scheduleEnabled = config.Enabled &&
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
if (scheduleEnabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
/// <see cref="Unknown"/> and are ignored.
/// </summary>
public enum ArrWebhookEventType
{
Unknown = 0,
Test,
Grab,
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
/// </summary>
public sealed record ArrWebhookPayload
{
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
public string? EventType { get; init; }
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
public string? DownloadId { get; init; }
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
public ArrWebhookContent? Series { get; init; }
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
public ArrWebhookContent? Movie { get; init; }
}
public sealed record ArrWebhookContent
{
public long Id { get; init; }
}
@@ -0,0 +1,109 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
/// <summary>
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
/// be pasted directly into the *arr Webhook connection.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public sealed class WebhooksController : ControllerBase
{
private readonly ILogger<WebhooksController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public WebhooksController(
ILogger<WebhooksController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpPost("malware-blocker/{instanceId:guid}")]
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
{
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
// The Test button sends an event we acknowledge without doing any work.
if (eventType is ArrWebhookEventType.Test)
{
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
return Ok();
}
if (eventType is not ArrWebhookEventType.Grab)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
payload.EventType, instanceId);
return Ok();
}
ArrConfig? arrConfig;
ArrInstance? instance;
ContentBlockerConfig config;
await DataContext.Lock.WaitAsync();
try
{
arrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
}
finally
{
DataContext.Lock.Release();
}
if (arrConfig is null || instance is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
}
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
{
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
}
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
return Ok();
}
if (string.IsNullOrWhiteSpace(payload.DownloadId))
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
return Ok();
}
long contentId = arrConfig.Type switch
{
InstanceType.Sonarr => payload.Series?.Id ?? 0,
InstanceType.Radarr => payload.Movie?.Id ?? 0,
_ => 0,
};
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
return Ok();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
@@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterMalwareBlockerWebhookJob(cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
await RegisterSeekerJob(seekerConfig, cancellationToken);
@@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService
{
// Always register the job definition
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
// Only add the cron trigger when scheduling is part of the trigger mode
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
/// is received.
/// </summary>
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
}
/// <summary>
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
where T : IHandler
{
if (_scheduler == null)
{
throw new InvalidOperationException("Scheduler not initialized");
}
string typeName = typeof(T).Name;
string typeName = jobKeyName ?? typeof(T).Name;
var jobKey = new JobKey(typeName);
// Check if job already exists
if (await _scheduler.CheckExists(jobKey, cancellationToken))
{
@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : IJob
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
SetWebhookScanTarget(context);
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
@@ -75,6 +77,34 @@ public sealed class GenericJob<T> : IJob
}
}
/// <summary>
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
/// </summary>
private static void SetWebhookScanTarget(IJobExecutionContext context)
{
JobDataMap dataMap = context.MergedJobDataMap;
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
{
return;
}
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
{
return;
}
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
: 0;
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum JobTriggerMode
{
Schedule,
Webhook,
Both,
}
@@ -847,40 +847,4 @@ 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,71 +1343,4 @@ 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,32 +772,4 @@ 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,36 +1001,4 @@ 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,33 +708,4 @@ 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,33 +82,10 @@ 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()
{
@@ -44,7 +44,8 @@ public class MalwareBlockerIntegrationTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher);
_fixture.EventPublisher,
Substitute.For<Cleanuparr.Infrastructure.Services.Interfaces.IJobManagementService>());
}
[Fact]
@@ -70,6 +71,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = false
@@ -200,6 +202,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = true
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
@@ -49,7 +50,8 @@ public class MalwareBlockerTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher
_fixture.EventPublisher,
_fixture.JobManagementService
);
}
@@ -276,7 +278,7 @@ public class MalwareBlockerTests : IDisposable
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
@@ -339,6 +341,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -362,6 +365,215 @@ public class MalwareBlockerTests : IDisposable
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTarget_ScansOnlyMatchingDownload()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var matching = new QueueRecord { Id = 1, DownloadId = "match-hash", Title = "Match", Protocol = "torrent", SeriesId = 5, EpisodeId = 1 };
var other = new QueueRecord { Id = 2, DownloadId = "other-hash", Title = "Other", Protocol = "torrent", SeriesId = 5, EpisodeId = 2 };
_fixture.ArrQueueIterator
.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(),
Arg.Any<long?>()
)
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([matching, other]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "match-hash", 5, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockDownloadService.Received(1).BlockUnwantedFilesAsync("match-hash", Arg.Any<List<string>>());
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync("other-hash", Arg.Any<List<string>>());
// Found in a client -> resolved, no retry scheduled
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetNotFoundInClient_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "pending-hash", Title = "Pending", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent not (yet) present in any client
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "pending-hash", 7, InstanceType.Sonarr, RetryIndex: 1));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t =>
t.InstanceId == sonarrInstance.Id &&
t.DownloadId == "pending-hash" &&
t.ContentId == 7 &&
t.Type == InstanceType.Sonarr &&
t.RetryIndex == 1));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetMetadataMissing_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent found in the client, but its metadata/file list is not ready yet
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "metadl-hash", 7, InstanceType.Sonarr, RetryIndex: 0));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "usenet-id", Title = "Usenet", Protocol = "usenet", SeriesId = 9, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "usenet-id", 9, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: usenet is acknowledged once seen in the queue -> no scan, no retry
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>());
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
{
@@ -409,6 +621,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AtLeastOneFileBlocked
@@ -484,6 +697,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -665,6 +879,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -30,6 +31,7 @@ public class JobHandlerFixture : IDisposable
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IJobManagementService JobManagementService { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public FakeTimeProvider TimeProvider { get; private set; }
@@ -52,6 +54,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
TimeProvider = new FakeTimeProvider();
@@ -151,6 +154,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
Cache.Clear();
@@ -512,63 +512,4 @@ public class JobManagementServiceTests
}
#endregion
#region GetMainTrigger Tests
[Fact]
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(false);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
var trigger = Substitute.For<ITrigger>();
trigger.Key.Returns(expectedTriggerKey);
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(true);
_scheduler.GetTrigger(expectedTriggerKey, Arg.Any<CancellationToken>())
.Returns(trigger);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldNotBeNull();
result.Key.ShouldBe(expectedTriggerKey);
}
[Fact]
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
#endregion
}
@@ -33,11 +33,11 @@ public abstract class ArrClient : IArrClient
_dryRunInterceptor = dryRunInterceptor;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
uriBuilder.Query = GetQueueUrlQuery(page, contentId);
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -271,7 +271,7 @@ public abstract class ArrClient : IArrClient
protected abstract string GetQueueUrlPath();
protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueUrlQuery(int page, long? contentId = null);
protected abstract string GetQueueDeleteUrlPath(long recordId);
@@ -14,7 +14,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
_logger = logger;
}
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null)
{
const ushort maxPage = 100;
ushort page = 1;
@@ -23,7 +23,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
do
{
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page);
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page, contentId);
if (totalRecords is 0)
{
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
@@ -5,5 +5,5 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrQueueIterator
{
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null);
}
@@ -32,7 +32,7 @@ public class LidarrClient : ArrClient, ILidarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
@@ -32,9 +32,16 @@ public class RadarrClient : ArrClient, IRadarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
string query = $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
if (contentId is not null)
{
query += $"&movieIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -32,7 +32,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
@@ -34,9 +34,16 @@ public class SonarrClient : ArrClient, ISonarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
string query = $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
if (contentId is not null)
{
query += $"&seriesIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -34,7 +34,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
@@ -33,7 +33,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
@@ -22,22 +22,7 @@ public sealed record PlexAccountInfo
public interface IPlexAuthService
{
/// <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<PlexPinResult> RequestPin();
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(string? forwardUrl = null)
public async Task<PlexPinResult> RequestPin()
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{PlexApiBaseUrl}/pins");
AddPlexHeaders(request);
@@ -43,11 +43,6 @@ 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,6 +5,7 @@ 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;
@@ -154,9 +155,33 @@ public sealed class OrphanedFilesCleanupService : IOrphanedFilesCleanupService
return false;
}
foreach (string claimedPath in await downloadService.GetClaimedPathsAsync(torrents))
foreach (ITorrentItemWrapper torrent in torrents)
{
claimedPaths.Add(claimedPath);
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));
}
_logger.LogDebug("Loaded {count} torrents | {name}", torrents.Count, downloadClient.Name);
@@ -15,6 +15,11 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
/// <summary>
/// True when the torrent's file list (metadata) was available so the scan could complete (or was not needed).
/// </summary>
public bool MetadataFound { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}
@@ -32,15 +32,17 @@ public partial class DelugeService
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -55,11 +57,14 @@ public partial class DelugeService
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)
if (contents is null || contents.Contents?.Count is null or 0)
{
_logger.LogDebug("torrent has no files | {name}", download.Name);
return result;
}
result.MetadataFound = true;
Dictionary<int, int> priorities = [];
bool hasPriorityUpdates = false;
long totalFiles = 0;
@@ -41,27 +41,6 @@ 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,7 +11,6 @@ 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;
@@ -78,79 +77,6 @@ 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,12 +37,6 @@ 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>
@@ -30,6 +30,8 @@ public partial class QBitService
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.Found = true;
result.MetadataFound = true;
return result;
}
@@ -55,6 +57,7 @@ public partial class QBitService
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -66,6 +69,8 @@ public partial class QBitService
return result;
}
result.MetadataFound = true;
List<int> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -48,19 +48,6 @@ 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
@@ -36,6 +36,7 @@ public partial class RTorrentService
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -44,6 +45,7 @@ public partial class RTorrentService
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
{
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -64,6 +66,8 @@ public partial class RTorrentService
return result;
}
result.MetadataFound = true;
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -32,32 +32,6 @@ 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))))
@@ -17,27 +17,30 @@ public partial class TransmissionService
TorrentInfo? download = await GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.FileStats is null || download.FileStats.Length == 0)
if (download is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
if (download.Files is null)
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
if (download.FileStats?.Length is null or 0 || download.Files?.Length is null or 0)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("torrent has no files | {name}", download.Name);
return result;
}
result.MetadataFound = true;
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
return result;
}
bool isPrivate = download.IsPrivate ?? false;
result.IsPrivate = isPrivate;
result.Found = true;
SetDownloadClientContext();
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
@@ -48,7 +51,7 @@ public partial class TransmissionService
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<long> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
@@ -31,19 +31,6 @@ 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)
{
@@ -35,15 +35,17 @@ public partial class UTorrentService
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -55,6 +57,8 @@ public partial class UTorrentService
return result;
}
result.MetadataFound = true;
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
@@ -36,19 +36,6 @@ 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))))
@@ -87,8 +87,9 @@ public sealed class DownloadCleaner : GenericHandler
{
DownloadCleanerConfig config = ContextProvider.Get<DownloadCleanerConfig>();
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(config.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(config.IgnoredDownloads)
.ToList();
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap = new();
List<IDownloadService> loggedInServices = new();
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
@@ -21,6 +22,7 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
public sealed class MalwareBlocker : GenericHandler
{
private readonly IBlocklistProvider _blocklistProvider;
private readonly IJobManagementService _jobManagementService;
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
@@ -31,13 +33,15 @@ public sealed class MalwareBlocker : GenericHandler
IArrQueueIterator arrArrQueueIterator,
IDownloadServiceFactory downloadServiceFactory,
IBlocklistProvider blocklistProvider,
IEventPublisher eventPublisher
IEventPublisher eventPublisher,
IJobManagementService jobManagementService
) : base(
logger, dataContext, cache, messageBus,
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
)
{
_blocklistProvider = blocklistProvider;
_jobManagementService = jobManagementService;
}
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
@@ -62,6 +66,12 @@ public sealed class MalwareBlocker : GenericHandler
await _blocklistProvider.LoadBlocklistsAsync();
if (ContextProvider.Get(nameof(WebhookScanTarget)) is WebhookScanTarget webhookTarget)
{
await ProcessWebhookTargetAsync(malwareBlockerConfig, webhookTarget);
return;
}
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
@@ -94,10 +104,52 @@ public sealed class MalwareBlocker : GenericHandler
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
/// <summary>
/// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating instance, instead of iterating the whole queue.
/// Schedules the next retry only when the download was not yet found/scanned.
/// </summary>
private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
BlocklistSettings? blocklist = target.Type switch
{
InstanceType.Sonarr => config.Sonarr,
InstanceType.Radarr => config.Radarr,
_ => null,
};
if (blocklist is null || !blocklist.Enabled)
{
_logger.LogDebug("skip webhook scan | blocklist for {type} is not enabled", target.Type);
return;
}
ArrConfig arrConfig = ContextProvider.Get<ArrConfig>(target.Type.ToString());
ArrInstance? instance = arrConfig.Instances
.FirstOrDefault(x => x.Id == target.InstanceId && x.Enabled);
if (instance is null)
{
_logger.LogWarning("skip webhook scan | instance {id} not found or disabled", target.InstanceId);
return;
}
instance.ArrConfig = arrConfig;
bool resolved = await ScanInstanceAsync(instance, target);
if (!resolved)
{
await _jobManagementService.ScheduleMalwareBlockerWebhookRetry(target);
}
}
protected override Task ProcessInstanceAsync(ArrInstance instance) => ScanInstanceAsync(instance);
private async Task<bool> ScanInstanceAsync(ArrInstance instance, WebhookScanTarget? target = null)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -111,8 +163,10 @@ public sealed class MalwareBlocker : GenericHandler
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
var config = ContextProvider.Get<ContentBlockerConfig>();
ContentBlockerConfig config = ContextProvider.Get<ContentBlockerConfig>();
bool targetResolved = false;
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
@@ -129,108 +183,148 @@ public sealed class MalwareBlocker : GenericHandler
continue;
}
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
continue;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
continue;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
continue;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
BlockFilesResult result = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
DownloadClientConfig? foundInClient = null;
if (isTorrent)
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
// stalled download check
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
if (target is not null &&
!string.Equals(record.DownloadId, target.DownloadId, StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
bool removeFromClient = true;
bool resolved = await TryProcessRecordAsync(group, instance, arrClient, downloadServices, ignoredDownloads, config);
if (result.IsPrivate && !config.DeletePrivate)
if (target is not null)
{
removeFromClient = false;
targetResolved = resolved;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
});
}, contentId: target is { ContentId: > 0 } ? target.ContentId : null);
return targetResolved;
}
/// <summary>
/// Scans a single grouped download. Returns <c>true</c> when the download is resolved — found in a
/// client and evaluated, a usenet record, or deliberately skipped — and <c>false</c> only when it is
/// a torrent that was not found in any download client yet (the case a webhook scan should retry).
/// </summary>
private async Task<bool> TryProcessRecordAsync(
IGrouping<string, QueueRecord> group,
ArrInstance instance,
IArrClient arrClient,
IReadOnlyList<IDownloadService> downloadServices,
List<string> ignoredDownloads,
ContentBlockerConfig config)
{
QueueRecord record = group.First();
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
return true;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
return true;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
return true;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (!isTorrent)
{
// Usenet is acknowledged once it appears in the queue; nothing to scan, no retry.
return true;
}
BlockFilesResult result = new();
DownloadClientConfig? foundInClient = null;
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
if (!result.Found || !result.MetadataFound)
{
// Retry while the torrent is not yet in a client, or is present but its file list/metadata isn't ready.
return false;
}
if (result.ShouldRemove)
{
bool removeFromClient = true;
if (result.IsPrivate && !config.DeletePrivate)
{
removeFromClient = false;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
return true;
}
}
@@ -81,9 +81,10 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(queueCleanerConfig.IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Jobs;
/// <summary>
/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an *arr "On Grab" webhook.
/// </summary>
public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type, int RetryIndex = 0)
{
public const string InstanceIdKey = "webhook.instanceId";
public const string DownloadIdKey = "webhook.downloadId";
public const string ContentIdKey = "webhook.contentId";
public const string InstanceTypeKey = "webhook.instanceType";
public const string RetryIndexKey = "webhook.retryIndex";
}
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Quartz;
@@ -9,8 +10,19 @@ public interface IJobManagementService
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
Task<bool> StopJob(JobType jobType);
Task<bool> TriggerJobOnce(JobType jobType);
/// <summary>
/// Schedules the first targeted MalwareBlocker scan for a single download received via an *arr "On Grab" webhook.
/// Subsequent retries are scheduled by the handler via <see cref="ScheduleMalwareBlockerWebhookRetry"/> only while the download has not been found.
/// </summary>
Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
/// <summary>
/// Schedules the next targeted MalwareBlocker webhook scan after a completed attempt.
/// </summary>
Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
Task<ITrigger?> GetMainTrigger(JobType jobType);
}
@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Quartz;
@@ -168,34 +170,6 @@ public class JobManagementService : IJobManagementService
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
}
}
/// <summary>
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
/// </summary>
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
return null;
}
// Look for the main trigger (follows our naming convention)
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
return await scheduler.GetTrigger(mainTriggerKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
return null;
}
}
public async Task<bool> StopJob(JobType jobType)
{
@@ -368,6 +342,71 @@ public class JobManagementService : IJobManagementService
}
}
public Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type)
{
return ScheduleWebhookAttempt(instanceId, downloadId, contentId, type, attemptIndex: 0);
}
public Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target)
{
int nextIndex = target.RetryIndex + 1;
if (nextIndex >= Constants.MalwareBlockerWebhookRetryDelays.Count)
{
_logger.LogDebug(
"{name} webhook scan gave up for download {downloadId} on {type} instance {instanceId} after {attemptCount} attempts",
nameof(JobType.MalwareBlocker), target.DownloadId, target.Type, target.InstanceId, Constants.MalwareBlockerWebhookRetryDelays.Count);
return Task.FromResult(false);
}
return ScheduleWebhookAttempt(target.InstanceId, target.DownloadId, target.ContentId, target.Type, nextIndex);
}
private async Task<bool> ScheduleWebhookAttempt(Guid instanceId, string downloadId, long contentId, InstanceType type, int attemptIndex)
{
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(Constants.MalwareBlockerWebhookJobKey);
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist", Constants.MalwareBlockerWebhookJobKey);
return false;
}
TimeSpan delay = Constants.MalwareBlockerWebhookRetryDelays[attemptIndex];
var jobData = new JobDataMap
{
{ WebhookScanTarget.InstanceIdKey, instanceId.ToString() },
{ WebhookScanTarget.DownloadIdKey, downloadId },
{ WebhookScanTarget.ContentIdKey, contentId },
{ WebhookScanTarget.InstanceTypeKey, type.ToString() },
{ WebhookScanTarget.RetryIndexKey, attemptIndex },
};
var trigger = TriggerBuilder.Create()
.WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{attemptIndex}-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.UsingJobData(jobData)
.StartAt(DateTimeOffset.UtcNow.Add(delay))
.Build();
await scheduler.ScheduleJob(trigger);
_logger.LogInformation(
"MalwareBlocker webhook scan attempt {attempt} scheduled (in {delay}s) for download {downloadId} on {type} instance {instanceId}",
attemptIndex, (int)delay.TotalSeconds, downloadId, type, instanceId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scheduling MalwareBlocker webhook scan for instance {instanceId}", instanceId);
return false;
}
}
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
@@ -2,7 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.MalwareBlocker;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddMalwareBlockerTriggerMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "trigger_mode",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "Schedule");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "trigger_mode",
table: "content_blocker_configs");
}
}
}
@@ -816,6 +816,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("process_no_content_id");
b.Property<string>("TriggerMode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("trigger_mode");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
@@ -1079,7 +1084,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1130,7 +1136,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTimeOffset>("UpdatedAt")
b.Property<string>("UpdatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("updated_at");
@@ -1577,7 +1584,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastProcessedAt")
b.Property<string>("LastProcessedAt")
.HasColumnType("TEXT")
.HasColumnName("last_processed_at");
@@ -1688,11 +1695,12 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSyncedAt")
b.Property<string>("LastSyncedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_synced_at");
b.Property<DateTimeOffset?>("LastUpgradedAt")
b.Property<string>("LastUpgradedAt")
.HasColumnType("TEXT")
.HasColumnName("last_upgraded_at");
@@ -1747,7 +1755,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("RecordedAt")
b.Property<string>("RecordedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("recorded_at");
@@ -1783,7 +1792,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1828,7 +1838,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("command_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1896,7 +1907,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSearchedAt")
b.Property<string>("LastSearchedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_searched_at");
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
using Cleanuparr.Domain.Enums;
using ValidationException = System.ComponentModel.DataAnnotations.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
@@ -11,9 +12,11 @@ public sealed record ContentBlockerConfig : IJobConfig
public Guid Id { get; set; } = Guid.NewGuid();
public bool Enabled { get; set; }
public JobTriggerMode TriggerMode { get; set; } = JobTriggerMode.Schedule;
public string CronExpression { get; set; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; set; }
public bool IgnorePrivate { get; set; }
@@ -22,4 +22,24 @@ public static class Constants
public const string LogoUrl = "https://cdn.jsdelivr.net/gh/Cleanuparr/Cleanuparr@main/Logo/48.png";
public const string CustomFormatScoreSyncerCron = "0 0/30 * * * ?";
/// <summary>
/// Quartz JobKey for webhook-triggered MalwareBlocker runs. Distinct from the cron JobKey
/// ("MalwareBlocker") so the two have independent DisallowConcurrentExecution locks.
/// </summary>
public const string MalwareBlockerWebhookJobKey = "MalwareBlockerWebhook";
/// <summary>
/// Delays (relative to receiving the "On Grab" webhook) at which the targeted MalwareBlocker scan
/// is run. The first run is immediate; later runs catch torrents whose file metadata was not yet
/// available. Once a download is acted on, retries become safe no-ops.
/// </summary>
public static readonly IReadOnlyList<TimeSpan> MalwareBlockerWebhookRetryDelays =
[
TimeSpan.Zero,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(60)
];
}
-1
View File
@@ -53,7 +53,6 @@
}
],
"styles": [
"node_modules/@angular/cdk/overlay-prebuilt.css",
"src/styles.scss"
],
"stylePreprocessorOptions": {
-37
View File
@@ -1,37 +0,0 @@
// @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: {},
},
);
+1989 -1570
View File
File diff suppressed because it is too large. Load diff
+23 -25
View File
@@ -5,8 +5,7 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"lint": "eslint ."
"watch": "ng build --watch --configuration development"
},
"prettier": {
"printWidth": 100,
@@ -22,40 +21,39 @@
},
"private": true,
"packageManager": "npm@11.6.2",
"engines": {
"node": ">=26"
},
"overrides": {
"angularx-qrcode": {
"@angular/common": "$@angular/common",
"@angular/core": "$@angular/core"
}
},
"dependencies": {
"@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",
"@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",
"@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/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",
"@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",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"typescript": "~6.0.3",
"typescript-eslint": "^8.62.1"
"typescript": "~5.9.2"
}
}
+3 -3
View File
@@ -1,6 +1,7 @@
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,
@@ -64,9 +65,8 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(
withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor]),
),
provideHttpClient(withInterceptors([baseUrlInterceptor, authInterceptor, errorInterceptor])),
provideAnimationsAsync(),
provideIcons({
tablerLayoutDashboard,
tablerFileText,
-7
View File
@@ -166,13 +166,6 @@ export const routes: Routes = [
'@features/auth/oidc-callback/oidc-callback.component'
).then((m) => m.OidcCallbackComponent),
},
{
path: 'plex/callback',
loadComponent: () =>
import(
'@features/auth/plex-callback/plex-callback.component'
).then((m) => m.PlexCallbackComponent),
},
],
},
{ path: '**', redirectTo: 'dashboard' },
+1 -2
View File
@@ -1,4 +1,4 @@
import { Component, inject, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Component, inject, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ThemeService } from '@core/services/theme.service';
import { AuthService } from '@core/auth/auth.service';
@@ -8,7 +8,6 @@ import { ToastContainerComponent, ConfirmDialogComponent } from '@ui';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, ToastContainerComponent, ConfirmDialogComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<router-outlet />
<app-toast-container />
@@ -2,7 +2,6 @@ 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);
@@ -25,9 +24,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
});
return next(freshReq);
}
if (!auth.hasRefreshToken()) {
auth.logout();
}
auth.logout();
return throwError(() => new HttpErrorResponse({ status: 401 }));
}),
);
@@ -42,9 +39,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
}
return next(req).pipe(
catchError((error) => {
catchError((error: HttpErrorResponse) => {
// Fallback: 401 catch for edge cases (e.g., token expired between check and send)
if ((error as ApiError).statusCode === 401 && token && !req.context.get(IS_RETRY)) {
if (error.status === 401 && token && !req.context.get(IS_RETRY)) {
return auth.refreshToken().pipe(
switchMap((result) => {
if (result) {
@@ -54,9 +51,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
});
return next(retryReq);
}
if (!auth.isAuthenticated()) {
auth.logout();
}
auth.logout();
return throwError(() => error);
}),
);
@@ -2,7 +2,6 @@ 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;
@@ -214,10 +213,8 @@ export class AuthService {
.post<TokenResponse>('/api/auth/refresh', { refreshToken: storedRefreshToken })
.pipe(
tap((tokens) => this.handleTokens(tokens)),
catchError((err) => {
if ((err as ApiError).statusCode === 401) {
this.clearAuth();
}
catchError(() => {
this.clearAuth();
return of(null);
}),
finalize(() => {
@@ -232,12 +229,7 @@ export class AuthService {
logout(): void {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
// 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.http.post('/api/auth/logout', { refreshToken }).subscribe();
}
this.clearAuth();
this.router.navigate(['/auth/login']);
@@ -247,11 +239,6 @@ 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: '[appStickyAware]',
selector: '[stickyAware]',
standalone: true,
})
export class StickyAwareDirective implements OnInit, OnDestroy {
@@ -101,12 +101,8 @@ export abstract class HubService implements OnDestroy {
return this.connection.invoke(method, ...args);
}
protected onConnected(): void {
// Optional hook for subclasses.
}
protected onReconnected(): void {
// Optional hook for subclasses.
}
protected onConnected(): void {}
protected onReconnected(): void {}
ngOnDestroy(): void {
this.stop();
@@ -7,7 +7,7 @@ export class ApplicationPathService {
if (isDevMode()) {
return 'http://localhost:5000';
}
return (window as unknown as { _server_base_path?: string })._server_base_path || '/';
return (window as any)['_server_base_path'] || '/';
}
getDocumentationBaseUrl(): string {
@@ -1,7 +1,9 @@
import { inject, Injectable } from '@angular/core';
import { ApplicationPathService } from './base-path.service';
type FieldMappings = Record<string, Record<string, string>>;
interface FieldMappings {
[section: string]: { [field: string]: string };
}
@Injectable({ providedIn: 'root' })
export class DocumentationService {
@@ -84,6 +86,8 @@ 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',
@@ -100,6 +104,7 @@ export class DocumentationService {
},
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'triggerMode': 'trigger-mode',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
@@ -126,8 +131,6 @@ 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',
@@ -1,52 +0,0 @@
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,6 +27,7 @@ export class PaginationService {
key: string,
pageSize: WritableSignal<number>,
currentPage: WritableSignal<number>,
reload: () => void,
): (size: number) => void {
return (size: number) => {
if (!this.isValidPageSize(size)) {
@@ -35,6 +36,7 @@ export class PaginationService {
this.setPageSize(key, size);
pageSize.set(size);
currentPage.set(1);
reload();
};
}
@@ -38,7 +38,7 @@
}
.retry-countdown {
background: var(--color-warning-bg);
background: rgba(234, 179, 8, 0.1);
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: var(--plex-brand-ink);
background: var(--plex-brand);
color: #1a1a2e;
background: #e5a00d;
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
&:hover:not(:disabled) {
background: var(--plex-brand-hover);
box-shadow: 0 0 20px color-mix(in srgb, var(--plex-brand) 30%, transparent);
background: #d4920c;
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
}
&:active:not(:disabled) {
@@ -40,6 +40,7 @@ export class LoginComponent implements OnInit, OnDestroy {
// Plex
plexLinked = this.auth.plexLinked;
plexLoading = signal(false);
plexPinId = signal(0);
// OIDC
oidcEnabled = this.auth.oidcEnabled;
@@ -87,6 +88,9 @@ export class LoginComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.clearCountdown();
if (this.plexPollTimer) {
clearInterval(this.plexPollTimer);
}
}
submitLogin(): void {
@@ -162,14 +166,17 @@ 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) => {
sessionStorage.setItem('plex_login_pin_id', String(result.pinId));
window.location.href = result.authUrl;
this.plexPinId.set(result.pinId);
window.open(result.authUrl, '_blank');
this.pollPlexPin();
},
error: (err) => {
this.error.set(err.message || 'Failed to start Plex login');
@@ -194,6 +201,34 @@ 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);
@@ -1,115 +0,0 @@
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: var(--plex-brand-ink);
background: var(--plex-brand);
color: #1a1a2e;
background: #e5a00d;
border: 1px solid transparent;
border-radius: var(--radius-lg);
cursor: pointer;
@@ -339,8 +339,8 @@
margin-bottom: var(--space-4);
&:hover:not(:disabled) {
background: var(--plex-brand-hover);
box-shadow: 0 0 20px color-mix(in srgb, var(--plex-brand) 30%, transparent);
background: #d4920c;
box-shadow: 0 0 20px rgba(229, 160, 13, 0.3);
}
&:active:not(:disabled) {
@@ -1,10 +1,9 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, DestroyRef } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } 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';
@@ -19,11 +18,10 @@ import { forkJoin, timer } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [provideIcons({ tablerCheck, tablerCopy, tablerShieldLock })],
})
export class SetupComponent {
export class SetupComponent implements OnDestroy {
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);
@@ -184,47 +182,57 @@ export class SetupComponent {
// 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);
if (authWindow) {
authWindow.location.href = result.authUrl;
} else {
window.open(result.authUrl, '_blank');
}
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 {
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: () => {
let attempts = 0;
this.plexPollTimer = setInterval(() => {
attempts++;
if (attempts > 60) {
// Timeout after ~2 minutes
clearInterval(this.plexPollTimer!);
this.plexLinking.set(false);
this.error.set('Plex authorization timed out');
},
destroyRef: this.destroyRef,
});
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);
}
completeSetup(): void {
@@ -1,4 +1,3 @@
@use 'responsive' as *;
@use 'page-animations' as *;
// Support section
@@ -8,7 +7,7 @@
gap: var(--space-6);
margin-bottom: var(--space-6);
@include tablet {
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
@@ -130,7 +129,7 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6);
@include tablet {
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
@@ -183,7 +182,7 @@
grid-template-columns: repeat(4, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-4);
@include mobile { grid-template-columns: repeat(2, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); }
}
&__stat {
@@ -433,7 +432,7 @@
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, var(--color-primary-subtle), rgba(var(--accent-rgb), 0.15), transparent);
background: linear-gradient(90deg, var(--color-primary-subtle), rgba(59, 130, 246, 0.15), transparent);
pointer-events: none;
}
@@ -636,12 +635,12 @@
gap: var(--space-2);
min-width: 200px;
@include tablet {
@media (max-width: 1024px) {
min-width: auto;
}
}
@include tablet {
@media (max-width: 1024px) {
&__row {
flex-wrap: wrap;
gap: var(--space-2);
@@ -684,7 +683,7 @@
}
// Mobile compact dashboard
@include mobile {
@media (max-width: 768px) {
.card-inner {
min-height: 240px;
}
@@ -1,6 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { Component, ChangeDetectionStrategy, inject, computed, signal, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { DatePipe, JsonPipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
@@ -11,8 +9,9 @@ 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, CfScoreUpgradesResponse } from '@core/api/cf-score.api';
import { CfScoreApi, CfScoreStats, CfScoreUpgrade } 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';
@@ -41,7 +40,7 @@ type DashboardRowId = typeof DEFAULT_ROW_ORDER[number];
styleUrl: './dashboard.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
export class DashboardComponent implements OnInit {
readonly JobType = JobType;
private readonly hub = inject(AppHubService);
@@ -54,24 +53,9 @@ export class DashboardComponent {
readonly connected = this.hub.isConnected;
readonly jobs = this.hub.jobs;
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 showSupportSection = signal(false);
readonly cfScoreStats = signal<CfScoreStats | null>(null);
readonly cfScoreUpgrades = signal<CfScoreUpgrade[]>([]);
readonly rowOrder = signal<DashboardRowId[]>(this.loadOrder());
readonly visibleRowOrder = computed(() => {
@@ -100,6 +84,22 @@ export class DashboardComponent {
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" appStickyAware>
<div class="toolbar" stickyAware>
<div class="toolbar__filters">
<app-select
placeholder="All Severities"
@@ -84,13 +84,9 @@
<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()" (keydown.enter)="$event.stopPropagation()" (keydown.space)="$event.stopPropagation()" title="Copy event">
<button class="event-row__copy" (click)="copyEvent(event); $event.stopPropagation()" title="Copy event">
<ng-icon name="tablerCopy" />
</button>
<span class="event-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</span>
@@ -1,4 +1,3 @@
@use 'responsive' as *;
@use 'data-toolbar' as *;
@use 'page-animations' as *;
@@ -280,7 +279,7 @@
}
// Tablet responsiveness
@include tablet {
@media (max-width: 1024px) {
.toolbar__filters {
app-input {
min-width: 0;
@@ -297,7 +296,7 @@
}
// Mobile responsiveness
@include mobile {
@media (max-width: 768px) {
.toolbar__filters {
app-input {
min-width: 0;
@@ -1,5 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit, OnDestroy } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
@@ -15,7 +14,6 @@ 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',
@@ -49,6 +47,9 @@ 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);
@@ -61,7 +62,22 @@ export class EventsComponent implements OnInit, OnDestroy {
readonly fromDate = signal('');
readonly toDate = signal('');
private readonly eventFilter = computed<EventFilter>(() => {
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 {
const filter: EventFilter = {
page: this.currentPage(),
pageSize: this.pageSize(),
@@ -71,86 +87,64 @@ 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;
}
return filter;
});
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;
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.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);
this.toast.error('Failed to load events');
}
},
});
}
ngOnInit(): void {
this.pollTimer = setInterval(() => this.eventsResource.reload(), 10_000);
}
ngOnDestroy(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
}
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 })),
]);
},
});
}
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 {
@@ -168,17 +162,19 @@ export class EventsComponent implements OnInit, OnDestroy {
}
refresh(): void {
this.eventsResource.reload();
this.loadEvents();
}
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" appStickyAware>
<div class="toolbar" stickyAware>
<div class="toolbar__filters">
<app-select
placeholder="All Levels"
@@ -83,13 +83,9 @@
<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()" (keydown.enter)="$event.stopPropagation()" (keydown.space)="$event.stopPropagation()" title="Copy log">
<button class="log-entry__copy" (click)="copyLog(log); $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,4 +1,3 @@
@use 'responsive' as *;
@use 'data-toolbar' as *;
@use 'page-animations' as *;
@@ -249,7 +248,7 @@
}
// Tablet responsiveness
@include tablet {
@media (max-width: 1024px) {
.toolbar__filters {
app-input {
min-width: 0;
@@ -262,7 +261,7 @@
}
// Mobile responsiveness
@include mobile {
@media (max-width: 768px) {
.toolbar__filters {
app-input {
min-width: 0;
@@ -1,5 +1,5 @@
<!-- Toolbar -->
<div class="toolbar" appStickyAware>
<div class="toolbar" stickyAware>
<div class="toolbar__filters">
<app-input
placeholder="Search by title..."
@@ -74,11 +74,7 @@
>
<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">
@@ -102,14 +98,7 @@
class="score-row__chevron"
/>
</div>
<div
class="score-row__title"
role="button"
tabindex="0"
(click)="toggleExpand(item)"
(keydown.enter)="toggleExpand(item)"
(keydown.space)="toggleExpand(item)"
>
<div class="score-row__title" (click)="toggleExpand(item)">
{{ item.title }}
</div>
@@ -187,8 +176,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))"
@@ -196,8 +185,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))"
@@ -205,8 +194,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))"
@@ -214,8 +203,8 @@
</div>
<div class="filter-group">
<label class="filter-group__label">Monitored</label>
<app-select
label="Monitored"
[value]="draft().monitoredFilter"
[options]="monitoredOptions"
(valueChange)="updateDraft('monitoredFilter', $any($event))"
@@ -1,4 +1,3 @@
@use 'responsive' as *;
@use 'data-toolbar' as *;
@use 'page-animations' as *;
@@ -235,7 +234,7 @@
}
// Tablet
@include tablet {
@media (max-width: 1024px) {
.score-row__main {
flex-wrap: wrap;
}
@@ -246,7 +245,7 @@
}
// Mobile
@include mobile {
@media (max-width: 768px) {
.stats-bar {
flex-wrap: wrap;
gap: var(--space-3);
@@ -1,6 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -12,7 +10,6 @@ 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';
@@ -59,7 +56,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
styleUrl: './quality-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QualityTabComponent {
export class QualityTabComponent implements OnInit {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-quality';
private readonly api = inject(CfScoreApi);
@@ -67,11 +64,19 @@ export class QualityTabComponent {
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);
@@ -94,46 +99,6 @@ export class QualityTabComponent {
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;
@@ -195,48 +160,98 @@ export class QualityTabComponent {
this.initialLoad = false;
return;
}
this.scoresResource.reload();
this.statsResource.reload();
untracked(() => {
this.loadScores();
this.loadStats();
});
});
effect(() => {
if (this.scoresResource.error()) {
}
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);
this.toast.error('Failed to load CF scores');
}
},
});
effect(() => {
if (this.statsResource.error()) {
this.toast.error('Failed to load CF score stats');
}
}
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.instancesResource.error()) {
this.toast.error('Failed to load instances');
}
}
private loadStats(): void {
this.api.getStats().subscribe({
next: (stats) => this.stats.set(stats),
error: () => this.toast.error('Failed to load CF score stats'),
});
}
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 {
@@ -262,6 +277,7 @@ export class QualityTabComponent {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.currentPage.set(1);
this.loadScores();
}
private collectProfilesFor(instanceId: string): Set<string> {
@@ -280,8 +296,8 @@ export class QualityTabComponent {
}
refresh(): void {
this.scoresResource.reload();
this.statsResource.reload();
this.loadScores();
this.loadStats();
}
toggleExpand(item: CfScoreEntry): void {
@@ -101,7 +101,7 @@
}
<!-- Toolbar -->
<div class="toolbar" appStickyAware>
<div class="toolbar" stickyAware>
<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">
<span class="filter-group__label">Instance</span>
<label class="filter-group__label">Instance</label>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -209,7 +209,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Cycle</span>
<label class="filter-group__label">Cycle</label>
<app-select
[value]="draft().cycleFilter"
[options]="cycleFilterOptions"
@@ -222,7 +222,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Status</span>
<label class="filter-group__label">Status</label>
<div class="chip-group">
@for (opt of statusOptions; track opt.value) {
<button
@@ -237,7 +237,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Search type</span>
<label class="filter-group__label">Search type</label>
<app-select
[value]="draft().searchType"
[options]="searchTypeOptions"
@@ -246,7 +246,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Search reason</span>
<label class="filter-group__label">Search reason</label>
<app-select
[value]="draft().searchReason"
[options]="searchReasonOptions"
@@ -255,7 +255,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Grabbed</span>
<label class="filter-group__label">Grabbed</label>
<app-select
[value]="draft().grabbed"
[options]="triStateOptions"
@@ -1,4 +1,3 @@
@use 'responsive' as *;
@use 'data-toolbar' as *;
@use 'page-animations' as *;
@@ -389,7 +388,7 @@
}
// Tablet
@include tablet {
@media (max-width: 1024px) {
.list-row__main {
flex-wrap: wrap;
}
@@ -400,7 +399,7 @@
}
// Mobile
@include mobile {
@media (max-width: 768px) {
.stats-bar {
flex-wrap: wrap;
gap: var(--space-3);
@@ -1,6 +1,4 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import type { Observable } from 'rxjs';
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
import { DatePipe } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import {
@@ -12,9 +10,7 @@ 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';
@@ -45,7 +41,7 @@ const EMPTY_FILTERS: AdvancedFilters = {
grabbed: 'any',
};
const STATUS_OPTIONS: readonly { value: SearchCommandStatus; label: string }[] = [
const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string }> = [
{ value: SearchCommandStatus.Started, label: 'Started' },
{ value: SearchCommandStatus.Completed, label: 'Completed' },
{ value: SearchCommandStatus.Failed, label: 'Failed' },
@@ -74,7 +70,7 @@ const STATUS_OPTIONS: readonly { value: SearchCommandStatus; label: string }[] =
styleUrl: './searches-tab.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchesTabComponent {
export class SearchesTabComponent implements OnInit {
private static readonly PAGE_SIZE_KEY = 'cleanuparr-page-size-seeker-searches';
private readonly api = inject(SearchStatsApi);
@@ -82,13 +78,10 @@ export class SearchesTabComponent {
private readonly toast = inject(ToastService);
private readonly pagination = inject(PaginationService);
private initialLoad = true;
private latestLoadToken = 0;
private readonly summaryResource = rxResource({
stream: (): Observable<SearchStatsSummary | null> => this.api.getSummary(),
defaultValue: null,
});
readonly summary = computed(() => this.summaryResource.value());
readonly summary = signal<SearchStatsSummary | null>(null);
readonly loading = signal(false);
readonly sortedInstanceStats = computed(() =>
[...(this.summary()?.perInstanceStats ?? [])].sort((a, b) => {
@@ -98,12 +91,7 @@ export class SearchesTabComponent {
);
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = computed<SelectOption[]>(() => {
return [
{ label: 'All Instances', value: '' },
...(this.summaryResource.value()?.perInstanceStats ?? []).map((st) => ({ label: st.instanceName, value: st.instanceId })),
];
});
readonly instanceOptions = signal<SelectOption[]>([]);
readonly searchQuery = signal('');
@@ -115,46 +103,11 @@ export class SearchesTabComponent {
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 },
@@ -213,43 +166,45 @@ export class SearchesTabComponent {
this.initialLoad = false;
return;
}
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');
}
untracked(() => {
this.loadSummary();
this.loadEvents();
});
});
}
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 {
@@ -267,6 +222,7 @@ export class SearchesTabComponent {
this.selectedInstanceId.set(draft.instanceId);
this.drawerOpen.set(false);
this.eventsPage.set(1);
this.loadEvents();
}
toggleStatus(value: SearchCommandStatus): void {
@@ -293,8 +249,8 @@ export class SearchesTabComponent {
}
refresh(): void {
this.summaryResource.reload();
this.eventsResource.reload();
this.loadSummary();
this.loadEvents();
}
searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' {
@@ -369,4 +325,62 @@ export class SearchesTabComponent {
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,6 +7,8 @@ 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" appStickyAware>
<div class="toolbar" stickyAware>
<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">
<span class="filter-group__label">Instance</span>
<label class="filter-group__label">Instance</label>
<app-select
[value]="draft().instanceId"
[options]="instanceOptions()"
@@ -95,7 +95,7 @@
</div>
<div class="filter-group">
<span class="filter-group__label">Time range</span>
<label class="filter-group__label">Time range</label>
<app-select
[value]="draft().timeRange"
[options]="timeRangeOptions"
@@ -1,4 +1,3 @@
@use 'responsive' as *;
@use 'data-toolbar' as *;
@use 'page-animations' as *;
@@ -126,14 +125,14 @@
}
// Tablet
@include tablet {
@media (max-width: 1024px) {
.upgrade-row__main {
flex-wrap: wrap;
}
}
// Mobile
@include mobile {
@media (max-width: 768px) {
.upgrade-row__main {
flex-wrap: wrap;
padding: var(--space-2) var(--space-3);
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more