Compare commits

...
Author SHA1 Message Date
Flaminel ad8c5f23cf fixed some early returns causing a retry 2026-06-17 15:47:06 +03:00
Flaminel 9eebeed990 fixed log again 2026-06-17 15:46:32 +03:00
Flaminel a79a60a339 fixed ignored downloads bloating with each run 2026-06-17 15:27:32 +03:00
Flaminel d1bd9fddcc retried webhook malware scans while the torrent metadata is not yet available 2026-06-17 15:06:25 +03:00
Flaminel 96823adcc3 reworked webhooks controller to ProblemDetails errors and [controller] route 2026-06-17 12:14:31 +03:00
Flaminel e0e88147aa added more retries 2026-06-17 12:05:45 +03:00
Flaminel f278a0dad0 fixed log 2026-06-17 12:05:35 +03:00
Flaminel f61300b869 fixed docs link 2026-06-16 23:51:01 +03:00
Flaminel 561c05778c showed only the instance id on the arr card instead of the full webhook url 2026-06-16 23:47:11 +03:00
Flaminel 60d273991d used an enum for arr webhook event type instead of hardcoded strings 2026-06-16 23:43:18 +03:00
Flaminel ddb1042ca5 mapped malware-blocker triggerMode help key to its docs anchor 2026-06-16 23:29:07 +03:00
Flaminel 9a31e86ad8 removed unused method 2026-06-16 23:21:56 +03:00
Flaminel 614e97313e passed WebhookScanTarget to ScheduleMalwareBlockerWebhookRetry instead of individual fields 2026-06-16 23:21:56 +03:00
Flaminel a34a3d3c7e made MalwareBlocker webhook retries conditional on the download not being found 2026-06-16 23:21:56 +03:00
Flaminel f9588d89c0 changed some stuff 2026-06-16 23:21:56 +03:00
Flaminel eacd9346a5 generalized MalwareBlockerTriggerMode to JobTriggerMode 2026-06-16 23:21:56 +03:00
Flaminel 0561c64ddf added tests for MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel 304a8e78ee documented MalwareBlocker webhook triggering 2026-06-16 23:21:56 +03:00
Flaminel e008b64a1d added MalwareBlocker webhook frontend (trigger mode and per-instance URL) 2026-06-16 23:21:56 +03:00
Flaminel 4f7e2d33b4 added webhook-triggered targeted MalwareBlocker scan 2026-06-16 23:21:56 +03:00
Flaminel b1b19e5f29 added content-id filter to arr queue fetch 2026-06-16 23:21:56 +03:00
Flaminel 40ab0e9fad added MalwareBlocker trigger mode config and migration 2026-06-16 23:21:56 +03:00
50 changed files with 3502 additions and 274 deletions

No files matched your search

