diff --git a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs
index b4508e79..49ca32fc 100644
--- a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs
+++ b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Controllers/MalwareBlockerConfigController.cs
@@ -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: MalwareBlockerTriggerMode.Webhook };
+
+ if (scheduleEnabled)
{
if (!string.IsNullOrEmpty(config.CronExpression))
{
diff --git a/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs b/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs
new file mode 100644
index 00000000..2a8a8b28
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Webhooks/Contracts/ArrWebhookPayload.cs
@@ -0,0 +1,25 @@
+namespace Cleanuparr.Api.Features.Webhooks.Contracts;
+
+///
+/// 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.
+///
+public sealed record ArrWebhookPayload
+{
+ /// "Grab" to act on; "Test" is sent when the connection's Test button is clicked.
+ public string? EventType { get; init; }
+
+ /// Torrent infohash (or NZB id) identifying the download in the download client.
+ public string? DownloadId { get; init; }
+
+ /// Present on Sonarr payloads; carries the series content id.
+ public ArrWebhookContent? Series { get; init; }
+
+ /// Present on Radarr payloads; carries the movie content id.
+ public ArrWebhookContent? Movie { get; init; }
+}
+
+public sealed record ArrWebhookContent
+{
+ public long Id { get; init; }
+}
diff --git a/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs b/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs
new file mode 100644
index 00000000..81e55c19
--- /dev/null
+++ b/code/backend/Cleanuparr.Api/Features/Webhooks/Controllers/MalwareBlockerWebhookController.cs
@@ -0,0 +1,107 @@
+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;
+using Microsoft.Extensions.Logging;
+
+namespace Cleanuparr.Api.Features.Webhooks.Controllers;
+
+///
+/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
+/// grabbed download. Authentication reuses the account API key (e.g. ?apikey=), so the URL can
+/// be pasted directly into the *arr Webhook connection.
+///
+[ApiController]
+[Route("api/webhooks")]
+[Authorize]
+public sealed class MalwareBlockerWebhookController : ControllerBase
+{
+ private readonly ILogger _logger;
+ private readonly DataContext _dataContext;
+ private readonly IJobManagementService _jobManagementService;
+
+ public MalwareBlockerWebhookController(
+ ILogger logger,
+ DataContext dataContext,
+ IJobManagementService jobManagementService)
+ {
+ _logger = logger;
+ _dataContext = dataContext;
+ _jobManagementService = jobManagementService;
+ }
+
+ [HttpPost("malware-blocker/{instanceId:guid}")]
+ public async Task TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
+ {
+ // The Test button sends an event we acknowledge without doing any work.
+ if (string.Equals(payload.EventType, "Test", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
+ return Ok();
+ }
+
+ if (!string.Equals(payload.EventType, "Grab", StringComparison.OrdinalIgnoreCase))
+ {
+ _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 NotFound($"No arr instance found with id {instanceId}");
+ }
+
+ if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
+ {
+ return UnprocessableEntity("MalwareBlocker webhooks are only supported for Sonarr and Radarr");
+ }
+
+ if (!config.Enabled || config.TriggerMode is MalwareBlockerTriggerMode.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();
+ }
+}
diff --git a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs
index e7f8fcd1..3f834d2d 100644
--- a/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs
+++ b/code/backend/Cleanuparr.Api/Jobs/BackgroundJobManager.cs
@@ -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(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 MalwareBlockerTriggerMode.Webhook)
{
await AddTriggersForJob(config.CronExpression, cancellationToken);
}
}
+
+ ///
+ /// 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.
+ ///
+ public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
+ {
+ await AddJobWithoutTrigger(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
+ }
///
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
///
/// Helper method to add a job without a trigger (for chained jobs).
///
- private async Task AddJobWithoutTrigger(CancellationToken cancellationToken = default)
+ private async Task AddJobWithoutTrigger(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))
{
diff --git a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs
index f09a78ad..ba43d464 100644
--- a/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs
+++ b/code/backend/Cleanuparr.Api/Jobs/GenericJob.cs
@@ -48,6 +48,8 @@ public sealed class GenericJob : 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();
@@ -75,6 +77,31 @@ public sealed class GenericJob : IJob
}
}
+ ///
+ /// 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.
+ ///
+ 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);
+
+ ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType));
+ }
+
private async Task BroadcastJobStatus(IHubContext hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
{
try
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs
index 995ae9cd..c8a6771f 100644
--- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs
@@ -62,6 +62,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(nameof(InstanceType.Sonarr));
var radarrConfig = ContextProvider.Get(nameof(InstanceType.Radarr));
var lidarrConfig = ContextProvider.Get(nameof(InstanceType.Lidarr));
@@ -94,8 +100,43 @@ public sealed class MalwareBlocker : GenericHandler
}
}
+ ///
+ /// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating
+ /// instance, instead of iterating the whole queue. Sonarr/Radarr only.
+ ///
+ private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target)
+ {
+ 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(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;
+ await ProcessInstanceAsync(instance);
+ }
+
protected override async Task ProcessInstanceAsync(ArrInstance instance)
{
+ WebhookScanTarget? webhookTarget = ContextProvider.Get(nameof(WebhookScanTarget)) as WebhookScanTarget;
+
List ignoredDownloads = ContextProvider.Get(nameof(GeneralConfig)).IgnoredDownloads;
ignoredDownloads.AddRange(ContextProvider.Get().IgnoredDownloads);
@@ -129,6 +170,12 @@ public sealed class MalwareBlocker : GenericHandler
continue;
}
+ if (webhookTarget is not null &&
+ !string.Equals(record.DownloadId, webhookTarget.DownloadId, StringComparison.InvariantCultureIgnoreCase))
+ {
+ continue;
+ }
+
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
{
_logger.LogInformation("skip | {title} | ignored", record.Title);
@@ -231,6 +278,6 @@ public sealed class MalwareBlocker : GenericHandler
downloadClient: foundInClient
);
}
- });
+ }, contentId: webhookTarget is { ContentId: > 0 } ? webhookTarget.ContentId : null);
}
}
\ No newline at end of file
diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs
new file mode 100644
index 00000000..44261737
--- /dev/null
+++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/WebhookScanTarget.cs
@@ -0,0 +1,16 @@
+using Cleanuparr.Domain.Enums;
+
+namespace Cleanuparr.Infrastructure.Features.Jobs;
+
+///
+/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an
+/// *arr "On Grab" webhook, instead of iterating the whole queue. Carried through the Quartz
+/// JobDataMap and surfaced to the handler via .
+///
+public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type)
+{
+ public const string InstanceIdKey = "webhook.instanceId";
+ public const string DownloadIdKey = "webhook.downloadId";
+ public const string ContentIdKey = "webhook.contentId";
+ public const string InstanceTypeKey = "webhook.instanceType";
+}
diff --git a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs
index d3828312..006ed1c6 100644
--- a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IJobManagementService.cs
@@ -9,6 +9,13 @@ public interface IJobManagementService
Task StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
Task StopJob(JobType jobType);
Task TriggerJobOnce(JobType jobType);
+
+ ///
+ /// Schedules targeted MalwareBlocker scans for a single download received via an *arr "On Grab"
+ /// webhook. Runs immediately plus a few delayed retries (to catch late torrent metadata) on the
+ /// dedicated webhook JobKey, independent of the scheduled MalwareBlocker job.
+ ///
+ Task TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
Task> GetAllJobs(IScheduler? scheduler = null);
Task GetJob(JobType jobType);
Task UpdateJobSchedule(JobType jobType, JobSchedule schedule);
diff --git a/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs b/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs
index a7caa334..7ba3c544 100644
--- a/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs
+++ b/code/backend/Cleanuparr.Infrastructure/Services/JobManagementService.cs
@@ -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;
@@ -368,6 +370,53 @@ public class JobManagementService : IJobManagementService
}
}
+ public async Task TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type)
+ {
+ 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;
+ }
+
+ long ticks = DateTimeOffset.UtcNow.Ticks;
+
+ foreach (TimeSpan delay in Constants.MalwareBlockerWebhookRetryDelays)
+ {
+ var jobData = new JobDataMap
+ {
+ { WebhookScanTarget.InstanceIdKey, instanceId.ToString() },
+ { WebhookScanTarget.DownloadIdKey, downloadId },
+ { WebhookScanTarget.ContentIdKey, contentId },
+ { WebhookScanTarget.InstanceTypeKey, type.ToString() },
+ };
+
+ var trigger = TriggerBuilder.Create()
+ .WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{(int)delay.TotalSeconds}-{ticks}")
+ .ForJob(jobKey)
+ .UsingJobData(jobData)
+ .StartAt(DateTimeOffset.UtcNow.Add(delay))
+ .Build();
+
+ await scheduler.ScheduleJob(trigger);
+ }
+
+ _logger.LogInformation(
+ "MalwareBlocker webhook scan scheduled for download {downloadId} on {type} instance {instanceId}",
+ 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 UpdateJobSchedule(JobType jobType, JobSchedule schedule)
{
if (schedule == null)
diff --git a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs
index bc5cf41c..89acd86a 100644
--- a/code/backend/Cleanuparr.Shared/Helpers/Constants.cs
+++ b/code/backend/Cleanuparr.Shared/Helpers/Constants.cs
@@ -22,4 +22,22 @@ 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 * * * ?";
+
+ ///
+ /// Quartz JobKey for webhook-triggered MalwareBlocker runs. Distinct from the cron JobKey
+ /// ("MalwareBlocker") so the two have independent DisallowConcurrentExecution locks.
+ ///
+ public const string MalwareBlockerWebhookJobKey = "MalwareBlockerWebhook";
+
+ ///
+ /// 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.
+ ///
+ public static readonly IReadOnlyList MalwareBlockerWebhookRetryDelays =
+ [
+ TimeSpan.Zero,
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromSeconds(120),
+ ];
}
\ No newline at end of file