mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-09-09 03:47:42 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8c5f23cf | ||
|
|
9eebeed990 | ||
|
|
a79a60a339 | ||
|
|
d1bd9fddcc | ||
|
|
96823adcc3 | ||
|
|
e0e88147aa | ||
|
|
f278a0dad0 | ||
|
|
f61300b869 | ||
|
|
561c05778c | ||
|
|
60d273991d | ||
|
|
ddb1042ca5 | ||
|
|
9a31e86ad8 | ||
|
|
614e97313e | ||
|
|
a34a3d3c7e | ||
|
|
f9588d89c0 | ||
|
|
eacd9346a5 | ||
|
|
0561c64ddf | ||
|
|
304a8e78ee | ||
|
|
e008b64a1d | ||
|
|
4f7e2d33b4 | ||
|
|
b1b19e5f29 | ||
|
|
40ab0e9fad |
No files matched your search
@@ -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>());
|
||||
}
|
||||
}
|
||||
+4
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
|
||||
namespace Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests;
|
||||
@@ -8,6 +9,8 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
{
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
public JobTriggerMode TriggerMode { get; init; } = JobTriggerMode.Schedule;
|
||||
|
||||
public string CronExpression { get; init; } = "0/5 * * * * ?";
|
||||
|
||||
public bool UseAdvancedScheduling { get; init; }
|
||||
@@ -35,6 +38,7 @@ public sealed record UpdateMalwareBlockerConfigRequest
|
||||
public ContentBlockerConfig ApplyTo(ContentBlockerConfig config)
|
||||
{
|
||||
config.Enabled = Enabled;
|
||||
config.TriggerMode = TriggerMode;
|
||||
config.CronExpression = CronExpression;
|
||||
config.UseAdvancedScheduling = UseAdvancedScheduling;
|
||||
config.IgnorePrivate = IgnorePrivate;
|
||||
|
||||
+5
-1
@@ -82,7 +82,11 @@ public sealed class MalwareBlockerConfigController : ControllerBase
|
||||
|
||||
private async Task UpdateJobSchedule(IJobConfig config, JobType jobType)
|
||||
{
|
||||
if (config.Enabled)
|
||||
// Webhook-only mode keeps the feature enabled but removes the cron trigger.
|
||||
bool scheduleEnabled = config.Enabled &&
|
||||
config is not ContentBlockerConfig { TriggerMode: JobTriggerMode.Webhook };
|
||||
|
||||
if (scheduleEnabled)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(config.CronExpression))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// The *arr webhook event types Cleanuparr acts on. Unrecognized events parse to
|
||||
/// <see cref="Unknown"/> and are ignored.
|
||||
/// </summary>
|
||||
public enum ArrWebhookEventType
|
||||
{
|
||||
Unknown = 0,
|
||||
Test,
|
||||
Grab,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, tolerant projection of the Sonarr/Radarr "On Grab" Webhook payload. Only the fields used
|
||||
/// to trigger a targeted MalwareBlocker scan are bound; all other fields are ignored.
|
||||
/// </summary>
|
||||
public sealed record ArrWebhookPayload
|
||||
{
|
||||
/// <summary>"Grab" to act on; "Test" is sent when the connection's Test button is clicked.</summary>
|
||||
public string? EventType { get; init; }
|
||||
|
||||
/// <summary>Torrent infohash (or NZB id) identifying the download in the download client.</summary>
|
||||
public string? DownloadId { get; init; }
|
||||
|
||||
/// <summary>Present on Sonarr payloads; carries the series content id.</summary>
|
||||
public ArrWebhookContent? Series { get; init; }
|
||||
|
||||
/// <summary>Present on Radarr payloads; carries the movie content id.</summary>
|
||||
public ArrWebhookContent? Movie { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ArrWebhookContent
|
||||
{
|
||||
public long Id { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Cleanuparr.Api.Extensions;
|
||||
using Cleanuparr.Api.Features.Webhooks.Contracts;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Cleanuparr.Api.Features.Webhooks.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Receives Sonarr/Radarr "On Grab" webhooks and triggers a targeted MalwareBlocker scan of the
|
||||
/// grabbed download. Authentication reuses the account API key (e.g. <c>?apikey=</c>), so the URL can
|
||||
/// be pasted directly into the *arr Webhook connection.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize]
|
||||
public sealed class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
private readonly DataContext _dataContext;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public WebhooksController(
|
||||
ILogger<WebhooksController> logger,
|
||||
DataContext dataContext,
|
||||
IJobManagementService jobManagementService)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataContext = dataContext;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
[HttpPost("malware-blocker/{instanceId:guid}")]
|
||||
public async Task<IActionResult> TriggerMalwareBlocker(Guid instanceId, [FromBody] ArrWebhookPayload payload)
|
||||
{
|
||||
Enum.TryParse(payload.EventType, ignoreCase: true, out ArrWebhookEventType eventType);
|
||||
|
||||
// The Test button sends an event we acknowledge without doing any work.
|
||||
if (eventType is ArrWebhookEventType.Test)
|
||||
{
|
||||
_logger.LogInformation("Received MalwareBlocker test webhook for instance {instanceId}", instanceId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (eventType is not ArrWebhookEventType.Grab)
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook event '{eventType}' for instance {instanceId}",
|
||||
payload.EventType, instanceId);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
ArrConfig? arrConfig;
|
||||
ArrInstance? instance;
|
||||
ContentBlockerConfig config;
|
||||
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
arrConfig = await _dataContext.ArrConfigs
|
||||
.Include(x => x.Instances)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Instances.Any(i => i.Id == instanceId));
|
||||
instance = arrConfig?.Instances.FirstOrDefault(i => i.Id == instanceId);
|
||||
config = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
|
||||
if (arrConfig is null || instance is null)
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status404NotFound, $"No arr instance found with id {instanceId}");
|
||||
}
|
||||
|
||||
if (arrConfig.Type is not (InstanceType.Sonarr or InstanceType.Radarr))
|
||||
{
|
||||
return this.ProblemResult(StatusCodes.Status422UnprocessableEntity, "MalwareBlocker webhooks are only supported for Sonarr and Radarr");
|
||||
}
|
||||
|
||||
if (!config.Enabled || config.TriggerMode is JobTriggerMode.Schedule)
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook | webhook triggering is not enabled");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.DownloadId))
|
||||
{
|
||||
_logger.LogDebug("Ignoring MalwareBlocker webhook | no download id in payload (usenet or pre-grab)");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
long contentId = arrConfig.Type switch
|
||||
{
|
||||
InstanceType.Sonarr => payload.Series?.Id ?? 0,
|
||||
InstanceType.Radarr => payload.Movie?.Id ?? 0,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
await _jobManagementService.TriggerMalwareBlockerWebhook(instanceId, payload.DownloadId, contentId, arrConfig.Type);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Domain.Exceptions;
|
||||
using Cleanuparr.Infrastructure.Features.BlacklistSync;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
@@ -112,6 +113,7 @@ public class BackgroundJobManager : IHostedService
|
||||
// Always register jobs, regardless of enabled status
|
||||
await RegisterQueueCleanerJob(queueCleanerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerJob(malwareBlockerConfig, cancellationToken);
|
||||
await RegisterMalwareBlockerWebhookJob(cancellationToken);
|
||||
await RegisterDownloadCleanerJob(downloadCleanerConfig, cancellationToken);
|
||||
await RegisterBlacklistSyncJob(blacklistSyncConfig, cancellationToken);
|
||||
await RegisterSeekerJob(seekerConfig, cancellationToken);
|
||||
@@ -144,13 +146,24 @@ public class BackgroundJobManager : IHostedService
|
||||
{
|
||||
// Always register the job definition
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken);
|
||||
|
||||
// Only add triggers if the job is enabled
|
||||
if (config.Enabled)
|
||||
|
||||
// Only add the cron trigger when scheduling is part of the trigger mode
|
||||
if (config.Enabled && config.TriggerMode is not JobTriggerMode.Webhook)
|
||||
{
|
||||
await AddTriggersForJob<MalwareBlocker>(config.CronExpression, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the webhook-triggered MalwareBlocker job under a dedicated JobKey (no cron trigger).
|
||||
/// The dedicated key gives webhook runs their own DisallowConcurrentExecution lock, independent of
|
||||
/// the scheduled MalwareBlocker job. Triggers are scheduled on demand when an "On Grab" webhook
|
||||
/// is received.
|
||||
/// </summary>
|
||||
public async Task RegisterMalwareBlockerWebhookJob(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await AddJobWithoutTrigger<MalwareBlocker>(cancellationToken, Constants.MalwareBlockerWebhookJobKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the DownloadCleaner job and optionally adds triggers based on configuration.
|
||||
@@ -273,17 +286,17 @@ public class BackgroundJobManager : IHostedService
|
||||
/// <summary>
|
||||
/// Helper method to add a job without a trigger (for chained jobs).
|
||||
/// </summary>
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default)
|
||||
private async Task AddJobWithoutTrigger<T>(CancellationToken cancellationToken = default, string? jobKeyName = null)
|
||||
where T : IHandler
|
||||
{
|
||||
if (_scheduler == null)
|
||||
{
|
||||
throw new InvalidOperationException("Scheduler not initialized");
|
||||
}
|
||||
|
||||
string typeName = typeof(T).Name;
|
||||
|
||||
string typeName = jobKeyName ?? typeof(T).Name;
|
||||
var jobKey = new JobKey(typeName);
|
||||
|
||||
|
||||
// Check if job already exists
|
||||
if (await _scheduler.CheckExists(jobKey, cancellationToken))
|
||||
{
|
||||
|
||||
@@ -48,6 +48,8 @@ public sealed class GenericJob<T> : IJob
|
||||
ContextProvider.SetJobRunId(jobRunId);
|
||||
using var __ = LogContext.PushProperty(LogProperties.JobRunId, jobRunId.ToString());
|
||||
|
||||
SetWebhookScanTarget(context);
|
||||
|
||||
await BroadcastJobStatus(hubContext, jobManagementService, jobType, false);
|
||||
|
||||
var handler = scope.ServiceProvider.GetRequiredService<T>();
|
||||
@@ -75,6 +77,34 @@ public sealed class GenericJob<T> : IJob
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the firing trigger carries a webhook scan target in its JobDataMap, surfaces it to the
|
||||
/// handler via the ContextProvider so the run scans only that download. No-op for normal triggers.
|
||||
/// </summary>
|
||||
private static void SetWebhookScanTarget(IJobExecutionContext context)
|
||||
{
|
||||
JobDataMap dataMap = context.MergedJobDataMap;
|
||||
|
||||
if (!dataMap.ContainsKey(WebhookScanTarget.InstanceIdKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Guid.TryParse(dataMap.GetString(WebhookScanTarget.InstanceIdKey), out Guid instanceId) ||
|
||||
!Enum.TryParse(dataMap.GetString(WebhookScanTarget.InstanceTypeKey), out InstanceType instanceType))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string downloadId = dataMap.GetString(WebhookScanTarget.DownloadIdKey) ?? string.Empty;
|
||||
long contentId = dataMap.GetLong(WebhookScanTarget.ContentIdKey);
|
||||
int retryIndex = dataMap.ContainsKey(WebhookScanTarget.RetryIndexKey)
|
||||
? dataMap.GetInt(WebhookScanTarget.RetryIndexKey)
|
||||
: 0;
|
||||
|
||||
ContextProvider.Set(new WebhookScanTarget(instanceId, downloadId, contentId, instanceType, retryIndex));
|
||||
}
|
||||
|
||||
private async Task BroadcastJobStatus(IHubContext<AppHub> hubContext, IJobManagementService jobManagementService, JobType jobType, bool isFinished)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
public enum JobTriggerMode
|
||||
{
|
||||
Schedule,
|
||||
Webhook,
|
||||
Both,
|
||||
}
|
||||
+4
-1
@@ -44,7 +44,8 @@ public class MalwareBlockerIntegrationTests : IDisposable
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.BlocklistProvider,
|
||||
_fixture.EventPublisher);
|
||||
_fixture.EventPublisher,
|
||||
Substitute.For<Cleanuparr.Infrastructure.Services.Interfaces.IJobManagementService>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -70,6 +71,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked,
|
||||
IsPrivate = false
|
||||
@@ -200,6 +202,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked,
|
||||
IsPrivate = true
|
||||
|
||||
@@ -5,6 +5,7 @@ using Cleanuparr.Infrastructure.Features.Arr;
|
||||
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
|
||||
using Cleanuparr.Infrastructure.Tests.TestHelpers;
|
||||
@@ -49,7 +50,8 @@ public class MalwareBlockerTests : IDisposable
|
||||
_fixture.ArrQueueIterator,
|
||||
_fixture.DownloadServiceFactory,
|
||||
_fixture.BlocklistProvider,
|
||||
_fixture.EventPublisher
|
||||
_fixture.EventPublisher,
|
||||
_fixture.JobManagementService
|
||||
);
|
||||
}
|
||||
|
||||
@@ -276,7 +278,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<List<string>>()
|
||||
)
|
||||
.Returns(new BlockFilesResult { Found = true, ShouldRemove = false });
|
||||
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
@@ -339,6 +341,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
@@ -362,6 +365,215 @@ public class MalwareBlockerTests : IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenWebhookTarget_ScansOnlyMatchingDownload()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = Substitute.For<IArrClient>();
|
||||
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
var matching = new QueueRecord { Id = 1, DownloadId = "match-hash", Title = "Match", Protocol = "torrent", SeriesId = 5, EpisodeId = 1 };
|
||||
var other = new QueueRecord { Id = 2, DownloadId = "other-hash", Title = "Other", Protocol = "torrent", SeriesId = 5, EpisodeId = 2 };
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Iterate(
|
||||
Arg.Any<IArrClient>(),
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(),
|
||||
Arg.Any<long?>()
|
||||
)
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
|
||||
return callback([matching, other]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
|
||||
.Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
|
||||
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
|
||||
sonarrInstance.Id, "match-hash", 5, InstanceType.Sonarr));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await mockDownloadService.Received(1).BlockUnwantedFilesAsync("match-hash", Arg.Any<List<string>>());
|
||||
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync("other-hash", Arg.Any<List<string>>());
|
||||
// Found in a client -> resolved, no retry scheduled
|
||||
await _fixture.JobManagementService.DidNotReceive()
|
||||
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenWebhookTargetNotFoundInClient_SchedulesRetry()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = Substitute.For<IArrClient>();
|
||||
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
var record = new QueueRecord { Id = 1, DownloadId = "pending-hash", Title = "Pending", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
|
||||
return callback([record]);
|
||||
});
|
||||
|
||||
// Torrent not (yet) present in any client
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
|
||||
.Returns(new BlockFilesResult { Found = false });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
|
||||
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
|
||||
sonarrInstance.Id, "pending-hash", 7, InstanceType.Sonarr, RetryIndex: 1));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
|
||||
Arg.Is<WebhookScanTarget>(t =>
|
||||
t.InstanceId == sonarrInstance.Id &&
|
||||
t.DownloadId == "pending-hash" &&
|
||||
t.ContentId == 7 &&
|
||||
t.Type == InstanceType.Sonarr &&
|
||||
t.RetryIndex == 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenWebhookTargetMetadataMissing_SchedulesRetry()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = Substitute.For<IArrClient>();
|
||||
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 };
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
|
||||
return callback([record]);
|
||||
});
|
||||
|
||||
// Torrent found in the client, but its metadata/file list is not ready yet
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>())
|
||||
.Returns(new BlockFilesResult { Found = true });
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
|
||||
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
|
||||
sonarrInstance.Id, "metadl-hash", 7, InstanceType.Sonarr, RetryIndex: 0));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await _fixture.JobManagementService.Received(1).ScheduleMalwareBlockerWebhookRetry(
|
||||
Arg.Is<WebhookScanTarget>(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry()
|
||||
{
|
||||
// Arrange
|
||||
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
|
||||
EnableSonarrBlocklist();
|
||||
var sonarrInstance = TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
|
||||
|
||||
var mockArrClient = Substitute.For<IArrClient>();
|
||||
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.Returns(mockArrClient);
|
||||
|
||||
var record = new QueueRecord { Id = 1, DownloadId = "usenet-id", Title = "Usenet", Protocol = "usenet", SeriesId = 9, EpisodeId = 1 };
|
||||
|
||||
_fixture.ArrQueueIterator
|
||||
.Iterate(Arg.Any<IArrClient>(), Arg.Any<ArrInstance>(), Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>(), Arg.Any<long?>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
|
||||
return callback([record]);
|
||||
});
|
||||
|
||||
var mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
Cleanuparr.Infrastructure.Features.Context.ContextProvider.Set(
|
||||
new Cleanuparr.Infrastructure.Features.Jobs.WebhookScanTarget(
|
||||
sonarrInstance.Id, "usenet-id", 9, InstanceType.Sonarr));
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert: usenet is acknowledged once seen in the queue -> no scan, no retry
|
||||
await mockDownloadService.DidNotReceive().BlockUnwantedFilesAsync(Arg.Any<string>(), Arg.Any<List<string>>());
|
||||
await _fixture.JobManagementService.DidNotReceive()
|
||||
.ScheduleMalwareBlockerWebhookRetry(Arg.Any<WebhookScanTarget>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest()
|
||||
{
|
||||
@@ -409,6 +621,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AtLeastOneFileBlocked
|
||||
@@ -484,6 +697,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = true,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
@@ -665,6 +879,7 @@ public class MalwareBlockerTests : IDisposable
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
MetadataFound = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AllFilesBlocked
|
||||
|
||||
+4
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.Files;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Interceptors;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -30,6 +31,7 @@ public class JobHandlerFixture : IDisposable
|
||||
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
|
||||
public IEventPublisher EventPublisher { get; private set; }
|
||||
public IBlocklistProvider BlocklistProvider { get; private set; }
|
||||
public IJobManagementService JobManagementService { get; private set; }
|
||||
public IHardLinkFileService HardLinkFileService { get; private set; }
|
||||
public IDryRunInterceptor DryRunInterceptor { get; private set; }
|
||||
public FakeTimeProvider TimeProvider { get; private set; }
|
||||
@@ -52,6 +54,7 @@ public class JobHandlerFixture : IDisposable
|
||||
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
JobManagementService = Substitute.For<IJobManagementService>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
TimeProvider = new FakeTimeProvider();
|
||||
@@ -151,6 +154,7 @@ public class JobHandlerFixture : IDisposable
|
||||
DownloadServiceFactory = Substitute.For<IDownloadServiceFactory>();
|
||||
EventPublisher = Substitute.For<IEventPublisher>();
|
||||
BlocklistProvider = Substitute.For<IBlocklistProvider>();
|
||||
JobManagementService = Substitute.For<IJobManagementService>();
|
||||
HardLinkFileService = Substitute.For<IHardLinkFileService>();
|
||||
DryRunInterceptor = Substitute.For<IDryRunInterceptor>();
|
||||
Cache.Clear();
|
||||
|
||||
@@ -512,63 +512,4 @@ public class JobManagementServiceTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMainTrigger Tests
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_JobDoesNotExist_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_TriggerExists_ReturnsTrigger()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.MalwareBlocker;
|
||||
var expectedTriggerKey = new TriggerKey("MalwareBlocker-trigger");
|
||||
|
||||
var trigger = Substitute.For<ITrigger>();
|
||||
trigger.Key.Returns(expectedTriggerKey);
|
||||
|
||||
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(true);
|
||||
_scheduler.GetTrigger(expectedTriggerKey, Arg.Any<CancellationToken>())
|
||||
.Returns(trigger);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
result.ShouldNotBeNull();
|
||||
result.Key.ShouldBe(expectedTriggerKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMainTrigger_WhenSchedulerThrows_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var jobType = JobType.QueueCleaner;
|
||||
|
||||
_scheduler.CheckExists(Arg.Any<JobKey>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new Exception("Scheduler error"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetMainTrigger(jobType);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -33,11 +33,11 @@ public abstract class ArrClient : IArrClient
|
||||
_dryRunInterceptor = dryRunInterceptor;
|
||||
}
|
||||
|
||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page)
|
||||
public virtual async Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null)
|
||||
{
|
||||
UriBuilder uriBuilder = new(arrInstance.Url);
|
||||
uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueUrlPath().TrimStart('/')}";
|
||||
uriBuilder.Query = GetQueueUrlQuery(page);
|
||||
uriBuilder.Query = GetQueueUrlQuery(page, contentId);
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, uriBuilder.Uri);
|
||||
SetApiKey(request, arrInstance.ApiKey);
|
||||
@@ -271,7 +271,7 @@ public abstract class ArrClient : IArrClient
|
||||
|
||||
protected abstract string GetQueueUrlPath();
|
||||
|
||||
protected abstract string GetQueueUrlQuery(int page);
|
||||
protected abstract string GetQueueUrlQuery(int page, long? contentId = null);
|
||||
|
||||
protected abstract string GetQueueDeleteUrlPath(long recordId);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action)
|
||||
public async Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null)
|
||||
{
|
||||
const ushort maxPage = 100;
|
||||
ushort page = 1;
|
||||
@@ -23,7 +23,7 @@ public sealed class ArrQueueIterator : IArrQueueIterator
|
||||
|
||||
do
|
||||
{
|
||||
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page);
|
||||
QueueListResponse queueResponse = await arrClient.GetQueueItemsAsync(arrInstance, page, contentId);
|
||||
|
||||
if (totalRecords is 0)
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IArrClient
|
||||
{
|
||||
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page);
|
||||
Task<QueueListResponse> GetQueueItemsAsync(ArrInstance arrInstance, int page, long? contentId = null);
|
||||
|
||||
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ namespace Cleanuparr.Infrastructure.Features.Arr.Interfaces;
|
||||
|
||||
public interface IArrQueueIterator
|
||||
{
|
||||
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action);
|
||||
Task Iterate(IArrClient arrClient, ArrInstance arrInstance, Func<IReadOnlyList<QueueRecord>, Task> action, long? contentId = null);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class LidarrClient : ArrClient, ILidarrClient
|
||||
return "/api/v1/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true";
|
||||
}
|
||||
|
||||
@@ -32,9 +32,16 @@ public class RadarrClient : ArrClient, IRadarrClient
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
|
||||
string query = $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
|
||||
|
||||
if (contentId is not null)
|
||||
{
|
||||
query += $"&movieIds={contentId}";
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlPath(long recordId)
|
||||
|
||||
@@ -32,7 +32,7 @@ public class ReadarrClient : ArrClient, IReadarrClient
|
||||
return "/api/v1/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true";
|
||||
}
|
||||
|
||||
@@ -34,9 +34,16 @@ public class SonarrClient : ArrClient, ISonarrClient
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
|
||||
string query = $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
|
||||
|
||||
if (contentId is not null)
|
||||
{
|
||||
query += $"&seriesIds={contentId}";
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
protected override string GetQueueDeleteUrlPath(long recordId)
|
||||
|
||||
@@ -34,7 +34,7 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true";
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client
|
||||
return "/api/v3/queue";
|
||||
}
|
||||
|
||||
protected override string GetQueueUrlQuery(int page)
|
||||
protected override string GetQueueUrlQuery(int page, long? contentId = null)
|
||||
{
|
||||
return $"page={page}&pageSize=200&includeUnknownMovieItems=true&includeMovie=true";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ public sealed record BlockFilesResult
|
||||
public bool IsPrivate { get; set; }
|
||||
|
||||
public bool Found { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when the torrent's file list (metadata) was available so the scan could complete (or was not needed).
|
||||
/// </summary>
|
||||
public bool MetadataFound { get; set; }
|
||||
|
||||
public DeleteReason DeleteReason { get; set; } = DeleteReason.None;
|
||||
}
|
||||
+9
-4
@@ -32,15 +32,17 @@ public partial class DelugeService
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
|
||||
if (malwareBlockerConfig.IgnorePrivate && download.Private)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,11 +57,14 @@ public partial class DelugeService
|
||||
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
|
||||
}
|
||||
|
||||
if (contents is null)
|
||||
if (contents is null || contents.Contents?.Count is null or 0)
|
||||
{
|
||||
_logger.LogDebug("torrent has no files | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
result.MetadataFound = true;
|
||||
|
||||
Dictionary<int, int> priorities = [];
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
|
||||
+5
@@ -30,6 +30,8 @@ public partial class QBitService
|
||||
(download.ShouldIgnore(ignoredDownloads) || trackers.Any(x => x.ShouldIgnore(ignoredDownloads)) is true))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
result.Found = true;
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ public partial class QBitService
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -66,6 +69,8 @@ public partial class QBitService
|
||||
return result;
|
||||
}
|
||||
|
||||
result.MetadataFound = true;
|
||||
|
||||
List<int> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
+4
@@ -36,6 +36,7 @@ public partial class RTorrentService
|
||||
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ public partial class RTorrentService
|
||||
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
|
||||
{
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -64,6 +66,8 @@ public partial class RTorrentService
|
||||
return result;
|
||||
}
|
||||
|
||||
result.MetadataFound = true;
|
||||
|
||||
bool hasPriorityUpdates = false;
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
+10
-7
@@ -17,27 +17,30 @@ public partial class TransmissionService
|
||||
TorrentInfo? download = await GetTorrentAsync(hash);
|
||||
BlockFilesResult result = new();
|
||||
|
||||
if (download?.FileStats is null || download.FileStats.Length == 0)
|
||||
if (download is null)
|
||||
{
|
||||
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (download.Files is null)
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
|
||||
if (download.FileStats?.Length is null or 0 || download.Files?.Length is null or 0)
|
||||
{
|
||||
_logger.LogDebug("torrent {hash} has no files", hash);
|
||||
_logger.LogDebug("torrent has no files | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.MetadataFound = true;
|
||||
|
||||
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
|
||||
{
|
||||
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isPrivate = download.IsPrivate ?? false;
|
||||
result.IsPrivate = isPrivate;
|
||||
result.Found = true;
|
||||
SetDownloadClientContext();
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
@@ -48,7 +51,7 @@ public partial class TransmissionService
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
List<long> unwantedFiles = [];
|
||||
long totalFiles = 0;
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
+5
-1
@@ -35,15 +35,17 @@ public partial class UTorrentService
|
||||
(download.ShouldIgnore(ignoredDownloads) || properties.TrackerList.Any(x => x.ShouldIgnore(ignoredDownloads))))
|
||||
{
|
||||
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
|
||||
if (malwareBlockerConfig.IgnorePrivate && result.IsPrivate)
|
||||
{
|
||||
// ignore private trackers
|
||||
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
|
||||
result.MetadataFound = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,6 +57,8 @@ public partial class UTorrentService
|
||||
return result;
|
||||
}
|
||||
|
||||
result.MetadataFound = true;
|
||||
|
||||
List<int> fileIndexes = new(files.Count);
|
||||
long totalUnwantedFiles = 0;
|
||||
|
||||
|
||||
@@ -87,8 +87,9 @@ public sealed class DownloadCleaner : GenericHandler
|
||||
{
|
||||
DownloadCleanerConfig config = ContextProvider.Get<DownloadCleanerConfig>();
|
||||
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(config.IgnoredDownloads);
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
|
||||
.Concat(config.IgnoredDownloads)
|
||||
.ToList();
|
||||
|
||||
Dictionary<IDownloadService, List<ITorrentItemWrapper>> downloadServiceToDownloadsMap = new();
|
||||
List<IDownloadService> loggedInServices = new();
|
||||
|
||||
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.Context;
|
||||
using Cleanuparr.Infrastructure.Features.DownloadClient;
|
||||
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
|
||||
using Cleanuparr.Infrastructure.Helpers;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Arr;
|
||||
@@ -21,6 +22,7 @@ namespace Cleanuparr.Infrastructure.Features.Jobs;
|
||||
public sealed class MalwareBlocker : GenericHandler
|
||||
{
|
||||
private readonly IBlocklistProvider _blocklistProvider;
|
||||
private readonly IJobManagementService _jobManagementService;
|
||||
|
||||
public MalwareBlocker(
|
||||
ILogger<MalwareBlocker> logger,
|
||||
@@ -31,13 +33,15 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
IArrQueueIterator arrArrQueueIterator,
|
||||
IDownloadServiceFactory downloadServiceFactory,
|
||||
IBlocklistProvider blocklistProvider,
|
||||
IEventPublisher eventPublisher
|
||||
IEventPublisher eventPublisher,
|
||||
IJobManagementService jobManagementService
|
||||
) : base(
|
||||
logger, dataContext, cache, messageBus,
|
||||
arrClientFactory, arrArrQueueIterator, downloadServiceFactory, eventPublisher
|
||||
)
|
||||
{
|
||||
_blocklistProvider = blocklistProvider;
|
||||
_jobManagementService = jobManagementService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteInternalAsync(CancellationToken cancellationToken = default)
|
||||
@@ -62,6 +66,12 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
|
||||
await _blocklistProvider.LoadBlocklistsAsync();
|
||||
|
||||
if (ContextProvider.Get(nameof(WebhookScanTarget)) is WebhookScanTarget webhookTarget)
|
||||
{
|
||||
await ProcessWebhookTargetAsync(malwareBlockerConfig, webhookTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
var sonarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Sonarr));
|
||||
var radarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Radarr));
|
||||
var lidarrConfig = ContextProvider.Get<ArrConfig>(nameof(InstanceType.Lidarr));
|
||||
@@ -94,10 +104,52 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance)
|
||||
/// <summary>
|
||||
/// Scans a single download identified by an *arr "On Grab" webhook, restricted to the originating instance, instead of iterating the whole queue.
|
||||
/// Schedules the next retry only when the download was not yet found/scanned.
|
||||
/// </summary>
|
||||
private async Task ProcessWebhookTargetAsync(ContentBlockerConfig config, WebhookScanTarget target)
|
||||
{
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
ignoredDownloads.AddRange(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads);
|
||||
BlocklistSettings? blocklist = target.Type switch
|
||||
{
|
||||
InstanceType.Sonarr => config.Sonarr,
|
||||
InstanceType.Radarr => config.Radarr,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (blocklist is null || !blocklist.Enabled)
|
||||
{
|
||||
_logger.LogDebug("skip webhook scan | blocklist for {type} is not enabled", target.Type);
|
||||
return;
|
||||
}
|
||||
|
||||
ArrConfig arrConfig = ContextProvider.Get<ArrConfig>(target.Type.ToString());
|
||||
ArrInstance? instance = arrConfig.Instances
|
||||
.FirstOrDefault(x => x.Id == target.InstanceId && x.Enabled);
|
||||
|
||||
if (instance is null)
|
||||
{
|
||||
_logger.LogWarning("skip webhook scan | instance {id} not found or disabled", target.InstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
instance.ArrConfig = arrConfig;
|
||||
|
||||
bool resolved = await ScanInstanceAsync(instance, target);
|
||||
|
||||
if (!resolved)
|
||||
{
|
||||
await _jobManagementService.ScheduleMalwareBlockerWebhookRetry(target);
|
||||
}
|
||||
}
|
||||
|
||||
protected override Task ProcessInstanceAsync(ArrInstance instance) => ScanInstanceAsync(instance);
|
||||
|
||||
private async Task<bool> ScanInstanceAsync(ArrInstance instance, WebhookScanTarget? target = null)
|
||||
{
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
|
||||
.Concat(ContextProvider.Get<ContentBlockerConfig>().IgnoredDownloads)
|
||||
.ToList();
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
|
||||
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
|
||||
@@ -111,8 +163,10 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
ContextProvider.Set(ContextProvider.Keys.Version, instance.Version);
|
||||
|
||||
IReadOnlyList<IDownloadService> downloadServices = await GetInitializedDownloadServicesAsync();
|
||||
|
||||
var config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
ContentBlockerConfig config = ContextProvider.Get<ContentBlockerConfig>();
|
||||
|
||||
bool targetResolved = false;
|
||||
|
||||
await _arrArrQueueIterator.Iterate(arrClient, instance, async items =>
|
||||
{
|
||||
@@ -129,108 +183,148 @@ public sealed class MalwareBlocker : GenericHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("skip | {title} | ignored", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
bool hasContentId = arrClient.HasContentId(record);
|
||||
|
||||
if (!hasContentId)
|
||||
{
|
||||
if (!config.ProcessNoContentId)
|
||||
{
|
||||
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item is missing the content id | {title}", record.Title);
|
||||
}
|
||||
|
||||
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
{
|
||||
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// push record to context
|
||||
ContextProvider.Set(nameof(QueueRecord), record);
|
||||
|
||||
BlockFilesResult result = new();
|
||||
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
|
||||
DownloadClientConfig? foundInClient = null;
|
||||
|
||||
if (isTorrent)
|
||||
{
|
||||
var torrentClients = downloadServices
|
||||
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
|
||||
if (torrentClients.Count > 0)
|
||||
{
|
||||
// Check each download client for the download item
|
||||
foreach (var downloadService in torrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
// stalled download check
|
||||
result = await downloadService
|
||||
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
|
||||
|
||||
if (result.Found)
|
||||
{
|
||||
foundInClient = downloadService.ClientConfig;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
record.Title, downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.Found)
|
||||
{
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No torrent clients enabled");
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.ShouldRemove)
|
||||
if (target is not null &&
|
||||
!string.Equals(record.DownloadId, target.DownloadId, StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool removeFromClient = true;
|
||||
bool resolved = await TryProcessRecordAsync(group, instance, arrClient, downloadServices, ignoredDownloads, config);
|
||||
|
||||
if (result.IsPrivate && !config.DeletePrivate)
|
||||
if (target is not null)
|
||||
{
|
||||
removeFromClient = false;
|
||||
targetResolved = resolved;
|
||||
}
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
result.DeleteReason,
|
||||
skipSearch: !hasContentId,
|
||||
downloadClient: foundInClient
|
||||
);
|
||||
}
|
||||
});
|
||||
}, contentId: target is { ContentId: > 0 } ? target.ContentId : null);
|
||||
|
||||
return targetResolved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans a single grouped download. Returns <c>true</c> when the download is resolved — found in a
|
||||
/// client and evaluated, a usenet record, or deliberately skipped — and <c>false</c> only when it is
|
||||
/// a torrent that was not found in any download client yet (the case a webhook scan should retry).
|
||||
/// </summary>
|
||||
private async Task<bool> TryProcessRecordAsync(
|
||||
IGrouping<string, QueueRecord> group,
|
||||
ArrInstance instance,
|
||||
IArrClient arrClient,
|
||||
IReadOnlyList<IDownloadService> downloadServices,
|
||||
List<string> ignoredDownloads,
|
||||
ContentBlockerConfig config)
|
||||
{
|
||||
QueueRecord record = group.First();
|
||||
|
||||
if (ignoredDownloads.Contains(record.DownloadId, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("skip | {title} | ignored", record.Title);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogTrace("processing | {title} | {id}", record.Title, record.DownloadId);
|
||||
|
||||
bool hasContentId = arrClient.HasContentId(record);
|
||||
|
||||
if (!hasContentId)
|
||||
{
|
||||
if (!config.ProcessNoContentId)
|
||||
{
|
||||
_logger.LogInformation("skip | item is missing the content id | {title}", record.Title);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("item is missing the content id | {title}", record.Title);
|
||||
}
|
||||
|
||||
string downloadRemovalKey = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
|
||||
|
||||
if (_cache.TryGetValue(downloadRemovalKey, out bool _))
|
||||
{
|
||||
_logger.LogDebug("skip | already marked for removal | {title}", record.Title);
|
||||
return true;
|
||||
}
|
||||
|
||||
// push record to context
|
||||
ContextProvider.Set(nameof(QueueRecord), record);
|
||||
|
||||
bool isTorrent = record.Protocol.Contains("torrent", StringComparison.InvariantCultureIgnoreCase);
|
||||
|
||||
if (!isTorrent)
|
||||
{
|
||||
// Usenet is acknowledged once it appears in the queue; nothing to scan, no retry.
|
||||
return true;
|
||||
}
|
||||
|
||||
BlockFilesResult result = new();
|
||||
DownloadClientConfig? foundInClient = null;
|
||||
|
||||
var torrentClients = downloadServices
|
||||
.Where(x => x.ClientConfig.Type is DownloadClientType.Torrent)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug("searching unwanted files for {title}", record.Title);
|
||||
|
||||
if (torrentClients.Count > 0)
|
||||
{
|
||||
// Check each download client for the download item
|
||||
foreach (var downloadService in torrentClients)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = await downloadService
|
||||
.BlockUnwantedFilesAsync(record.DownloadId, ignoredDownloads);
|
||||
|
||||
if (result.Found)
|
||||
{
|
||||
foundInClient = downloadService.ClientConfig;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error checking download {dName} with download client {cName}",
|
||||
record.Title, downloadService.ClientConfig.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.Found)
|
||||
{
|
||||
_logger.LogWarning("Download not found in any torrent client | {title}", record.Title);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No torrent clients enabled");
|
||||
}
|
||||
|
||||
if (!result.Found || !result.MetadataFound)
|
||||
{
|
||||
// Retry while the torrent is not yet in a client, or is present but its file list/metadata isn't ready.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (result.ShouldRemove)
|
||||
{
|
||||
bool removeFromClient = true;
|
||||
|
||||
if (result.IsPrivate && !config.DeletePrivate)
|
||||
{
|
||||
removeFromClient = false;
|
||||
}
|
||||
|
||||
await PublishQueueItemRemoveRequest(
|
||||
downloadRemovalKey,
|
||||
instance,
|
||||
record,
|
||||
group.Count() > 1,
|
||||
removeFromClient,
|
||||
result.DeleteReason,
|
||||
skipSearch: !hasContentId,
|
||||
downloadClient: foundInClient
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -81,9 +81,10 @@ public sealed class QueueCleaner : GenericHandler
|
||||
|
||||
protected override async Task ProcessInstanceAsync(ArrInstance instance)
|
||||
{
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads;
|
||||
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>();
|
||||
ignoredDownloads.AddRange(queueCleanerConfig.IgnoredDownloads);
|
||||
List<string> ignoredDownloads = ContextProvider.Get<GeneralConfig>(nameof(GeneralConfig)).IgnoredDownloads
|
||||
.Concat(queueCleanerConfig.IgnoredDownloads)
|
||||
.ToList();
|
||||
|
||||
using var _ = LogContext.PushProperty(LogProperties.Category, instance.ArrConfig.Type.ToString());
|
||||
using var _2 = LogContext.PushProperty(LogProperties.InstanceName, instance.Name);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies a single download that should be scanned by the MalwareBlocker as the result of an *arr "On Grab" webhook.
|
||||
/// </summary>
|
||||
public sealed record WebhookScanTarget(Guid InstanceId, string DownloadId, long ContentId, InstanceType Type, int RetryIndex = 0)
|
||||
{
|
||||
public const string InstanceIdKey = "webhook.instanceId";
|
||||
public const string DownloadIdKey = "webhook.downloadId";
|
||||
public const string ContentIdKey = "webhook.contentId";
|
||||
public const string InstanceTypeKey = "webhook.instanceType";
|
||||
public const string RetryIndexKey = "webhook.retryIndex";
|
||||
}
|
||||
+13
-1
@@ -1,4 +1,5 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Quartz;
|
||||
|
||||
@@ -9,8 +10,19 @@ public interface IJobManagementService
|
||||
Task<bool> StartJob(JobType jobType, JobSchedule? schedule = null, string? directCronExpression = null);
|
||||
Task<bool> StopJob(JobType jobType);
|
||||
Task<bool> TriggerJobOnce(JobType jobType);
|
||||
|
||||
/// <summary>
|
||||
/// Schedules the first targeted MalwareBlocker scan for a single download received via an *arr "On Grab" webhook.
|
||||
/// Subsequent retries are scheduled by the handler via <see cref="ScheduleMalwareBlockerWebhookRetry"/> only while the download has not been found.
|
||||
/// </summary>
|
||||
Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type);
|
||||
|
||||
/// <summary>
|
||||
/// Schedules the next targeted MalwareBlocker webhook scan after a completed attempt.
|
||||
/// </summary>
|
||||
Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target);
|
||||
|
||||
Task<IReadOnlyList<JobInfo>> GetAllJobs(IScheduler? scheduler = null);
|
||||
Task<JobInfo> GetJob(JobType jobType);
|
||||
Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule);
|
||||
Task<ITrigger?> GetMainTrigger(JobType jobType);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Jobs;
|
||||
using Cleanuparr.Infrastructure.Models;
|
||||
using Cleanuparr.Infrastructure.Services.Interfaces;
|
||||
using Cleanuparr.Infrastructure.Utilities;
|
||||
using Cleanuparr.Shared.Helpers;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
@@ -168,34 +170,6 @@ public class JobManagementService : IJobManagementService
|
||||
_logger.LogWarning(ex, "Failed to trigger job {jobName} immediately", jobKey.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the main scheduled trigger for a job (excludes one-time triggers)
|
||||
/// </summary>
|
||||
public async Task<ITrigger?> GetMainTrigger(JobType jobType)
|
||||
{
|
||||
string jobName = jobType.ToString();
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(jobName);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look for the main trigger (follows our naming convention)
|
||||
var mainTriggerKey = new TriggerKey($"{jobName}-trigger");
|
||||
return await scheduler.GetTrigger(mainTriggerKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting main trigger for job {jobName}", jobName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> StopJob(JobType jobType)
|
||||
{
|
||||
@@ -368,6 +342,71 @@ public class JobManagementService : IJobManagementService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> TriggerMalwareBlockerWebhook(Guid instanceId, string downloadId, long contentId, InstanceType type)
|
||||
{
|
||||
return ScheduleWebhookAttempt(instanceId, downloadId, contentId, type, attemptIndex: 0);
|
||||
}
|
||||
|
||||
public Task<bool> ScheduleMalwareBlockerWebhookRetry(WebhookScanTarget target)
|
||||
{
|
||||
int nextIndex = target.RetryIndex + 1;
|
||||
|
||||
if (nextIndex >= Constants.MalwareBlockerWebhookRetryDelays.Count)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"{name} webhook scan gave up for download {downloadId} on {type} instance {instanceId} after {attemptCount} attempts",
|
||||
nameof(JobType.MalwareBlocker), target.DownloadId, target.Type, target.InstanceId, Constants.MalwareBlockerWebhookRetryDelays.Count);
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
return ScheduleWebhookAttempt(target.InstanceId, target.DownloadId, target.ContentId, target.Type, nextIndex);
|
||||
}
|
||||
|
||||
private async Task<bool> ScheduleWebhookAttempt(Guid instanceId, string downloadId, long contentId, InstanceType type, int attemptIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scheduler = await _schedulerFactory.GetScheduler();
|
||||
var jobKey = new JobKey(Constants.MalwareBlockerWebhookJobKey);
|
||||
|
||||
if (!await scheduler.CheckExists(jobKey))
|
||||
{
|
||||
_logger.LogError("Job {name} does not exist", Constants.MalwareBlockerWebhookJobKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
TimeSpan delay = Constants.MalwareBlockerWebhookRetryDelays[attemptIndex];
|
||||
|
||||
var jobData = new JobDataMap
|
||||
{
|
||||
{ WebhookScanTarget.InstanceIdKey, instanceId.ToString() },
|
||||
{ WebhookScanTarget.DownloadIdKey, downloadId },
|
||||
{ WebhookScanTarget.ContentIdKey, contentId },
|
||||
{ WebhookScanTarget.InstanceTypeKey, type.ToString() },
|
||||
{ WebhookScanTarget.RetryIndexKey, attemptIndex },
|
||||
};
|
||||
|
||||
var trigger = TriggerBuilder.Create()
|
||||
.WithIdentity($"{Constants.MalwareBlockerWebhookJobKey}-{instanceId}-{downloadId}-{attemptIndex}-{DateTimeOffset.UtcNow.Ticks}")
|
||||
.ForJob(jobKey)
|
||||
.UsingJobData(jobData)
|
||||
.StartAt(DateTimeOffset.UtcNow.Add(delay))
|
||||
.Build();
|
||||
|
||||
await scheduler.ScheduleJob(trigger);
|
||||
|
||||
_logger.LogInformation(
|
||||
"MalwareBlocker webhook scan attempt {attempt} scheduled (in {delay}s) for download {downloadId} on {type} instance {instanceId}",
|
||||
attemptIndex, (int)delay.TotalSeconds, downloadId, type, instanceId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error scheduling MalwareBlocker webhook scan for instance {instanceId}", instanceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateJobSchedule(JobType jobType, JobSchedule schedule)
|
||||
{
|
||||
if (schedule == null)
|
||||
|
||||
+2267
File diff suppressed because it is too large.
Load diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMalwareBlockerTriggerMode : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "trigger_mode",
|
||||
table: "content_blocker_configs",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "Schedule");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "trigger_mode",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,6 +816,11 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("process_no_content_id");
|
||||
|
||||
b.Property<string>("TriggerMode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("trigger_mode");
|
||||
|
||||
b.Property<bool>("UseAdvancedScheduling")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("use_advanced_scheduling");
|
||||
@@ -1079,7 +1084,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
@@ -1130,7 +1136,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("type");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
b.Property<string>("UpdatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
@@ -1577,7 +1584,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastProcessedAt")
|
||||
b.Property<string>("LastProcessedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_processed_at");
|
||||
|
||||
@@ -1688,11 +1695,12 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<DateTimeOffset>("LastSyncedAt")
|
||||
b.Property<string>("LastSyncedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_synced_at");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastUpgradedAt")
|
||||
b.Property<string>("LastUpgradedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_upgraded_at");
|
||||
|
||||
@@ -1747,7 +1755,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<DateTimeOffset>("RecordedAt")
|
||||
b.Property<string>("RecordedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("recorded_at");
|
||||
|
||||
@@ -1783,7 +1792,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("arr_instance_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
@@ -1828,7 +1838,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("command_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
@@ -1896,7 +1907,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("item_type");
|
||||
|
||||
b.Property<DateTimeOffset>("LastSearchedAt")
|
||||
b.Property<string>("LastSearchedAt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_searched_at");
|
||||
|
||||
|
||||
+5
-2
@@ -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;
|
||||
}
|
||||
|
||||
+24
-18
@@ -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., "0 0/5 * ? * * *" 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 "On Grab" 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., "0 0/5 * ? * * *" 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>
|
||||
|
||||
+16
-1
@@ -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>
|
||||
Reference in new issue
Block a user