@@ -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>());
}
}
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
@@ -8,6 +9,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
{
public bool Enabled { get; init; }
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
public string CronExpression { get; init; } = "0/5 * * * * ?";
public bool UseAdvancedScheduling { get; init; }
@@ -35,6 +38,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
{
config.Enabled = Enabled;
config.TriggerMode = TriggerMode;
config.CronExpression = CronExpression;
config.UseAdvancedScheduling = UseAdvancedScheduling;
config.IgnorePrivate = IgnorePrivate;
@@ -82,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
{
if (config.Enabled)
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
bool scheduleEnabled = config.Enabled &&
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
if (scheduleEnabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
@@ -0,0 +1,12 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
/// <see cref="Unknown"/> and are ignored.
/// </summary>
public enum ArrWebhookEventType
{
Unknown = 0,
Test,
Grab,
}
@@ -0,0 +1,25 @@
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
/// <summary>
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
/// </summary>
public sealed record ArrWebhookPayload
{
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
public string? EventType { get; init; }
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
public string? DownloadId { get; init; }
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
public ArrWebhookContent? Series { get; init; }
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
public ArrWebhookContent? Movie { get; init; }
}
public sealed record ArrWebhookContent
{
public long Id { get; init; }
}
@@ -0,0 +1,109 @@
using Cleanuparr.Api.Extensions;
using Cleanuparr.Api.Features.Webhooks.Contracts;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
/// <summary>
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
/// be pasted directly into the *arr Webhook connection.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize]
public sealed class WebhooksController : ControllerBase
{
private readonly ILogger<WebhooksController> _logger;
private readonly DataContext _dataContext;
private readonly IJobManagementService _jobManagementService;
public WebhooksController(
ILogger<WebhooksController> logger,
DataContext dataContext,
IJobManagementService jobManagementService)
{
_logger = logger;
_dataContext = dataContext;
_jobManagementService = jobManagementService;
}
[HttpPost("malware-blocker/{instanceId:guid}")]
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
{
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
// The Test button sends an event we acknowledge without doing any work.
if (eventType is ArrWebhookEventType.Test)
{
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
return Ok();
}
if (eventType is not ArrWebhookEventType.Grab)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
payload.EventType, instanceId);
return Ok();
}
ArrConfig? arrConfig;
ArrInstance? instance;
ContentBlockerConfig config;
await DataContext.Lock.WaitAsync();
try
{
arrConfig = await _dataContext.ArrConfigs
.Include(x => x.Instances)
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
}
finally
{
DataContext.Lock.Release();
}
if (arrConfig is null || instance is null)
{
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
}
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
{
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
}
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
return Ok();
}
if (string.IsNullOrWhiteSpace(payload.DownloadId))
{
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
return Ok();
}
long contentId = arrConfig.Type switch
{
InstanceType.Sonarr => payload.Series?.Id ?? 0,
InstanceType.Radarr => payload.Movie?.Id ?? 0,
_ => 0,
};
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
return Ok();
}
}
@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.BlacklistSync;
using Cleanuparr.Infrastructure.Features.Jobs;
@@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService
// Always register jobs, regardless of enabled status
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
await RegisterMalwareBlockerWebhookJob(cancellationToken);
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
await RegisterSeekerJob(seekerConfig, cancellationToken);
@@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService
{
// Always register the job definition
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
// Only add triggers if the job is enabled
if (config.Enabled)
// Only add the cron trigger when scheduling is part of the trigger mode
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
{
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
}
}
/// <summary>
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
/// is received.
/// </summary>
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
{
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
}
/// <summary>
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
/// <summary>
/// Helper method to add a job without a trigger (for chained jobs).
/// </summary>
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
where T : IHandler
{
if (_scheduler == null)
{
throw new InvalidOperationException("Scheduler not initialized");
}
string typeName = typeof(T).Name;
string typeName = jobKeyName ?? typeof(T).Name;
var jobKey = new JobKey(typeName);
// Check if job already exists
if (await _scheduler.CheckExists(jobKey, cancellationToken))
{
@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : IJob
ContextProvider.SetJobRunId(jobRunId);
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
SetWebhookScanTarget(context);
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
var handler = scope.ServiceProvider.GetRequiredService<T>();
@@ -75,6 +77,34 @@ public sealed class GenericJob<T> : IJob
}
}
/// <summary>
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
/// </summary>
private static void SetWebhookScanTarget(IJobExecutionContext context)
{
JobDataMap dataMap = context.MergedJobDataMap;
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
{
return;
}
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
{
return;
}
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
: 0;
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
}
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
@@ -0,0 +1,8 @@
namespace Cleanuparr.Domain.Enums;
public enum JobTriggerMode
{
Schedule,
Webhook,
Both,
}
@@ -44,7 +44,8 @@ public class MalwareBlockerIntegrationTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher);
_fixture.EventPublisher,
Substitute.For<Cleanuparr.Infrastructure.Services.Interfaces.IJobManagementService>());
}
[Fact]
@@ -70,6 +71,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = false
@@ -200,6 +202,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
DeleteReason = DeleteReason.AllFilesBlocked,
IsPrivate = true
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
@@ -49,7 +50,8 @@ public class MalwareBlockerTests : IDisposable
_fixture.ArrQueueIterator,
_fixture.DownloadServiceFactory,
_fixture.BlocklistProvider,
_fixture.EventPublisher
_fixture.EventPublisher,
_fixture.JobManagementService
);
}
@@ -276,7 +278,7 @@ public class MalwareBlockerTests : IDisposable
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
@@ -339,6 +341,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -362,6 +365,215 @@ public class MalwareBlockerTests : IDisposable
);
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTarget_ScansOnlyMatchingDownload()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var matching = new QueueRecord { Id = 1, DownloadId = "match-hash", Title = "Match", Protocol = "torrent", SeriesId = 5, EpisodeId = 1 };
var other = new QueueRecord { Id = 2, DownloadId = "other-hash", Title = "Other", Protocol = "torrent", SeriesId = 5, EpisodeId = 2 };
_fixture.ArrQueueIterator
.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(),
Arg.Any<long?>()
)
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([matching, other]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "match-hash", 5, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await mockDownloadService.Received(1).BlockUnwantedFilesAsync("match-hash", Arg.Any<List<string>>());
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync("other-hash", Arg.Any<List<string>>());
// Found in a client -> resolved, no retry scheduled
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetNotFoundInClient_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "pending-hash", Title = "Pending", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent not (yet) present in any client
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = false });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "pending-hash", 7, InstanceType.Sonarr, RetryIndex: 1));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t =>
t.InstanceId == sonarrInstance.Id &&
t.DownloadId == "pending-hash" &&
t.ContentId == 7 &&
t.Type == InstanceType.Sonarr &&
t.RetryIndex == 1));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetMetadataMissing_SchedulesRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
// Torrent found in the client, but its metadata/file list is not ready yet
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
.Returns(new BlockFilesResult { Found = true });
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "metadl-hash", 7, InstanceType.Sonarr, RetryIndex: 0));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
Arg.Is<WebhookScanTarget>(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0));
}
[Fact]
public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry()
{
// Arrange
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
EnableSonarrBlocklist();
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var record = new QueueRecord { Id = 1, DownloadId = "usenet-id", Title = "Usenet", Protocol = "usenet", SeriesId = 9, EpisodeId = 1 };
_fixture.ArrQueueIterator
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
.Returns(ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
return callback([record]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
sonarrInstance.Id, "usenet-id", 9, InstanceType.Sonarr));
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert: usenet is acknowledged once seen in the queue -> no scan, no retry
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>());
await _fixture.JobManagementService.DidNotReceive()
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
}
[Fact]
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
{
@@ -409,6 +621,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AtLeastOneFileBlocked
@@ -484,6 +697,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = true,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -665,6 +879,7 @@ public class MalwareBlockerTests : IDisposable
.Returns(new BlockFilesResult
{
Found = true,
MetadataFound = true,
ShouldRemove = true,
IsPrivate = false,
DeleteReason = DeleteReason.AllFilesBlocked
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using MassTransit;
using Microsoft.Extensions.Caching.Memory;
@@ -30,6 +31,7 @@ public class JobHandlerFixture : IDisposable
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
public IEventPublisher EventPublisher { get; private set; }
public IBlocklistProvider BlocklistProvider { get; private set; }
public IJobManagementService JobManagementService { get; private set; }
public IHardLinkFileService HardLinkFileService { get; private set; }
public IDryRunInterceptor DryRunInterceptor { get; private set; }
public FakeTimeProvider TimeProvider { get; private set; }
@@ -52,6 +54,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
TimeProvider = new FakeTimeProvider();
@@ -151,6 +154,7 @@ public class JobHandlerFixture : IDisposable
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
EventPublisher = Substitute.For<IEventPublisher>();
BlocklistProvider = Substitute.For<IBlocklistProvider>();
JobManagementService = Substitute.For<IJobManagementService>();
HardLinkFileService = Substitute.For<IHardLinkFileService>();
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
Cache.Clear();
@@ -512,63 +512,4 @@ public class JobManagementServiceTests
}
#endregion
#region GetMainTrigger Tests
[Fact]
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(false);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
[Fact]
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
{
// Arrange
var jobType = JobType.MalwareBlocker;
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
var trigger = Substitute.For<ITrigger>();
trigger.Key.Returns(expectedTriggerKey);
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.Returns(true);
_scheduler.GetTrigger(expectedTriggerKey, Arg.Any<CancellationToken>())
.Returns(trigger);
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldNotBeNull();
result.Key.ShouldBe(expectedTriggerKey);
}
[Fact]
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
{
// Arrange
var jobType = JobType.QueueCleaner;
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception("Scheduler error"));
// Act
var result = await _service.GetMainTrigger(jobType);
// Assert
result.ShouldBeNull();
}
#endregion
}
@@ -33,11 +33,11 @@ public abstract class ArrClient : IArrClient
_dryRunInterceptor = dryRunInterceptor;
}
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null)
{
UriBuilder uriBuilder = new(arrInstance.Url);
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
uriBuilder.Query = GetQueueUrlQuery(page);
uriBuilder.Query = GetQueueUrlQuery(page, contentId);
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
SetApiKey(request, arrInstance.ApiKey);
@@ -271,7 +271,7 @@ public abstract class ArrClient : IArrClient
protected abstract string GetQueueUrlPath();
protected abstract string GetQueueUrlQuery(int page);
protected abstract string GetQueueUrlQuery(int page, long? contentId = null);
protected abstract string GetQueueDeleteUrlPath(long recordId);
@@ -14,7 +14,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
_logger = logger;
}
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null)
{
const ushort maxPage = 100;
ushort page = 1;
@@ -23,7 +23,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
do
{
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page);
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page, contentId);
if (totalRecords is 0)
{
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrClient
{
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null);
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
@@ -5,5 +5,5 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
public interface IArrQueueIterator
{
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null);
}
@@ -32,7 +32,7 @@ public class LidarrClient : ArrClient, ILidarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
}
@@ -32,9 +32,16 @@ public class RadarrClient : ArrClient, IRadarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
string query = $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
if (contentId is not null)
{
query += $"&movieIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -32,7 +32,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
return "/api/v1/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
}
@@ -34,9 +34,16 @@ public class SonarrClient : ArrClient, ISonarrClient
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
string query = $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
if (contentId is not null)
{
query += $"&seriesIds={contentId}";
}
return query;
}
protected override string GetQueueDeleteUrlPath(long recordId)
@@ -34,7 +34,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
}
@@ -33,7 +33,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
return "/api/v3/queue";
}
protected override string GetQueueUrlQuery(int page)
protected override string GetQueueUrlQuery(int page, long? contentId = null)
{
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
}
@@ -15,6 +15,11 @@ public sealed record BlockFilesResult
public bool IsPrivate { get; set; }
public bool Found { get; set; }
/// <summary>
/// True when the torrent's file list (metadata) was available so the scan could complete (or was not needed).
/// </summary>
public bool MetadataFound { get; set; }
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
}
@@ -32,15 +32,17 @@ public partial class DelugeService
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
result.MetadataFound = true;
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
result.MetadataFound = true;
return result;
}
@@ -55,11 +57,14 @@ public partial class DelugeService
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
}
if (contents is null)
if (contents is null || contents.Contents?.Count is null or 0)
{
_logger.LogDebug("torrent has no files | {name}", download.Name);
return result;
}
result.MetadataFound = true;
Dictionary<int, int> priorities = [];
bool hasPriorityUpdates = false;
long totalFiles = 0;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -87,8 +87,9 @@ public sealed class DownloadCleaner : GenericHandler
{
DownloadCleanerConfig config = ContextProvider.Get<DownloadCleanerConfig>();
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(config.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(config.IgnoredDownloads)
.ToList();
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap = new();
List<IDownloadService> loggedInServices = new();
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
@@ -21,6 +22,7 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
public sealed class MalwareBlocker : GenericHandler
{
private readonly IBlocklistProvider _blocklistProvider;
private readonly IJobManagementService _jobManagementService;
public MalwareBlocker(
ILogger<MalwareBlocker> logger,
@@ -31,13 +33,15 @@ public sealed class MalwareBlocker : GenericHandler
IArrQueueIterator arrArrQueueIterator,
IDownloadServiceFactory downloadServiceFactory,
IBlocklistProvider blocklistProvider,
IEventPublisher eventPublisher
IEventPublisher eventPublisher,
IJobManagementService jobManagementService
) : base(
logger, dataContext, cache, messageBus,
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
)
{
_blocklistProvider = blocklistProvider;
_jobManagementService = jobManagementService;
}
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
@@ -62,6 +66,12 @@ public sealed class MalwareBlocker : GenericHandler
await _blocklistProvider.LoadBlocklistsAsync();
if (ContextProvider.Get(nameof(WebhookScanTarget)) is WebhookScanTarget webhookTarget)
{
await ProcessWebhookTargetAsync(malwareBlockerConfig, webhookTarget);
return;
}
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
@@ -94,10 +104,52 @@ public sealed class MalwareBlocker : GenericHandler
}
}
protected override async Task ProcessInstanceAsync(ArrInstance instance)
/// <summary>
/// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating instance, instead of iterating the whole queue.
/// Schedules the next retry only when the download was not yet found/scanned.
/// </summary>
private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
BlocklistSettings? blocklist = target.Type switch
{
InstanceType.Sonarr => config.Sonarr,
InstanceType.Radarr => config.Radarr,
_ => null,
};
if (blocklist is null || !blocklist.Enabled)
{
_logger.LogDebug("skip webhook scan | blocklist for {type} is not enabled", target.Type);
return;
}
ArrConfig arrConfig = ContextProvider.Get<ArrConfig>(target.Type.ToString());
ArrInstance? instance = arrConfig.Instances
.FirstOrDefault(x => x.Id == target.InstanceId && x.Enabled);
if (instance is null)
{
_logger.LogWarning("skip webhook scan | instance {id} not found or disabled", target.InstanceId);
return;
}
instance.ArrConfig = arrConfig;
bool resolved = await ScanInstanceAsync(instance, target);
if (!resolved)
{
await _jobManagementService.ScheduleMalwareBlockerWebhookRetry(target);
}
}
protected override Task ProcessInstanceAsync(ArrInstance instance) => ScanInstanceAsync(instance);
private async Task<bool> ScanInstanceAsync(ArrInstance instance, WebhookScanTarget? target = null)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -111,8 +163,10 @@ public sealed class MalwareBlocker : GenericHandler
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
var config = ContextProvider.Get<ContentBlockerConfig>();
ContentBlockerConfig config = ContextProvider.Get<ContentBlockerConfig>();
bool targetResolved = false;
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
{
@@ -129,108 +183,148 @@ public sealed class MalwareBlocker : GenericHandler
continue;
}
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
continue;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
continue;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
continue;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
BlockFilesResult result = new();
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
DownloadClientConfig? foundInClient = null;
if (isTorrent)
{
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
// stalled download check
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
}
if (!result.ShouldRemove)
if (target is not null &&
!string.Equals(record.DownloadId, target.DownloadId, StringComparison.InvariantCultureIgnoreCase))
{
continue;
}
bool removeFromClient = true;
bool resolved = await TryProcessRecordAsync(group, instance, arrClient, downloadServices, ignoredDownloads, config);
if (result.IsPrivate && !config.DeletePrivate)
if (target is not null)
{
removeFromClient = false;
targetResolved = resolved;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
});
}, contentId: target is { ContentId: > 0 } ? target.ContentId : null);
return targetResolved;
}
/// <summary>
/// Scans a single grouped download. Returns <c>true</c> when the download is resolved — found in a
/// client and evaluated, a usenet record, or deliberately skipped — and <c>false</c> only when it is
/// a torrent that was not found in any download client yet (the case a webhook scan should retry).
/// </summary>
private async Task<bool> TryProcessRecordAsync(
IGrouping<string, QueueRecord> group,
ArrInstance instance,
IArrClient arrClient,
IReadOnlyList<IDownloadService> downloadServices,
List<string> ignoredDownloads,
ContentBlockerConfig config)
{
QueueRecord record = group.First();
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
return true;
}
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
bool hasContentId = arrClient.HasContentId(record);
if (!hasContentId)
{
if (!config.ProcessNoContentId)
{
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
return true;
}
_logger.LogDebug("item is missing the content id | {title}", record.Title);
}
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
{
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
return true;
}
// push record to context
ContextProvider.Set(nameof(QueueRecord), record);
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
if (!isTorrent)
{
// Usenet is acknowledged once it appears in the queue; nothing to scan, no retry.
return true;
}
BlockFilesResult result = new();
DownloadClientConfig? foundInClient = null;
var torrentClients = downloadServices
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
.ToList();
_logger.LogDebug("searching unwanted files for {title}", record.Title);
if (torrentClients.Count > 0)
{
// Check each download client for the download item
foreach (var downloadService in torrentClients)
{
try
{
result = await downloadService
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
if (result.Found)
{
foundInClient = downloadService.ClientConfig;
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
record.Title, downloadService.ClientConfig.Name);
}
}
if (!result.Found)
{
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
}
}
else
{
_logger.LogDebug("No torrent clients enabled");
}
if (!result.Found || !result.MetadataFound)
{
// Retry while the torrent is not yet in a client, or is present but its file list/metadata isn't ready.
return false;
}
if (result.ShouldRemove)
{
bool removeFromClient = true;
if (result.IsPrivate && !config.DeletePrivate)
{
removeFromClient = false;
}
await PublishQueueItemRemoveRequest(
downloadRemovalKey,
instance,
record,
group.Count() > 1,
removeFromClient,
result.DeleteReason,
skipSearch: !hasContentId,
downloadClient: foundInClient
);
}
return true;
}
}
@@ -81,9 +81,10 @@ public sealed class QueueCleaner : GenericHandler
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
.Concat(queueCleanerConfig.IgnoredDownloads)
.ToList();
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
@@ -0,0 +1,15 @@
using Cleanuparr.Domain.Enums;
namespace Cleanuparr.Infrastructure.Features.Jobs;
/// <summary>
/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an *arr "On Grab" webhook.
/// </summary>
public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type, int RetryIndex = 0)
{
public const string InstanceIdKey = "webhook.instanceId";
public const string DownloadIdKey = "webhook.downloadId";
public const string ContentIdKey = "webhook.contentId";
public const string InstanceTypeKey = "webhook.instanceType";
public const string RetryIndexKey = "webhook.retryIndex";
}
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Quartz;
@@ -9,8 +10,19 @@ public interface IJobManagementService
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
Task<bool> StopJob(JobType jobType);
Task<bool> TriggerJobOnce(JobType jobType);
/// <summary>
/// Schedules the first targeted MalwareBlocker scan for a single download received via an *arr "On Grab" webhook.
/// Subsequent retries are scheduled by the handler via <see cref="ScheduleMalwareBlockerWebhookRetry"/> only while the download has not been found.
/// </summary>
Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
/// <summary>
/// Schedules the next targeted MalwareBlocker webhook scan after a completed attempt.
/// </summary>
Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target);
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
Task<JobInfo> GetJob(JobType jobType);
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
Task<ITrigger?> GetMainTrigger(JobType jobType);
}
@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Infrastructure.Utilities;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Quartz;
@@ -168,34 +170,6 @@ public class JobManagementService : IJobManagementService
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
}
}
/// <summary>
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
/// </summary>
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
{
string jobName = jobType.ToString();
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(jobName);
if (!await scheduler.CheckExists(jobKey))
{
return null;
}
// Look for the main trigger (follows our naming convention)
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
return await scheduler.GetTrigger(mainTriggerKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
return null;
}
}
public async Task<bool> StopJob(JobType jobType)
{
@@ -368,6 +342,71 @@ public class JobManagementService : IJobManagementService
}
}
public Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type)
{
return ScheduleWebhookAttempt(instanceId, downloadId, contentId, type, attemptIndex: 0);
}
public Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target)
{
int nextIndex = target.RetryIndex + 1;
if (nextIndex >= Constants.MalwareBlockerWebhookRetryDelays.Count)
{
_logger.LogDebug(
"{name} webhook scan gave up for download {downloadId} on {type} instance {instanceId} after {attemptCount} attempts",
nameof(JobType.MalwareBlocker), target.DownloadId, target.Type, target.InstanceId, Constants.MalwareBlockerWebhookRetryDelays.Count);
return Task.FromResult(false);
}
return ScheduleWebhookAttempt(target.InstanceId, target.DownloadId, target.ContentId, target.Type, nextIndex);
}
private async Task<bool> ScheduleWebhookAttempt(Guid instanceId, string downloadId, long contentId, InstanceType type, int attemptIndex)
{
try
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey(Constants.MalwareBlockerWebhookJobKey);
if (!await scheduler.CheckExists(jobKey))
{
_logger.LogError("Job {name} does not exist", Constants.MalwareBlockerWebhookJobKey);
return false;
}
TimeSpan delay = Constants.MalwareBlockerWebhookRetryDelays[attemptIndex];
var jobData = new JobDataMap
{
{ WebhookScanTarget.InstanceIdKey, instanceId.ToString() },
{ WebhookScanTarget.DownloadIdKey, downloadId },
{ WebhookScanTarget.ContentIdKey, contentId },
{ WebhookScanTarget.InstanceTypeKey, type.ToString() },
{ WebhookScanTarget.RetryIndexKey, attemptIndex },
};
var trigger = TriggerBuilder.Create()
.WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{attemptIndex}-{DateTimeOffset.UtcNow.Ticks}")
.ForJob(jobKey)
.UsingJobData(jobData)
.StartAt(DateTimeOffset.UtcNow.Add(delay))
.Build();
await scheduler.ScheduleJob(trigger);
_logger.LogInformation(
"MalwareBlocker webhook scan attempt {attempt} scheduled (in {delay}s) for download {downloadId} on {type} instance {instanceId}",
attemptIndex, (int)delay.TotalSeconds, downloadId, type, instanceId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scheduling MalwareBlocker webhook scan for instance {instanceId}", instanceId);
return false;
}
}
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddMalwareBlockerTriggerMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "trigger_mode",
table: "content_blocker_configs",
type: "TEXT",
nullable: false,
defaultValue: "Schedule");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "trigger_mode",
table: "content_blocker_configs");
}
}
}
@@ -816,6 +816,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("process_no_content_id");
b.Property<string>("TriggerMode")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("trigger_mode");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
@@ -1079,7 +1084,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1130,7 +1136,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<DateTimeOffset>("UpdatedAt")
b.Property<string>("UpdatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("updated_at");
@@ -1577,7 +1584,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastProcessedAt")
b.Property<string>("LastProcessedAt")
.HasColumnType("TEXT")
.HasColumnName("last_processed_at");
@@ -1688,11 +1695,12 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSyncedAt")
b.Property<string>("LastSyncedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_synced_at");
b.Property<DateTimeOffset?>("LastUpgradedAt")
b.Property<string>("LastUpgradedAt")
.HasColumnType("TEXT")
.HasColumnName("last_upgraded_at");
@@ -1747,7 +1755,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("RecordedAt")
b.Property<string>("RecordedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("recorded_at");
@@ -1783,7 +1792,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("arr_instance_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1828,7 +1838,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("command_id");
b.Property<DateTimeOffset>("CreatedAt")
b.Property<string>("CreatedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -1896,7 +1907,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("item_type");
b.Property<DateTimeOffset>("LastSearchedAt")
b.Property<string>("LastSearchedAt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("last_searched_at");
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
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)
];
}
@@ -104,6 +104,7 @@ export class DocumentationService {
},
'malware-blocker': {
'enabled': 'enable-malware-blocker',
'triggerMode': 'trigger-mode',
'ignoredDownloads': 'ignored-downloads',
'useAdvancedScheduling': 'scheduling-mode',
'cronExpression': 'cron-expression',
@@ -26,18 +26,27 @@
</div>
@for (instance of instances(); track instance.id) {
<div class="instance-row">
<div class="instance-row__info">
<span class="instance-row__name">{{ instance.name }}</span>
<app-badge [severity]="instance.enabled ? 'success' : 'default'" size="sm">
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
</app-badge>
<span class="instance-row__url">{{ instance.url }}</span>
</div>
<div class="instance-row__actions">
<app-button variant="ghost" size="sm" (clicked)="openEditModal(instance)">Edit</app-button>
<app-button variant="destructive" size="sm" (clicked)="deleteInstance(instance)">Delete</app-button>
<div class="instance-item">
<div class="instance-row">
<div class="instance-row__info">
<span class="instance-row__name">{{ instance.name }}</span>
<app-badge [severity]="instance.enabled ? 'success' : 'default'" size="sm">
{{ instance.enabled ? 'Enabled' : 'Disabled' }}
</app-badge>
<span class="instance-row__url">{{ instance.url }}</span>
</div>
<div class="instance-row__actions">
<app-button variant="ghost" size="sm" (clicked)="openEditModal(instance)">Edit</app-button>
<app-button variant="destructive" size="sm" (clicked)="deleteInstance(instance)">Delete</app-button>
</div>
</div>
@if (webhookSupported() && instance.id) {
<div class="instance-id">
<span class="instance-id__label">Instance ID</span>
<code class="instance-id__value">{{ instance.id }}</code>
<app-button variant="ghost" size="sm" (clicked)="copyInstanceId(instance)">Copy</app-button>
</div>
}
</div>
} @empty {
<app-empty-state
@@ -22,3 +22,24 @@
white-space: nowrap;
}
}
.instance-id {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) 0 var(--space-3);
&__label {
font-size: var(--font-size-xs);
color: var(--text-tertiary);
}
&__value {
font-size: var(--font-size-xs);
font-family: var(--font-mono);
color: var(--text-secondary);
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
}
}
@@ -39,6 +39,13 @@ export class ArrSettingsComponent implements HasPendingChanges {
private readonly confirmService = inject(ConfirmService);
readonly arrType = input.required<string>({ alias: 'type' });
// MalwareBlocker "On Grab" webhook triggering is only supported for Sonarr and Radarr; the instance
// id shown on the card is used to build the per-instance webhook URL.
readonly webhookSupported = computed(() => {
const t = this.arrType();
return t === 'sonarr' || t === 'radarr';
});
readonly displayName = computed(() => {
const t = this.arrType();
return t.charAt(0).toUpperCase() + t.slice(1);
@@ -211,6 +218,12 @@ export class ArrSettingsComponent implements HasPendingChanges {
});
}
copyInstanceId(instance: ArrInstance): void {
if (!instance.id) return;
navigator.clipboard.writeText(instance.id);
this.toast.success('Instance ID copied to clipboard');
}
hasPendingChanges(): boolean {
return false;
}
@@ -39,24 +39,30 @@
<div class="form-divider"></div>
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="malware-blocker:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
helpKey="malware-blocker:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="malware-blocker:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
helpKey="malware-blocker:scheduleEvery" />
</div>
<app-select label="Trigger Mode" [options]="triggerModeOptions" [(value)]="triggerMode"
hint="Run on a schedule, when a Sonarr/Radarr &quot;On Grab&quot; webhook is received, or both. Webhook triggering is only supported for Sonarr and Radarr; configure the per-instance webhook URL on the Sonarr/Radarr connection page."
helpKey="malware-blocker:triggerMode" />
@if (scheduleEnabled()) {
<app-toggle label="Advanced Scheduling" [(checked)]="useAdvancedScheduling"
hint="Choose between basic scheduling or advanced cron expression"
helpKey="malware-blocker:useAdvancedScheduling" />
@if (useAdvancedScheduling()) {
<app-input label="Cron Expression" placeholder="0 0/5 * ? * * *" [(value)]="cronExpression"
hint="Enter a valid Quartz cron expression (e.g., &quot;0 0/5 * ? * * *&quot; runs every 5 minutes)"
[error]="cronError()"
helpKey="malware-blocker:cronExpression" />
} @else {
<div class="form-row">
<app-select label="Schedule Unit" [options]="scheduleUnitOptions" [(value)]="scheduleUnit"
hint="Choose the time unit for the schedule"
helpKey="malware-blocker:scheduleUnit" />
<app-select label="Every" [options]="scheduleIntervalOptions()" [(value)]="scheduleEvery"
hint="How often the job should run"
[error]="scheduleEveryError()"
helpKey="malware-blocker:scheduleEvery" />
</div>
}
}
<div class="form-divider"></div>
@@ -9,7 +9,7 @@ import { MalwareBlockerApi } from '@core/api/malware-blocker.api';
import { ApiError } from '@core/interceptors/error.interceptor';
import { ToastService } from '@core/services/toast.service';
import { MalwareBlockerConfig, BlocklistSettings, MalwareScheduleOptions } from '@shared/models/malware-blocker-config.model';
import { BlocklistType, ScheduleUnit } from '@shared/models/enums';
import { BlocklistType, JobTriggerMode, ScheduleUnit } from '@shared/models/enums';
import { HasPendingChanges } from '@core/guards/pending-changes.guard';
import { DeferredLoader } from '@shared/utils/loading.util';
import { generateCronExpression, parseCronToJobSchedule } from '@shared/utils/schedule.util';
@@ -25,6 +25,12 @@ const SCHEDULE_UNIT_OPTIONS: SelectOption[] = [
{ label: 'Hours', value: ScheduleUnit.Hours },
];
const TRIGGER_MODE_OPTIONS: SelectOption[] = [
{ label: 'Schedule', value: JobTriggerMode.Schedule },
{ label: 'Webhook (Sonarr/Radarr)', value: JobTriggerMode.Webhook },
{ label: 'Schedule + Webhook', value: JobTriggerMode.Both },
];
const ARR_NAMES = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr'] as const;
@Component({
@@ -48,6 +54,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
readonly blocklistTypeOptions = BLOCKLIST_TYPE_OPTIONS;
readonly scheduleUnitOptions = SCHEDULE_UNIT_OPTIONS;
readonly triggerModeOptions = TRIGGER_MODE_OPTIONS;
readonly arrNames = ARR_NAMES;
readonly loader = new DeferredLoader();
readonly loadError = signal(false);
@@ -55,6 +62,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
readonly saved = signal(false);
readonly enabled = signal(false);
readonly triggerMode = signal<unknown>(JobTriggerMode.Schedule);
readonly useAdvancedScheduling = signal(false);
readonly cronExpression = signal('');
readonly scheduleEvery = signal<unknown>(5);
@@ -83,6 +91,8 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
readonly deletePrivateDisabled = computed(() => this.ignorePrivate());
readonly scheduleEnabled = computed(() => this.triggerMode() !== JobTriggerMode.Webhook);
constructor() {
effect(() => {
const unit = this.scheduleUnit();
@@ -102,6 +112,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
}
readonly scheduleEveryError = computed(() => {
if (!this.scheduleEnabled()) return undefined;
if (this.useAdvancedScheduling()) return undefined;
const unit = this.scheduleUnit() as ScheduleUnit;
const options = MalwareScheduleOptions[unit] ?? [];
@@ -110,6 +121,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
});
readonly cronError = computed(() => {
if (!this.scheduleEnabled()) return undefined;
if (this.useAdvancedScheduling() && !this.cronExpression().trim()) return 'Cron expression is required';
return undefined;
});
@@ -155,6 +167,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
next: (config) => {
this.config = config;
this.enabled.set(config.enabled);
this.triggerMode.set(config.triggerMode ?? JobTriggerMode.Schedule);
this.useAdvancedScheduling.set(config.useAdvancedScheduling);
this.cronExpression.set(config.cronExpression);
const parsed = parseCronToJobSchedule(config.cronExpression);
@@ -217,6 +230,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
const config: MalwareBlockerConfig = {
...this.config,
enabled: this.enabled(),
triggerMode: this.triggerMode() as JobTriggerMode,
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression,
ignoredDownloads: this.ignoredDownloads(),
@@ -252,6 +266,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges {
private buildSnapshot(): string {
return JSON.stringify({
enabled: this.enabled(),
triggerMode: this.triggerMode(),
useAdvancedScheduling: this.useAdvancedScheduling(),
cronExpression: this.cronExpression(),
scheduleEvery: this.scheduleEvery(),
@@ -116,3 +116,9 @@ export enum SearchCommandStatus {
}
export type ArrType = 'sonarr' | 'radarr' | 'lidarr' | 'readarr' | 'whisparr';
export enum JobTriggerMode {
Schedule = 'Schedule',
Webhook = 'Webhook',
Both = 'Both',
}
@@ -1,4 +1,4 @@
import { BlocklistType, ScheduleUnit } from './enums';
import { BlocklistType, JobTriggerMode, ScheduleUnit } from './enums';
import { JobSchedule } from './queue-cleaner-config.model';
export const MalwareScheduleOptions: Record<ScheduleUnit, number[]> = {
@@ -15,6 +15,7 @@ export interface BlocklistSettings {
export interface MalwareBlockerConfig {
enabled: boolean;
triggerMode: JobTriggerMode;
cronExpression: string;
useAdvancedScheduling: boolean;
jobSchedule?: JobSchedule;
@@ -34,6 +34,21 @@ When enabled, the Malware Blocker will run according to the configured schedule
</ConfigSection>
<ConfigSection
title="Trigger Mode"
>
Controls how the Malware Blocker is started:
- **Schedule**: Runs on the configured cron schedule (default).
- **Webhook**: Runs only when a Sonarr/Radarr "On Grab" webhook is received, scanning just the grabbed download.
- **Schedule + Webhook**: Both — webhooks scan new grabs immediately, while the schedule remains a safety net.
<Note>
Webhook triggering is only supported for **Sonarr** and **Radarr**. See [Webhook triggering](./malware-blocker/webhook-triggers) for setup.
</Note>
</ConfigSection>
<ConfigSection
title="Scheduling Mode"
>
@@ -0,0 +1,76 @@
---
sidebar_position: 1
---
import {
ConfigSection,
Note,
Important,
Warning,
ElementNavigator,
styles
} from '@site/src/components/documentation';
# Webhook Triggering
By default the Malware Blocker runs on a fixed schedule, which leaves a gap between when a download is
grabbed and when its files are evaluated. Sonarr and Radarr can call Cleanuparr the moment a release is
sent to the download client via their **"On Grab"** webhook, so the grabbed download is scanned right
away instead of waiting for the next scheduled run.
<ElementNavigator />
<div className={styles.documentationPage}>
<Important>
Webhook triggering is only supported for **Sonarr** and **Radarr**. Set the Malware Blocker **Trigger Mode** to **Webhook** or **Schedule + Webhook** for incoming webhooks to be acted on.
</Important>
<div className={styles.section}>
<ConfigSection
title="How it works"
>
When an "On Grab" webhook arrives, Cleanuparr runs a targeted Malware Blocker scan of just that one download (identified by its torrent hash from the webhook), rather than iterating the whole queue.
The scan runs immediately and is retried a couple of times shortly after, because a torrent often has no file metadata yet at grab time.
<Note>
Because the grabbed torrent may not have downloaded its file list yet, the very first scan can find nothing to act on.
Keeping the schedule enabled (**Schedule + Webhook**) ensures these are still caught by the regular run.
</Note>
</ConfigSection>
<ConfigSection
title="Finding the webhook URL"
>
Each Sonarr/Radarr instance has its own webhook URL, built from the instance id and your account API key:
```
http://<cleanuparr-host>:11011/api/webhooks/malware-blocker/<instanceId>?apikey=<your-api-key>
```
- **`<instanceId>`** — open the instance under its connection page (**Sonarr** / **Radarr** settings)
and copy the **Instance ID**.
- **`<your-api-key>`** — copy it from **Settings → Account** (API key).
</ConfigSection>
<ConfigSection
title="Configuring Sonarr / Radarr"
>
1. In Sonarr/Radarr, go to **Settings → Connect → + → Webhook**.
2. Enable the **On Grab** notification trigger.
3. Set **URL** to the webhook URL copied from Cleanuparr and **Method** to `POST`.
4. Click **Test**, then **Save**.
From now on, every grab is scanned by the Malware Blocker as soon as it reaches the download client.
</ConfigSection>
</div>
</div>