mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-12 13:27:16 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c5f23cf | ||
|
|
9eebeed990 | ||
|
|
a79a60a339 | ||
|
|
d1bd9fddcc | ||
|
|
96823adcc3 | ||
|
|
e0e88147aa | ||
|
|
f278a0dad0 | ||
|
|
f61300b869 | ||
|
|
561c05778c | ||
|
|
60d273991d | ||
|
|
ddb1042ca5 | ||
|
|
9a31e86ad8 | ||
|
|
614e97313e | ||
|
|
a34a3d3c7e | ||
|
|
f9588d89c0 | ||
|
|
eacd9346a5 | ||
|
|
0561c64ddf | ||
|
|
304a8e78ee | ||
|
|
e008b64a1d | ||
|
|
4f7e2d33b4 | ||
|
|
b1b19e5f29 | ||
|
|
40ab0e9fad |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
{
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
+5
-1
@@ -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,
|
||||
}
|
||||
-36
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-28
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-32
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-29
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-23
@@ -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()
|
||||
{
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+27
-2
@@ -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;
|
||||
}
|
||||
+9
-4
@@ -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;
|
||||
|
||||
-21
@@ -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>
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
-13
@@ -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
|
||||
|
||||
+4
@@ -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;
|
||||
|
||||
-26
@@ -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))))
|
||||
|
||||
+10
-7
@@ -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;
|
||||
|
||||
-13
@@ -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)
|
||||
{
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
|
||||
-13
@@ -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";
|
||||
}
|
||||
+13
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
+2267
File diff suppressed because it is too large.
Load diff
+29
@@ -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");
|
||||
|
||||
|
||||
+6
-3
@@ -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)
|
||||
];
|
||||
}
|
||||
@@ -53,7 +53,6 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@angular/cdk/overlay-prebuilt.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"stylePreprocessorOptions": {
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
);
|
||||
Generated
+1989
-1570
File diff suppressed because it is too large.
Load diff
+23
-25
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
+7
-7
@@ -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"
|
||||
|
||||
+2
-3
@@ -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);
|
||||
|
||||
+84
-70
@@ -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,
|
||||
|
||||
+3
-3
@@ -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"
|
||||
|
||||
+2
-3
@@ -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
Reference in new issue
Block a user