diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs index fdd6ffef..09d07e4a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs @@ -71,6 +71,7 @@ public class MalwareBlockerIntegrationTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, DeleteReason = DeleteReason.AllFilesBlocked, IsPrivate = false @@ -201,6 +202,7 @@ public class MalwareBlockerIntegrationTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, DeleteReason = DeleteReason.AllFilesBlocked, IsPrivate = true diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs index 2f64d61e..e1271e26 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs @@ -278,7 +278,7 @@ public class MalwareBlockerTests : IDisposable Arg.Any(), Arg.Any>() ) - .Returns(new BlockFilesResult { Found = true, ShouldRemove = false }); + .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) @@ -341,6 +341,7 @@ public class MalwareBlockerTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AllFilesBlocked @@ -399,7 +400,7 @@ public class MalwareBlockerTests : IDisposable var mockDownloadService = _fixture.CreateMockDownloadService(); mockDownloadService .BlockUnwantedFilesAsync(Arg.Any(), Arg.Any>()) - .Returns(new BlockFilesResult { Found = true, ShouldRemove = false }); + .Returns(new BlockFilesResult { Found = true, MetadataFound = true, ShouldRemove = false }); _fixture.DownloadServiceFactory .GetDownloadService(Arg.Any()) @@ -477,6 +478,56 @@ public class MalwareBlockerTests : IDisposable 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(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var record = new QueueRecord { Id = 1, DownloadId = "metadl-hash", Title = "MetaDL", Protocol = "torrent", SeriesId = 7, EpisodeId = 1 }; + + _fixture.ArrQueueIterator + .Iterate(Arg.Any(), Arg.Any(), Arg.Any, Task>>(), Arg.Any()) + .Returns(ci => + { + var callback = ci.ArgAt, 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(), Arg.Any>()) + .Returns(new BlockFilesResult { Found = true }); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .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(t => t.DownloadId == "metadl-hash" && t.RetryIndex == 0)); + } + [Fact] public async Task ExecuteInternalAsync_WhenWebhookTargetIsUsenet_DoesNotScanOrRetry() { @@ -570,6 +621,7 @@ public class MalwareBlockerTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AtLeastOneFileBlocked @@ -645,6 +697,7 @@ public class MalwareBlockerTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, IsPrivate = true, DeleteReason = DeleteReason.AllFilesBlocked @@ -826,6 +879,7 @@ public class MalwareBlockerTests : IDisposable .Returns(new BlockFilesResult { Found = true, + MetadataFound = true, ShouldRemove = true, IsPrivate = false, DeleteReason = DeleteReason.AllFilesBlocked diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs index 5b4e871e..3fd31dca 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/BlockFilesResult.cs @@ -15,6 +15,11 @@ public sealed record BlockFilesResult public bool IsPrivate { get; set; } public bool Found { get; set; } - + + /// + /// True when the torrent's file list (metadata) was available so the scan could complete (or was not needed). + /// + public bool MetadataFound { get; set; } + public DeleteReason DeleteReason { get; set; } = DeleteReason.None; } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index 5693ae60..cb2d5c2a 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -55,11 +55,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 priorities = []; bool hasPriorityUpdates = false; long totalFiles = 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index 76dbd406..a11f81b1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -66,6 +66,8 @@ public partial class QBitService return result; } + result.MetadataFound = true; + List unwantedFiles = []; long totalFiles = 0; long totalUnwantedFiles = 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 9f184eaf..6a5a0d79 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -64,6 +64,8 @@ public partial class RTorrentService return result; } + result.MetadataFound = true; + bool hasPriorityUpdates = false; long totalFiles = 0; long totalUnwantedFiles = 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index c3dcbbad..6a907f51 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -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(); @@ -48,7 +51,7 @@ public partial class TransmissionService _logger.LogDebug("skip files check | download is private | {name}", download.Name); return result; } - + List unwantedFiles = []; long totalFiles = 0; long totalUnwantedFiles = 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index daf86822..46e40866 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -55,6 +55,8 @@ public partial class UTorrentService return result; } + result.MetadataFound = true; + List fileIndexes = new(files.Count); long totalUnwantedFiles = 0; diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs index d5672336..ea7fe43c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/MalwareBlocker.cs @@ -297,9 +297,9 @@ public sealed class MalwareBlocker : GenericHandler _logger.LogDebug("No torrent clients enabled"); } - if (!result.Found) + if (!result.Found || !result.MetadataFound) { - // Torrent not present in any client yet (e.g. metadata not ready) — webhook scan should retry. + // Retry while the torrent is not yet in a client, or is present but its file list/metadata isn't ready. return false; }