diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs index a61028c0..e933f1c7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceDCTests.cs @@ -749,6 +749,82 @@ public class DelugeServiceDCTests : IClassFixture // Assert await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); + await _fixture.EventPublisher.DidNotReceive().PublishCategoryChanged(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NullContents_TreatedAsNoFiles_NoHardlinks_ChangesLabel() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked" + }; + + var downloads = new List + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .GetTorrentFiles("hash1") + .Returns(new DelugeContents { Contents = null }); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetTorrentLabel("hash1", "unlinked"); + _fixture.HardLinkFileService + .DidNotReceive() + .GetHardLinkCount(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UseTagTrue_StillChangesLabel_AndPublishesEventWithIsTagFalse() + { + // Arrange + var sut = _fixture.CreateSut(); + + var unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked", + UseTag = true + }; + + var downloads = new List + { + new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List(), DownloadLocation = "/downloads" }) + }; + + _fixture.ClientWrapper + .GetTorrentFiles("hash1") + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } } + } + }); + + _fixture.HardLinkFileService + .GetHardLinkCount(Arg.Any(), Arg.Any()) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetTorrentLabel("hash1", "unlinked"); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "unlinked", false); + downloads[0].Category.ShouldBe("unlinked"); } [Fact] diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs index 70959233..bd85d543 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs @@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Shouldly; using Xunit; @@ -590,6 +591,77 @@ public class DelugeServiceTests : IClassFixture } } + public class ChangeTorrentCategoryAsync_Tests : DelugeServiceTests + { + public ChangeTorrentCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task UseTagFalse_ChangesLabel_PublishesCategoryEvent_UpdatesTorrentCategory() + { + var sut = _fixture.CreateSut(); + var torrent = new DelugeItemWrapper(new DownloadStatus + { + Hash = "hash1", + Name = "Test", + Label = "movies", + Trackers = new List(), + DownloadLocation = "/downloads" + }); + + await sut.ChangeTorrentCategoryAsync(torrent, "target", false); + + await _fixture.ClientWrapper.Received(1).SetTorrentLabel("hash1", "target"); + await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false); + torrent.Category.ShouldBe("target"); + } + + [Fact] + public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseDelugeHasNoTags() + { + var sut = _fixture.CreateSut(); + var torrent = new DelugeItemWrapper(new DownloadStatus + { + Hash = "hash1", + Name = "Test", + Label = "movies", + Trackers = new List(), + DownloadLocation = "/downloads" + }); + + await sut.ChangeTorrentCategoryAsync(torrent, "target", true); + + await _fixture.ClientWrapper.Received(1).SetTorrentLabel("hash1", "target"); + await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false); + torrent.Category.ShouldBe("target"); + } + + [Fact] + public async Task DryRun_SkipsLabelChange_ButStillPublishesEventAndUpdatesCategory() + { + var sut = _fixture.CreateSut(); + var torrent = new DelugeItemWrapper(new DownloadStatus + { + Hash = "hash1", + Name = "Test", + Label = "movies", + Trackers = new List(), + DownloadLocation = "/downloads" + }); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + await sut.ChangeTorrentCategoryAsync(torrent, "target", false); + + await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any(), Arg.Any()); + await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false); + torrent.Category.ShouldBe("target"); + } + } + public class BlockUnwantedFilesAsyncScenarios : DelugeServiceTests { public BlockUnwantedFilesAsyncScenarios(DelugeServiceFixture fixture) : base(fixture) @@ -624,7 +696,7 @@ public class DelugeServiceTests : IClassFixture }; [Fact] - public async Task AllFilesAreMalware_DoesNotCallChangeFilesPriority_AndMarksForRemoval() + public async Task AllFilesAreMalware_CallsChangeFilesPriority_AndMarksForRemoval() { const string hash = "all-malware-hash"; var sut = _fixture.CreateSut(); @@ -655,8 +727,48 @@ public class DelugeServiceTests : IClassFixture result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); await _fixture.ClientWrapper - .DidNotReceive() - .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + .Received(1) + .ChangeFilesPriority(hash, Arg.Is>(p => p.Count == 1 && p[0] == 0)); + } + + [Fact] + public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() + { + const string hash = "all-malware-priority-throws-hash"; + DelugeService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "malware.exe" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.ClientWrapper + .ChangeFilesPriority(hash, Arg.Is>(p => p.Count == 1 && p[0] == 0)) + .ThrowsAsync(new HttpRequestException("rejected")); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .ChangeFilesPriority(hash, Arg.Is>(p => p.Count == 1 && p[0] == 0)); } [Fact] @@ -738,5 +850,260 @@ public class DelugeServiceTests : IClassFixture .DidNotReceive() .ChangeFilesPriority(Arg.Any(), Arg.Any>()); } + + [Fact] + public async Task NoUnwantedFiles_DoesNotCallChangeFilesPriority_AndDoesNotRemove() + { + const string hash = "no-unwanted-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task AlreadySkippedFilePlusMalwareFile_MarksAllFilesBlocked() + { + const string hash = "skipped-plus-malware-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "skipped.txt", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "skipped.txt" } }, + { "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "malware.exe" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + } + + [Fact] + public async Task DeleteIfAnyFileBlocked_AlreadySkippedFileAlone_DoesNotTriggerAtLeastOneFileBlocked() + { + const string hash = "skipped-alone-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "skipped.txt", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "skipped.txt" } }, + }, + }); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task PartialMalware_NestedDirectory_ChangeFilesPriorityReceivesFullVectorWithOriginalPrioritiesPreserved() + { + const string hash = "nested-partial-malware-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } }, + { + "Subs", new DelugeFileOrDirectory + { + Type = "dir", + Contents = new Dictionary + { + { "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 5, Index = 1, Path = "Subs/malware.exe" } }, + } + } + }, + { "sample.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 7, Index = 2, Path = "sample.mkv" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name == "malware.exe"), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name != "malware.exe"), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper + .Received(1) + .ChangeFilesPriority(hash, Arg.Is>(p => p.Count == 3 && p[0] == 1 && p[1] == 0 && p[2] == 7)); + } + + [Fact] + public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck_EvaluatorNeverCalled() + { + const string hash = "private-ignore-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true }); + + DownloadStatus downloadStatus = MakeDownloadStatus(hash) with { Private = true }; + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(downloadStatus); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.IsPrivate.ShouldBeTrue(); + + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); + + await _fixture.ClientWrapper + .DidNotReceive() + .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task DryRun_StillSetsAllFilesBlockedVerdict_ButSkipsChangeFilesPriority() + { + const string hash = "dry-run-all-malware-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "malware.exe" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + } + + [Fact] + public async Task NestedFile_ValidatesBareFileName_NotRelativePath() + { + const string hash = "nested-bare-name-hash"; + var sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { + "Season 01", new DelugeFileOrDirectory + { + Type = "dir", + Contents = new Dictionary + { + { "episode.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "Season 01/episode.mkv" } }, + } + } + }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + _fixture.FilenameEvaluator + .Received(1) + .IsValid("episode.mkv", Arg.Any(), Arg.Any>(), Arg.Any>()); + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid("Season 01/episode.mkv", Arg.Any(), Arg.Any>(), Arg.Any>()); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs index ac76fd6a..ea1c6dc4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceDCTests.cs @@ -1229,6 +1229,29 @@ public class QBitServiceDCTests : IClassFixture await _fixture.EventPublisher.Received(1) .PublishCategoryChanged("movies", "cleanuparr-dead", true); } + + [Fact] + public async Task DryRun_SkipsClientCall_ButPublishesEvent() + { + var sut = _fixture.CreateSut(); + var torrent = Substitute.For(); + torrent.Hash.Returns("hash1"); + torrent.Name.Returns("Test"); + torrent.Category.Returns("movies"); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false); + + await _fixture.ClientWrapper.DidNotReceive() + .SetTorrentCategoryAsync(Arg.Any>(), Arg.Any()); + await _fixture.ClientWrapper.DidNotReceive() + .AddTorrentTagAsync(Arg.Any>(), Arg.Any()); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "cleanuparr-dead", false); + } } public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs index 603e2fcc..828158c0 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Newtonsoft.Json.Linq; using QBittorrent.Client; using Shouldly; @@ -1371,6 +1372,41 @@ public class QBitServiceTests : IClassFixture result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); + } + + [Fact] + public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() + { + const string hash = "all-malware-priority-throws-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.ClientWrapper + .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip) + .ThrowsAsync(new HttpRequestException("rejected")); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); } [Fact] @@ -1574,5 +1610,92 @@ public class QBitServiceTests : IClassFixture result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); } + + [Fact] + public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck() + { + const string hash = "private-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true }); + + StubClient(hash, + [ + new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, + ], isPrivate: true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.IsPrivate.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); + } + + [Fact] + public async Task FileWithNullIndex_ExcludedFromTotals_MalwareFileStillMarksAllFilesBlocked() + { + const string hash = "null-index-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "unindexed.mkv", Index = null, Priority = TorrentContentPriority.Normal }, + new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip); + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(hash, Arg.Is(i => i != 0), Arg.Any()); + } + + [Fact] + public async Task DryRun_AllFilesMalware_VerdictSet_ButNoSetFilePriorityCall() + { + const string hash = "dry-run-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs index cae567a2..affc185d 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs @@ -528,6 +528,71 @@ public class RTorrentServiceDCTests : IClassFixture } } + public class ChangeTorrentCategoryAsync_Tests : RTorrentServiceDCTests + { + public ChangeTorrentCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task UseTagFalse_ChangesLabel_PublishesEvent_UpdatesCategory() + { + // Arrange + RTorrentService sut = _fixture.CreateSut(); + RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetLabelAsync("HASH1", "target"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + + [Fact] + public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseRTorrentDoesNotSupportTags() + { + // Arrange + RTorrentService sut = _fixture.CreateSut(); + RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: true); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetLabelAsync("HASH1", "target"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + + [Fact] + public async Task DryRun_SkipsLabelCall_ButPublishesEventAndUpdatesCategory() + { + // Arrange + RTorrentService sut = _fixture.CreateSut(); + RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false); + + // Assert + await _fixture.ClientWrapper.DidNotReceive() + .SetLabelAsync(Arg.Any(), Arg.Any()); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + } + public class ChangeCategoryForNoHardLinksAsync_Tests : RTorrentServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(RTorrentServiceFixture fixture) : base(fixture) @@ -859,6 +924,44 @@ public class RTorrentServiceDCTests : IClassFixture .PublishCategoryChanged("movies", "unlinked", false); } + [Fact] + public async Task UseTagTrue_ChangesLabel_PublishesEventWithIsTagFalse_UpdatesCategory() + { + // Arrange + RTorrentService sut = _fixture.CreateSut(); + + UnlinkedConfig unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked", + UseTag = true + }; + + RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" }, null, TimeProvider.System); + List downloads = new List { wrapper }; + + _fixture.ClientWrapper + .GetTorrentFilesAsync("HASH1") + .Returns(new List + { + new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 } + }); + + _fixture.HardLinkFileService + .GetHardLinkCount(Arg.Any(), Arg.Any()) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert - rTorrent does not support tags, so useTag falls back to a category change + await _fixture.ClientWrapper.Received(1) + .SetLabelAsync("HASH1", "unlinked"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "unlinked", false); + wrapper.Category.ShouldBe("unlinked"); + } + [Fact] public async Task UsesDirectoryOverBasePathForFilePath() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs index 2db31eb7..4ea9db6a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs @@ -833,6 +833,38 @@ public class RTorrentServiceTests : IClassFixture result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 0, 0); + } + + [Fact] + public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() + { + const string hash = "ALL-MALWARE-PRIORITY-THROWS-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.ClientWrapper + .SetFilePriorityAsync(hash, 0, 0) + .ThrowsAsync(new HttpRequestException("rejected")); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 0, 0); } [Fact] @@ -944,5 +976,107 @@ public class RTorrentServiceTests : IClassFixture result.ShouldRemove.ShouldBeFalse(); result.DeleteReason.ShouldBe(DeleteReason.None); } + + [Fact] + public async Task PrivateTorrent_WithIgnorePrivate_SkipsFileCheck_NeverEvaluatesFilenames() + { + const string hash = "IGNORE-PRIVATE-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true }); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "installer.exe", Priority = 1 }], isPrivate: true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.IsPrivate.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DryRun_AllFilesMalware_StillMarksForRemoval_SkipsPriorityCall() + { + const string hash = "DRY-RUN-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PartialMalware_MultipleUnwantedFiles_CallsSetFilePriorityForExactIndicesOnly() + { + const string hash = "MULTI-PARTIAL-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }, + new RTorrentFile { Index = 1, Path = "malware1.exe", Priority = 1 }, + new RTorrentFile { Index = 2, Path = "malware2.exe", Priority = 1 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.StartsWith("malware")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper.Received(1).SetFilePriorityAsync(hash, 1, 0); + await _fixture.ClientWrapper.Received(1).SetFilePriorityAsync(hash, 2, 0); + await _fixture.ClientWrapper.DidNotReceive().SetFilePriorityAsync(hash, 0, 0); + } + + [Fact] + public async Task NestedFilePath_ValidatesBareFilename() + { + const string hash = "NESTED-PATH-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "Season 01/Episode.01.mkv", Priority = 1 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + _fixture.FilenameEvaluator + .Received(1) + .IsValid("Episode.01.mkv", Arg.Any(), Arg.Any>(), Arg.Any>()); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs index db6b1675..5c59d94a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceDCTests.cs @@ -580,6 +580,106 @@ public class TransmissionServiceDCTests : IClassFixture(ids => ids.Contains(123)), expectedNewLocation, true); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "cleanuparr-dead", false); + torrent.Category.ShouldBe("cleanuparr-dead"); + } + + [Fact] + public async Task TagMode_SetsLabels_PublishesEvent_AndKeepsCategoryUnchanged() + { + // Arrange + var sut = _fixture.CreateSut(); + + var baseDownloadDir = Path.Combine("downloads", "movies"); + + var torrent = new TransmissionItemWrapper(new TorrentInfo + { + Id = 123, + HashString = "hash1", + Name = "Test", + DownloadDir = baseDownloadDir, + Labels = ["existing", "CLEANUPARR-DEAD"] + }); + + // Act + await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true); + + // Assert + await _fixture.ClientWrapper.Received(1) + .TorrentSetAsync(Arg.Is(s => + s.Ids.Contains(123L) + && s.Labels.Contains("existing") + && s.Labels.Contains("CLEANUPARR-DEAD") + && s.Labels.Length == 2)); + await _fixture.ClientWrapper.DidNotReceive() + .TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "cleanuparr-dead", true); + torrent.Category.ShouldBe("movies"); + } + + [Fact] + public async Task DryRun_SkipsClientCall_ButPublishesEvent() + { + // Arrange + var sut = _fixture.CreateSut(); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + var torrent = new TransmissionItemWrapper(new TorrentInfo + { + Id = 123, + HashString = "hash1", + Name = "Test", + DownloadDir = Path.Combine("downloads", "movies") + }); + + // Act + await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false); + + // Assert + await _fixture.ClientWrapper.DidNotReceive() + .TorrentSetLocationAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _fixture.ClientWrapper.DidNotReceive() + .TorrentSetAsync(Arg.Any()); + await _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "cleanuparr-dead", false); + } + } + public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture) diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs index eea19796..8ccc540f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs @@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using NSubstitute; +using NSubstitute.ExceptionExtensions; using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; using Shouldly; @@ -592,6 +593,38 @@ public class TransmissionServiceTests : IClassFixture()); + } + + [Fact] + public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() + { + const string hash = "all-malware-priority-throws-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("malware.exe", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.ClientWrapper + .TorrentSetAsync(Arg.Any()) + .ThrowsAsync(new HttpRequestException("rejected")); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .TorrentSetAsync(Arg.Any()); } [Fact] @@ -691,5 +724,85 @@ public class TransmissionServiceTests : IClassFixture()); + + result.Found.ShouldBeTrue(); + result.IsPrivate.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper + .DidNotReceive() + .TorrentSetAsync(Arg.Any()); + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); + } + + [Fact] + public async Task PartialMalware_TorrentSetContainsExactUnwantedIndices() + { + const string hash = "exact-indices-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("movie.mkv", true), ("installer.exe", true), ("readme.txt", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("readme.txt")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + await _fixture.ClientWrapper + .Received(1) + .TorrentSetAsync(Arg.Is(s => + s.FilesUnwanted != null + && s.FilesUnwanted.Length == 2 + && s.FilesUnwanted.Contains(1L) + && s.FilesUnwanted.Contains(2L))); + } + + [Fact] + public async Task DryRun_AllFilesMalware_VerdictSet_ButNoTorrentSetCall() + { + const string hash = "dry-run-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("malware.exe", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .TorrentSetAsync(Arg.Any()); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs index 94f03412..024fcaf1 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceDCTests.cs @@ -515,6 +515,77 @@ public class UTorrentServiceDCTests : IClassFixture } } + public class ChangeTorrentCategoryAsync_Tests : UTorrentServiceDCTests + { + public ChangeTorrentCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture) + { + } + + [Fact] + public async Task UseTagFalse_ChangesLabel_PublishesEvent_UpdatesCategory() + { + // Arrange + UTorrentService sut = _fixture.CreateSut(); + UTorrentItemWrapper wrapper = new UTorrentItemWrapper( + new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" }, + new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetTorrentLabelAsync("hash1", "target"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + + [Fact] + public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseUTorrentDoesNotSupportTags() + { + // Arrange + UTorrentService sut = _fixture.CreateSut(); + UTorrentItemWrapper wrapper = new UTorrentItemWrapper( + new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" }, + new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: true); + + // Assert + await _fixture.ClientWrapper.Received(1) + .SetTorrentLabelAsync("hash1", "target"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + + [Fact] + public async Task DryRun_SkipsLabelCall_ButPublishesEventAndUpdatesCategory() + { + // Arrange + UTorrentService sut = _fixture.CreateSut(); + UTorrentItemWrapper wrapper = new UTorrentItemWrapper( + new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" }, + new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act + await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false); + + // Assert + await _fixture.ClientWrapper.DidNotReceive() + .SetTorrentLabelAsync(Arg.Any(), Arg.Any()); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "target", false); + wrapper.Category.ShouldBe("target"); + } + } + public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests { public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture) @@ -826,6 +897,46 @@ public class UTorrentServiceDCTests : IClassFixture .SetTorrentLabelAsync("hash1", "unlinked"); } + [Fact] + public async Task UseTagTrue_ChangesLabel_PublishesEventWithIsTagFalse_UpdatesCategory() + { + // Arrange + UTorrentService sut = _fixture.CreateSut(); + + UnlinkedConfig unlinkedConfig = new UnlinkedConfig + { + Id = Guid.NewGuid(), + TargetCategory = "unlinked", + UseTag = true + }; + + UTorrentItemWrapper wrapper = new UTorrentItemWrapper( + new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" }, + new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System); + List downloads = new List { wrapper }; + + _fixture.ClientWrapper + .GetTorrentFilesAsync("hash1") + .Returns(new List + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.HardLinkFileService + .GetHardLinkCount(Arg.Any(), Arg.Any()) + .Returns(0); + + // Act + await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig); + + // Assert - uTorrent does not support tags, so useTag falls back to a category change + await _fixture.ClientWrapper.Received(1) + .SetTorrentLabelAsync("hash1", "unlinked"); + _fixture.EventPublisher.Received(1) + .PublishCategoryChanged("movies", "unlinked", false); + wrapper.Category.ShouldBe("unlinked"); + } + [Fact] public async Task NullFilesResponse_ChangesLabel() { diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs index 13726ffe..f5e12072 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs @@ -799,6 +799,38 @@ public class UTorrentServiceTests : IClassFixture result.Found.ShouldBeTrue(); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilesPriorityAsync(hash, Arg.Is>(idx => idx.Count == 1 && idx[0] == 0), 0); + } + + [Fact] + public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval() + { + const string hash = "all-malware-priority-throws-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.ClientWrapper + .SetFilesPriorityAsync(hash, Arg.Is>(idx => idx.Count == 1 && idx[0] == 0), 0) + .ThrowsAsync(new HttpRequestException("rejected")); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .Received(1) + .SetFilesPriorityAsync(hash, Arg.Is>(idx => idx.Count == 1 && idx[0] == 0), 0); } [Fact] @@ -1009,5 +1041,89 @@ public class UTorrentServiceTests : IClassFixture await _fixture.ClientWrapper.DidNotReceive() .SetFilesPriorityAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } + + [Fact] + public async Task PrivateTorrent_WithIgnorePrivate_NeverEvaluatesFilenames() + { + const string hash = "ignore-private-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true }); + + StubClient(hash, + [ + new UTorrentFile { Name = "installer.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }, + ], isPrivate: true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.IsPrivate.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + + _fixture.FilenameEvaluator + .DidNotReceive() + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilesPriorityAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task DryRun_AllFilesMalware_StillMarksForRemoval_SkipsPriorityCall() + { + const string hash = "dry-run-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + _fixture.DryRunInterceptor + .InterceptAsync(Arg.Any>(), Arg.Any()) + .Returns(Task.CompletedTask); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilesPriorityAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task PartialMalware_MultipleUnwantedFiles_CallsSetFilesPriorityWithExactIndicesOnly() + { + const string hash = "multi-partial-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }, + new UTorrentFile { Name = "malware1.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 }, + new UTorrentFile { Name = "malware2.exe", Index = 2, Priority = 2, Size = 1024, Downloaded = 1024 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.StartsWith("malware")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeFalse(); + + await _fixture.ClientWrapper + .Received(1) + .SetFilesPriorityAsync(hash, Arg.Is>(idx => idx.Count == 2 && idx.Contains(1) && idx.Contains(2) && !idx.Contains(0)), 0); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs index 4b380ca7..acd3408c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs @@ -545,8 +545,8 @@ public class DownloadCleanerIntegrationTests : IDisposable public override List? FilterDownloadsToChangeCategoryAsync( List? downloads, UnlinkedConfig unlinkedConfig) => []; - public override Task ChangeCategoryForNoHardLinksAsync( - List? downloads, UnlinkedConfig unlinkedConfig) => Task.CompletedTask; + protected override Task?> GetHardLinkScanItemsAsync( + ITorrentItemWrapper torrent) => throw new NotSupportedException(); public override Task> GetAllTorrentsLite() => Task.FromResult(_seedingDownloads); @@ -564,7 +564,7 @@ public class DownloadCleanerIntegrationTests : IDisposable public override Task ShouldRemoveFromArrQueueAsync( string hash, IReadOnlyList ignoredDownloads) => throw new NotSupportedException(); - public override Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => + protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => throw new NotSupportedException(); public override Task CreateCategoryAsync(string name) => throw new NotSupportedException(); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index aed1ef95..23c7c6f0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Cleanuparr.Domain.Entities.Deluge.Response; +using Cleanuparr.Domain.Entities.Deluge.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; using Cleanuparr.Infrastructure.Features.Context; @@ -19,9 +17,9 @@ public partial class DelugeService DownloadStatus? download = await _client.GetTorrentStatus(hash); BlockFilesResult result = new(); - if (download?.Hash is null) + if (download?.Hash is null || download?.Name is null) { - _logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); return result; } @@ -32,7 +30,7 @@ public partial class DelugeService if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) { - _logger.LogInformation("skip | download is ignored | {name}", download.Name); + _logger.LogInformation("skip | download is ignored | {Name}", download.Name); return result; } @@ -41,7 +39,7 @@ public partial class DelugeService if (malwareBlockerConfig.IgnorePrivate && download.Private) { // ignore private trackers - _logger.LogDebug("skip files check | download is private | {name}", download.Name); + _logger.LogDebug("skip files check | download is private | {Name}", download.Name); return result; } @@ -53,94 +51,37 @@ public partial class DelugeService } catch (Exception exception) { - _logger.LogWarning(exception, "failed to find files in the download client | {name}", download.Name); + _logger.LogWarning(exception, "failed to find files in the download client | {Name}", download.Name); } if (contents is null) { return result; } - - Dictionary priorities = []; - bool hasPriorityUpdates = false; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); + + // Deluge's priority API takes the full per-file vector, so keep every original priority + Dictionary originalPriorities = []; + List<(int Index, string ValidationName, string LogName, FileBlockAction Action)> scanItems = []; ProcessFiles(contents.Contents, (name, file) => { - totalFiles++; - int priority = file.Priority; - - if (result.ShouldRemove) - { - return; - } - - if (file.Priority is 0) - { - _logger.LogTrace("File is already skipped | {file}", file.Path); - totalUnwantedFiles++; - } - - if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes)) - { - totalUnwantedFiles++; - priority = 0; - hasPriorityUpdates = true; - _logger.LogInformation("unwanted file found | {file}", file.Path); - - if (malwareBlockerConfig.DeleteIfAnyFileBlocked) - { - _logger.LogDebug("at least one file is blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; - return; - } - } - - _logger.LogTrace("File is valid | {file}", file.Path); - priorities.Add(file.Index, priority); + originalPriorities[file.Index] = file.Priority; + scanItems.Add((file.Index, name, file.Path, file.Priority is 0 + ? FileBlockAction.AlreadySkipped + : FileBlockAction.CheckBlocklist)); }); - if (result.ShouldRemove) + await ApplyFileBlockingAsync(result, download.Name, scanItems, malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => { - return result; - } + HashSet unwantedLookup = [..unwantedIndices]; + List sortedPriorities = originalPriorities + .OrderBy(x => x.Key) + .Select(x => unwantedLookup.Contains(x.Key) ? 0 : x.Value) + .ToList(); - if (!hasPriorityUpdates) - { - return result; - } - - _logger.LogDebug("changing priorities | torrent {hash}", hash); - - List sortedPriorities = priorities - .OrderBy(x => x.Key) - .Select(x => x.Value) - .ToList(); - - if (totalUnwantedFiles == totalFiles) - { - _logger.LogDebug("All files are blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - return result; - } - - _logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name); - - await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, sortedPriorities)); + await _client.ChangeFilesPriority(hash, sortedPriorities); + }); return result; } - - protected virtual async Task ChangeFilesPriority(string hash, List sortedPriorities) - { - await _client.ChangeFilesPriority(hash, sortedPriorities); - } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs index b0cf7d11..5201e7c9 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceDC.cs @@ -3,8 +3,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Domain.Exceptions; using Cleanuparr.Infrastructure.Extensions; -using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; @@ -67,17 +65,6 @@ public partial class DelugeService return relativePaths; }); - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => - downloads - ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) - .ToList(); - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => - downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .ToList(); - /// public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) { @@ -107,103 +94,44 @@ public partial class DelugeService await _dryRunInterceptor.InterceptAsync(() => CreateLabel(name)); } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + /// + protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { - if (downloads?.Count is null or 0) + DelugeItemWrapper delugeTorrent = (DelugeItemWrapper)torrent; + DelugeContents? contents; + + try { - return; + contents = await _client.GetTorrentFiles(delugeTorrent.Hash); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "failed to find torrent files for {Name}", delugeTorrent.Name); + return null; } - foreach (DelugeItemWrapper torrent in downloads.Cast()) + List<(string FilePath, HardLinkScanAction Action)> scanItems = []; + + ProcessFiles(contents?.Contents, (_, file) => { - if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) - { - continue; - } + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(delugeTorrent.Info.DownloadLocation, file.Path), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); + HardLinkScanAction action = file.Priority <= 0 + ? HardLinkScanAction.SkipUnwanted + : HardLinkScanAction.CheckHardLinks; - DelugeContents? contents; - try - { - contents = await _client.GetTorrentFiles(torrent.Hash); - } - catch (Exception exception) - { - _logger.LogWarning(exception, "failed to find torrent files for {name}", torrent.Name); - continue; - } + scanItems.Add((filePath, action)); + }); - bool hasHardlinks = false; - bool hasErrors = false; - - ProcessFiles(contents?.Contents, (_, file) => - { - string filePath = PathHelper.NormalizeAndRemap( - Path.Combine(torrent.Info.DownloadLocation, file.Path), - _downloadClientConfig.DownloadDirectorySource, - _downloadClientConfig.DownloadDirectoryTarget); - - if (file.Priority <= 0) - { - _logger.LogDebug("skip | file is not downloaded | {file}", filePath); - return; - } - - long hardlinkCount = _hardLinkFileService - .GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath); - hasErrors = true; - return; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - } - }); - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); - - _logger.LogInformation("category changed for {name}", torrent.Name); - - await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory); - - torrent.Category = unlinkedConfig.TargetCategory; - } + return scanItems; } /// - public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) - { - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - string currentCategory = torrent.Category ?? string.Empty; - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory); - - torrent.Category = targetCategory; - } + protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => + ChangeLabel(torrent.Hash, targetCategory); protected async Task CreateLabel(string name) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs index 719e49e1..b3432859 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs @@ -2,9 +2,6 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.Deluge.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; -using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Infrastructure.Services.Interfaces; -using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge; @@ -76,44 +73,4 @@ public partial class DelugeService return result; } - - private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) - { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(wrapper); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) - { - _logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); - } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs index f16bf4d8..34e69467 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadService.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.HealthCheck; using Cleanuparr.Domain.Enums; @@ -72,6 +74,55 @@ public abstract class DownloadService : IDownloadService return Task.FromResult(false); } + /// + /// Runs the slow check, then the stall check; the first removal wins. + /// + protected async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + { + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); + + if (result.ShouldRemove) + { + return result; + } + + return await CheckIfStuck(wrapper); + } + + /// + /// Alt-speed-limit probe for the slow rules. + /// Only qBittorrent and Transmission report that state. + /// + protected virtual Func>? GetAltSpeedLimitProbe() => null; + + protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) + { + if (!wrapper.IsDownloading()) + { + _logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + if (wrapper.DownloadSpeed <= 0) + { + _logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, GetAltSpeedLimitProbe()); + } + + protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) + { + if (!wrapper.IsStalled()) + { + _logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name); + return (false, DeleteReason.None, false, false); + } + + return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); + } + public abstract void Dispose(); public abstract Task LoginAsync(); @@ -176,10 +227,17 @@ public abstract class DownloadService : IDownloadService .TrimEnd(Path.DirectorySeparatorChar); /// - public abstract List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules); + public virtual List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => + downloads + ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) + .ToList(); /// - public abstract List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig); + public virtual List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => + downloads + ?.Where(x => !string.IsNullOrEmpty(x.Hash)) + .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) + .ToList(); /// public virtual async Task CleanDownloadsAsync(List? downloads, List seedingRules) @@ -272,10 +330,230 @@ public abstract class DownloadService : IDownloadService } /// - public abstract Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig); + public async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + { + if (downloads?.Count is null or 0) + { + return; + } + + foreach (ITorrentItemWrapper torrent in downloads) + { + if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) + { + continue; + } + + ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); + SetDownloadClientContext(); + + IEnumerable<(string FilePath, HardLinkScanAction Action)>? files = await GetHardLinkScanItemsAsync(torrent); + + if (files is null) + { + continue; + } + + (bool hasHardlinks, bool hasErrors) = ScanForHardLinks(files, unlinkedConfig.IgnoredRootDirs.Count > 0); + + if (hasErrors) + { + continue; + } + + if (hasHardlinks) + { + _logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name); + continue; + } + + await ChangeTorrentCategoryAsync(torrent, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); + + _logger.LogInformation(unlinkedConfig.UseTag && SupportsTags ? "tag added for {Name}" : "category changed for {Name}", torrent.Name); + } + } + + /// + /// Maps a torrent's files to (path, ) pairs. + /// Returns null to skip the torrent, such as when its files can't be read. + /// + protected abstract Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent); + + /// + /// Stops at the first hardlink or unreadable file. + /// Each client maps its files to (path, ) pairs. + /// + protected (bool HasHardlinks, bool HasErrors) ScanForHardLinks( + IEnumerable<(string FilePath, HardLinkScanAction Action)> files, + bool ignoreRootDirs) + { + foreach ((string filePath, HardLinkScanAction action) in files) + { + if (action is HardLinkScanAction.TreatAsLinked) + { + return (true, false); + } + + if (action is HardLinkScanAction.SkipUnwanted) + { + _logger.LogDebug("skip | file is not downloaded | {File}", filePath); + continue; + } + + long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, ignoreRootDirs); + + if (hardlinkCount < 0) + { + _logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath); + return (false, true); + } + + if (hardlinkCount > 0) + { + return (true, false); + } + } + + return (false, false); + } + + /// + /// Counts unwanted files and collects the indices to block. + /// Don't yield a file the client excludes from the count, such as one with no index. + /// Deluge and rTorrent validate the bare filename but log the relative path. + /// With on, the first unwanted file sets DeleteImmediately and ends the scan. + /// + protected (List UnwantedIndices, long TotalFiles, long TotalUnwantedFiles, bool DeleteImmediately) ScanFilesForBlocking( + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files, + BlocklistType blocklistType, + ConcurrentBag patterns, + ConcurrentBag regexes, + bool deleteIfAnyFileBlocked) + { + List unwantedIndices = []; + long totalFiles = 0; + long totalUnwantedFiles = 0; + + foreach ((int index, string validationName, string logName, FileBlockAction action) in files) + { + totalFiles++; + + if (action is FileBlockAction.AlreadySkipped) + { + _logger.LogTrace("File is already skipped | {File}", logName); + totalUnwantedFiles++; + continue; + } + + if (_filenameEvaluator.IsValid(validationName, blocklistType, patterns, regexes)) + { + _logger.LogTrace("File is valid | {File}", logName); + continue; + } + + _logger.LogInformation("unwanted file found | {File}", logName); + totalUnwantedFiles++; + + if (deleteIfAnyFileBlocked) + { + return (unwantedIndices, totalFiles, totalUnwantedFiles, true); + } + + unwantedIndices.Add(index); + } + + return (unwantedIndices, totalFiles, totalUnwantedFiles, false); + } + + /// + /// Scans the files, sets the removal verdict on and marks the unwanted files as skipped. + /// + /// Marks the given file indices as skipped in the client. + protected async Task ApplyFileBlockingAsync( + BlockFilesResult result, + string name, + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files, + bool deleteIfAnyFileBlocked, + Func, Task> markFilesAsSkipped) + { + InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); + BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); + ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); + ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); + + (List unwantedIndices, long totalFiles, long totalUnwantedFiles, bool deleteImmediately) = + ScanFilesForBlocking(files, blocklistType, patterns, regexes, deleteIfAnyFileBlocked); + + if (deleteImmediately) + { + _logger.LogDebug("at least one file is blocked for {Name}", name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return; + } + + if (unwantedIndices.Count is 0) + { + _logger.LogDebug("No unwanted files found for {Name}", name); + return; + } + + if (totalUnwantedFiles == totalFiles) + { + _logger.LogDebug("All files are blocked for {Name}", name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AllFilesBlocked; + } + + _logger.LogDebug("Marking {Count} unwanted files as skipped for {Name}", unwantedIndices.Count, name); + await _dryRunInterceptor.InterceptAsync(() => MarkFilesAsSkipped(name, unwantedIndices, markFilesAsSkipped)); + } + + /// + /// A failed priority update leaves the verdict in place. + /// + private async Task MarkFilesAsSkipped(string name, List unwantedIndices, Func, Task> markFilesAsSkipped) + { + try + { + await markFilesAsSkipped(unwantedIndices); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to mark files as skipped | {Name}", name); + } + } /// - public abstract Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); + public async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) + { + ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); + ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); + SetDownloadClientContext(); + + string currentCategory = torrent.Category ?? string.Empty; + useTag = useTag && SupportsTags; + + await _dryRunInterceptor.InterceptAsync(() => ChangeCategoryInClientAsync(torrent, targetCategory, useTag)); + + await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag); + + if (!useTag) + { + torrent.Category = targetCategory; + } + } + + /// + /// Clients without tags fall back to a category change when asked for a tag. + /// + protected virtual bool SupportsTags => false; + + /// + /// Applies the category, or the tag when is set, in the client. + /// + protected abstract Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag); /// public abstract Task CreateCategoryAsync(string name); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/FileBlockAction.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/FileBlockAction.cs new file mode 100644 index 00000000..0d5b19bc --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/FileBlockAction.cs @@ -0,0 +1,17 @@ +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// What does with one file. +/// +public enum FileBlockAction +{ + /// + /// Already unwanted in the client; count it and skip the blocklist. + /// + AlreadySkipped, + + /// + /// Check the file name against the blocklist. + /// + CheckBlocklist +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/HardLinkScanAction.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/HardLinkScanAction.cs new file mode 100644 index 00000000..80ab963e --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/HardLinkScanAction.cs @@ -0,0 +1,22 @@ +namespace Cleanuparr.Infrastructure.Features.DownloadClient; + +/// +/// What does with one file. +/// +public enum HardLinkScanAction +{ + /// + /// File not downloaded; log and skip it. + /// + SkipUnwanted, + + /// + /// Read the file's hardlink count from disk. + /// + CheckHardLinks, + + /// + /// Count the torrent as hardlinked without touching disk. + /// + TreatAsLinked +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index 93104401..d7321bec 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -1,6 +1,4 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; @@ -73,77 +71,30 @@ public partial class QBitService return result; } - List unwantedFiles = []; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); - - foreach (TorrentContent file in files) + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems() { - if (!file.Index.HasValue) + foreach (TorrentContent file in files) { - _logger.LogTrace("Skipping file with no index | {File}", file.Name); - continue; + if (!file.Index.HasValue) + { + _logger.LogTrace("Skipping file with no index | {File}", file.Name); + continue; + } + + yield return (file.Index.Value, file.Name, file.Name, file.Priority is TorrentContentPriority.Skip + ? FileBlockAction.AlreadySkipped + : FileBlockAction.CheckBlocklist); } - - totalFiles++; - - if (file.Priority is TorrentContentPriority.Skip) - { - _logger.LogTrace("File is already skipped | {File}", file.Name); - totalUnwantedFiles++; - continue; - } - - if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes)) - { - _logger.LogTrace("File is valid | {File}", file.Name); - continue; - } - - _logger.LogInformation("unwanted file found | {File}", file.Name); - - if (malwareBlockerConfig.DeleteIfAnyFileBlocked) - { - _logger.LogDebug("at least one file is blocked for {Name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; - return result; - } - - unwantedFiles.Add(file.Index.Value); - totalUnwantedFiles++; } - if (unwantedFiles.Count is 0) + await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => { - _logger.LogDebug("No unwanted files found for {Name}", download.Name); - return result; - } - - if (totalUnwantedFiles == totalFiles) - { - _logger.LogDebug("All files are blocked for {Name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - } - - _logger.LogDebug("Marking {Count} unwanted files as skipped for {Name}", totalUnwantedFiles, download.Name); + foreach (int fileIndex in unwantedIndices) + { + await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); + } + }); - foreach (int fileIndex in unwantedFiles) - { - await _dryRunInterceptor.InterceptAsync(() => MarkFileAsSkipped(hash, fileIndex)); - } - return result; } - - protected virtual async Task MarkFileAsSkipped(string hash, int fileIndex) - { - await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip); - } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs index 88b23405..7bbc0c95 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceDC.cs @@ -1,6 +1,5 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; @@ -125,115 +124,49 @@ public partial class QBitService await _dryRunInterceptor.InterceptAsync(() => CreateCategory(name)); } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + /// + protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { - if (downloads?.Count is null or 0) + QBitItemWrapper qBitTorrent = (QBitItemWrapper)torrent; + IReadOnlyList? files = await _client.GetTorrentContentsAsync(qBitTorrent.Hash); + + if (files is null) { - return; + _logger.LogDebug("failed to find files for {Name}", qBitTorrent.Name); + return null; } - foreach (QBitItemWrapper torrent in downloads.Cast()) + IEnumerable<(string FilePath, HardLinkScanAction Action)> BuildScanItems() { - if (string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Category)) - { - continue; - } - - IReadOnlyList? files = await _client.GetTorrentContentsAsync(torrent.Hash); - - if (files is null) - { - _logger.LogDebug("failed to find files for {Name}", torrent.Name); - continue; - } - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - bool hasHardlinks = false; - bool hasErrors = false; - foreach (TorrentContent file in files) { if (!file.Index.HasValue) { - _logger.LogDebug("skip | file index is null for {Name}", torrent.Name); - hasHardlinks = true; - break; + _logger.LogDebug("skip | file index is null for {Name}", qBitTorrent.Name); + yield return (string.Empty, HardLinkScanAction.TreatAsLinked); + yield break; } string filePath = PathHelper.NormalizeAndRemap( - Path.Combine(torrent.Info.SavePath, file.Name), + Path.Combine(qBitTorrent.Info.SavePath, file.Name), _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget); - if (file.Priority is TorrentContentPriority.Skip) - { - _logger.LogDebug("skip | file is not downloaded | {File}", filePath); - continue; - } - - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath); - hasErrors = true; - break; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } - } - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag)); - - await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag); - - if (unlinkedConfig.UseTag) - { - _logger.LogInformation("tag added for {Name}", torrent.Name); - } - else - { - _logger.LogInformation("category changed for {Name}", torrent.Name); - torrent.Category = unlinkedConfig.TargetCategory; + yield return (filePath, file.Priority is TorrentContentPriority.Skip + ? HardLinkScanAction.SkipUnwanted + : HardLinkScanAction.CheckHardLinks); } } + + return BuildScanItems(); } /// - public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) - { - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); + protected override bool SupportsTags => true; - string currentCategory = torrent.Category ?? string.Empty; - - await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, targetCategory, useTag)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag); - - if (!useTag) - { - torrent.Category = targetCategory; - } - } + /// + protected override async Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => + await ChangeCategory(torrent.Hash, targetCategory, useTag); protected async Task CreateCategory(string name) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs index a2da79b2..58cf3016 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs @@ -83,62 +83,34 @@ public partial class QBitService return result; } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + /// + protected override Func>? GetAltSpeedLimitProbe() => IsAltSpeedLimitActiveAsync; + + /// + /// qBittorrent can stall on metadata before any content arrives. + /// That stall gets its own strike before the shared stall check. + /// + protected override async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) slowResult = await CheckIfSlow(wrapper); - - if (slowResult.ShouldRemove) + if (!((QBitItemWrapper)wrapper).IsMetadataDownloading()) { - return slowResult; + return await base.CheckIfStuck(wrapper); } - return await CheckIfStuck(wrapper); - } + QueueCleanerConfig queueCleanerConfig = ContextProvider.Get(nameof(QueueCleanerConfig)); - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) + if (queueCleanerConfig.DownloadingMetadataMaxStrikes <= 0) { - _logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name); return (false, DeleteReason.None, false, false); } - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } + bool shouldRemove = await _striker.StrikeAndCheckLimit( + wrapper.Hash, + wrapper.Name, + queueCleanerConfig.DownloadingMetadataMaxStrikes, + StrikeType.DownloadingMetadata + ); - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, IsAltSpeedLimitActiveAsync); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (((QBitItemWrapper)wrapper).IsMetadataDownloading()) - { - var queueCleanerConfig = ContextProvider.Get(nameof(QueueCleanerConfig)); - - if (queueCleanerConfig.DownloadingMetadataMaxStrikes > 0) - { - bool shouldRemove = await _striker.StrikeAndCheckLimit( - wrapper.Hash, - wrapper.Name, - queueCleanerConfig.DownloadingMetadataMaxStrikes, - StrikeType.DownloadingMetadata - ); - - return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove, false); - } - - return (false, DeleteReason.None, false, false); - } - - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); + return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove, false); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 428fbdc1..6c1bfcf6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities.RTorrent.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; @@ -21,7 +19,7 @@ public partial class RTorrentService if (download?.Hash is null) { - _logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); return result; } @@ -44,7 +42,7 @@ public partial class RTorrentService if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1) { - _logger.LogDebug("skip files check | download is private | {name}", download.Name); + _logger.LogDebug("skip files check | download is private | {Name}", download.Name); return result; } @@ -56,7 +54,7 @@ public partial class RTorrentService } catch (Exception exception) { - _logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name); + _logger.LogDebug(exception, "failed to find files in the download client | {Name}", download.Name); return result; } @@ -65,84 +63,24 @@ public partial class RTorrentService return result; } - bool hasPriorityUpdates = false; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); - - List<(int Index, int Priority)> priorityUpdates = []; - - foreach (var file in files) + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems() { - totalFiles++; - string fileName = Path.GetFileName(file.Path); - - if (result.ShouldRemove) + foreach (RTorrentFile file in files) { - continue; + yield return (file.Index, Path.GetFileName(file.Path), file.Path, file.Priority == 0 + ? FileBlockAction.AlreadySkipped + : FileBlockAction.CheckBlocklist); } + } - if (file.Priority == 0) + await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => + { + foreach (int index in unwantedIndices) { - _logger.LogTrace("File is already skipped | {file}", file.Path); - totalUnwantedFiles++; - continue; + await _client.SetFilePriorityAsync(hash, index, 0); } - - if (!_filenameEvaluator.IsValid(fileName, blocklistType, patterns, regexes)) - { - totalUnwantedFiles++; - hasPriorityUpdates = true; - priorityUpdates.Add((file.Index, 0)); - _logger.LogInformation("unwanted file found | {file}", file.Path); - - if (malwareBlockerConfig.DeleteIfAnyFileBlocked) - { - _logger.LogDebug("at least one file is blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; - return result; - } - - continue; - } - - _logger.LogTrace("File is valid | {file}", file.Path); - } - - if (result.ShouldRemove) - { - return result; - } - - if (!hasPriorityUpdates) - { - return result; - } - - if (totalUnwantedFiles == totalFiles) - { - _logger.LogDebug("All files are blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - } - - _logger.LogDebug("Marking {count} unwanted files as skipped for {name}", priorityUpdates.Count, download.Name); - - foreach (var (index, priority) in priorityUpdates) - { - await _dryRunInterceptor.InterceptAsync(() => SetFilePriority(hash, index, priority)); - } + }); return result; } - - protected virtual async Task SetFilePriority(string hash, int index, int priority) - { - await _client.SetFilePriorityAsync(hash, index, priority); - } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index 413adf6c..41c09497 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -1,7 +1,5 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.RTorrent.Response; -using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; @@ -66,17 +64,6 @@ public partial class RTorrentService return Task.FromResult>(claimed.ToList()); } - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => - downloads - ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) - .ToList(); - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => - downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .ToList(); - /// public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) { @@ -113,104 +100,42 @@ public partial class RTorrentService return Task.CompletedTask; } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + /// + protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { - if (downloads?.Count is null or 0) + RTorrentItemWrapper rTorrent = (RTorrentItemWrapper)torrent; + List files; + + try { - return; + files = await _client.GetTorrentFilesAsync(rTorrent.Hash); + } + catch (Exception exception) + { + _logger.LogDebug(exception, "failed to find torrent files for {Name}", rTorrent.Name); + return null; } - foreach (RTorrentItemWrapper torrent in downloads.Cast()) + List<(string FilePath, HardLinkScanAction Action)> scanItems = []; + + foreach (RTorrentFile file in files) { - if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) - { - continue; - } + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(rTorrent.Info.Directory ?? rTorrent.Info.BasePath ?? "", file.Path), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - List files; - try - { - files = await _client.GetTorrentFilesAsync(torrent.Hash); - } - catch (Exception exception) - { - _logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name); - continue; - } - - bool hasHardlinks = false; - bool hasErrors = false; - - foreach (var file in files) - { - string filePath = PathHelper.NormalizeAndRemap( - Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path), - _downloadClientConfig.DownloadDirectorySource, - _downloadClientConfig.DownloadDirectoryTarget); - - if (file.Priority <= 0) - { - _logger.LogDebug("skip | file is not downloaded | {file}", filePath); - continue; - } - - long hardlinkCount = _hardLinkFileService - .GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath); - hasErrors = true; - continue; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } - } - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); - - _logger.LogInformation("category changed for {name}", torrent.Name); - - await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory); - - torrent.Category = unlinkedConfig.TargetCategory; + scanItems.Add((filePath, file.Priority <= 0 + ? HardLinkScanAction.SkipUnwanted + : HardLinkScanAction.CheckHardLinks)); } + + return scanItems; } /// - public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) - { - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - string currentCategory = torrent.Category ?? string.Empty; - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory); - - torrent.Category = targetCategory; - } + protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => + ChangeLabel(torrent.Hash, targetCategory); protected virtual async Task ChangeLabel(string hash, string newLabel) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs index 00f70dd5..3f3ab742 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs @@ -67,44 +67,4 @@ public partial class RTorrentService return result; } - - private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) - { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(wrapper); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) - { - _logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); - } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index 3016d366..5a0d55fe 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -1,10 +1,9 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; -using Cleanuparr.Domain.Enums; +using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Microsoft.Extensions.Logging; +using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; @@ -17,21 +16,21 @@ public partial class TransmissionService TorrentInfo? download = await GetTorrentAsync(hash); BlockFilesResult result = new(); - if (download?.FileStats is null || download.FileStats.Length == 0) + if (download?.FileStats is null || download.FileStats.Length == 0 || download.Name is null) { - _logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name); + _logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name); return result; } if (download.Files is null) { - _logger.LogDebug("torrent {hash} has no files", hash); + _logger.LogDebug("Torrent {Hash} has no files", hash); return result; } if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads)) { - _logger.LogDebug("skip | download is ignored | {name}", download.Name); + _logger.LogDebug("skip | download is ignored | {Name}", download.Name); return result; } @@ -46,72 +45,34 @@ public partial class TransmissionService if (malwareBlockerConfig.IgnorePrivate && isPrivate) { // ignore private trackers - _logger.LogDebug("skip files check | download is private | {name}", download.Name); + _logger.LogDebug("skip files check | download is private | {Name}", download.Name); return result; } - List unwantedFiles = []; - long totalFiles = 0; - long totalUnwantedFiles = 0; - - InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); - - for (int i = 0; i < download.Files.Length; i++) + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems() { - if (download.FileStats?[i].Wanted == null) + for (int i = 0; i < download.Files.Length; i++) { - _logger.LogTrace("Skipping file with no stats | {file}", download.Files[i].Name); - continue; + if (download.FileStats?[i].Wanted == null) + { + _logger.LogTrace("Skipping file with no stats | {File}", download.Files[i].Name); + continue; + } + + yield return (i, download.Files[i].Name, download.Files[i].Name, download.FileStats[i].Wanted!.Value + ? FileBlockAction.CheckBlocklist + : FileBlockAction.AlreadySkipped); } - - totalFiles++; - - if (!download.FileStats[i].Wanted.Value) - { - _logger.LogTrace("File is already skipped | {file}", download.Files[i].Name); - totalUnwantedFiles++; - continue; - } - - if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes)) - { - _logger.LogTrace("File is valid | {file}", download.Files[i].Name); - continue; - } - - _logger.LogInformation("unwanted file found | {file}", download.Files[i].Name); - - if (malwareBlockerConfig.DeleteIfAnyFileBlocked) - { - _logger.LogDebug("at least one file is blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; - return result; - } - - unwantedFiles.Add(i); - totalUnwantedFiles++; } - if (unwantedFiles.Count is 0) + await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => { - _logger.LogDebug("No unwanted files found for {name}", download.Name); - return result; - } - - if (totalUnwantedFiles == totalFiles) - { - _logger.LogDebug("All files are blocked for {name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - } - - _logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name); - - await _dryRunInterceptor.InterceptAsync(() => SetUnwantedFiles(download.Id, unwantedFiles.ToArray())); + await _client.TorrentSetAsync(new TorrentSettings + { + Ids = [download.Id], + FilesUnwanted = unwantedIndices.Select(i => (long)i).ToArray(), + }); + }); return result; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs index 7ca4c537..f72b3c92 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceDC.cs @@ -1,6 +1,5 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Infrastructure.Extensions; -using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; @@ -54,13 +53,6 @@ public partial class TransmissionService }); /// - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) - { - return downloads - ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) - .ToList(); - } - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) { return downloads @@ -98,37 +90,28 @@ public partial class TransmissionService await Task.CompletedTask; } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + /// + protected override Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { - if (downloads?.Count is null or 0) + TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)torrent; + + if (string.IsNullOrEmpty(transmissionTorrent.Info.DownloadDir)) { - return; + return Task.FromResult?>(null); } - foreach (TransmissionItemWrapper torrent in downloads.Cast()) + if (transmissionTorrent.Info.Files is null || transmissionTorrent.Info.FileStats is null) { - if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Info.DownloadDir)) + _logger.LogDebug("skip | download has no files | {name}", transmissionTorrent.Name); + return Task.FromResult?>(null); + } + + IEnumerable<(string FilePath, HardLinkScanAction Action)> BuildScanItems() + { + for (int i = 0; i < transmissionTorrent.Info.Files.Length; i++) { - continue; - } - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - if (torrent.Info.Files is null || torrent.Info.FileStats is null) - { - _logger.LogDebug("skip | download has no files | {name}", torrent.Name); - continue; - } - - bool hasHardlinks = false; - bool hasErrors = false; - - for (int i = 0; i < torrent.Info.Files.Length; i++) - { - TransmissionTorrentFiles file = torrent.Info.Files[i]; - TransmissionTorrentFileStats stats = torrent.Info.FileStats[i]; + TransmissionTorrentFiles file = transmissionTorrent.Info.Files[i]; + TransmissionTorrentFileStats stats = transmissionTorrent.Info.FileStats[i]; if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name)) { @@ -136,99 +119,40 @@ public partial class TransmissionService } string filePath = PathHelper.NormalizeAndRemap( - Path.Combine(torrent.Info.DownloadDir, file.Name), + Path.Combine(transmissionTorrent.Info.DownloadDir, file.Name), _downloadClientConfig.DownloadDirectorySource, _downloadClientConfig.DownloadDirectoryTarget); - long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath); - hasErrors = true; - break; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } + yield return (filePath, HardLinkScanAction.CheckHardLinks); } - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name); - continue; - } - - string currentCategory = torrent.Category ?? string.Empty; - - if (unlinkedConfig.UseTag) - { - string[] newLabels = torrent.Tags - .Append(unlinkedConfig.TargetCategory) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(torrent.Info.Id, newLabels)); - - _logger.LogInformation("label added for {name}", torrent.Name); - - await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory, isTag: true); - - continue; - } - - string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory); - - await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(torrent.Info.Id, newLocation)); - - _logger.LogInformation("category changed for {name}", torrent.Name); - - await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory); - - torrent.Category = unlinkedConfig.TargetCategory; } + + return Task.FromResult?>(BuildScanItems()); } /// - public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) + protected override bool SupportsTags => true; + + /// + protected override async Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) { - var transmissionTorrent = (TransmissionItemWrapper)torrent; - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - string currentCategory = torrent.Category ?? string.Empty; + TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)torrent; if (useTag) { - string[] newLabels = torrent.Tags + string[] newLabels = transmissionTorrent.Tags .Append(targetCategory) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(transmissionTorrent.Info.Id, newLabels)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, isTag: true); + await ChangeLabels(transmissionTorrent.Info.Id, newLabels); return; } string newLocation = transmissionTorrent.Info.GetNewLocationByAppend(targetCategory); - await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory); - - torrent.Category = targetCategory; + await ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation); } protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs index 64a68b82..e41b2254 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs @@ -1,11 +1,5 @@ -using Cleanuparr.Domain.Entities; -using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Extensions; -using Cleanuparr.Infrastructure.Features.Context; -using Cleanuparr.Infrastructure.Services.Interfaces; -using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Cleanuparr.Domain.Enums; using Microsoft.Extensions.Logging; -using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; @@ -72,53 +66,6 @@ public partial class TransmissionService return result; } - protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles) - { - await _client.TorrentSetAsync(new TorrentSettings - { - Ids = [downloadId], - FilesUnwanted = unwantedFiles, - }); - } - - private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) - { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(wrapper); - } - - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) - { - _logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, IsAltSpeedLimitActiveAsync); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); - } + /// + protected override Func>? GetAltSpeedLimitProbe() => IsAltSpeedLimitActiveAsync; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index 438eb754..b6a95ce6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -1,5 +1,3 @@ -using System.Collections.Concurrent; -using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities.UTorrent.Response; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Extensions; @@ -63,61 +61,22 @@ public partial class UTorrentService return result; } - List fileIndexes = new(files.Count); - long totalUnwantedFiles = 0; - - InstanceType instanceType = (InstanceType)ContextProvider.Get(nameof(InstanceType)); - BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType); - ConcurrentBag patterns = _blocklistProvider.GetPatterns(instanceType); - ConcurrentBag regexes = _blocklistProvider.GetRegexes(instanceType); - - for (int i = 0; i < files.Count; i++) + IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems() { - var file = files[i]; - - if (file.Priority == 0) // Already skipped + for (int i = 0; i < files.Count; i++) { - totalUnwantedFiles++; - continue; - } - - if (file.Priority != 0 && !_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes)) - { - totalUnwantedFiles++; - fileIndexes.Add(i); - _logger.LogInformation("unwanted file found | {File}", file.Name); - - if (malwareBlockerConfig.DeleteIfAnyFileBlocked) - { - _logger.LogDebug("at least one file is blocked for {Name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; - return result; - } + UTorrentFile file = files[i]; + yield return (i, file.Name, file.Name, file.Priority == 0 + ? FileBlockAction.AlreadySkipped + : FileBlockAction.CheckBlocklist); } } - if (fileIndexes.Count is 0) + await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices => { - return result; - } - - _logger.LogDebug("changing priorities | torrent {Hash}", hash); - - if (totalUnwantedFiles == files.Count) - { - _logger.LogDebug("All files are blocked for {Name}", download.Name); - result.ShouldRemove = true; - result.DeleteReason = DeleteReason.AllFilesBlocked; - } - - await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, fileIndexes)); + await _client.SetFilesPriorityAsync(hash, unwantedIndices, 0); + }); return result; } - - protected virtual async Task ChangeFilesPriority(string hash, List fileIndexes) - { - await _client.SetFilesPriorityAsync(hash, fileIndexes, 0); - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs index 1dd8cff5..c54e3696 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceDC.cs @@ -1,9 +1,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.UTorrent.Response; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions; -using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.Extensions.Logging; @@ -60,17 +58,6 @@ public partial class UTorrentService return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? []; }); - public override List? FilterDownloadsToBeCleanedAsync(List? downloads, List seedingRules) => - downloads - ?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase)))) - .ToList(); - - public override List? FilterDownloadsToChangeCategoryAsync(List? downloads, UnlinkedConfig unlinkedConfig) => - downloads - ?.Where(x => !string.IsNullOrEmpty(x.Hash)) - .Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase))) - .ToList(); - /// public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles) { @@ -89,95 +76,32 @@ public partial class UTorrentService await Task.CompletedTask; } - public override async Task ChangeCategoryForNoHardLinksAsync(List? downloads, UnlinkedConfig unlinkedConfig) + /// + protected override async Task?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent) { - if (downloads?.Count is null or 0) + UTorrentItemWrapper uTorrent = (UTorrentItemWrapper)torrent; + List? files = await _client.GetTorrentFilesAsync(uTorrent.Hash); + + List<(string FilePath, HardLinkScanAction Action)> scanItems = []; + + foreach (UTorrentFile file in files ?? []) { - return; + string filePath = PathHelper.NormalizeAndRemap( + Path.Combine(uTorrent.Info.SavePath, file.Name), + _downloadClientConfig.DownloadDirectorySource, + _downloadClientConfig.DownloadDirectoryTarget); + + scanItems.Add((filePath, file.Priority <= 0 + ? HardLinkScanAction.SkipUnwanted + : HardLinkScanAction.CheckHardLinks)); } - foreach (UTorrentItemWrapper torrent in downloads.Cast()) - { - if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category)) - { - continue; - } - - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - List? files = await _client.GetTorrentFilesAsync(torrent.Hash); - - bool hasHardlinks = false; - bool hasErrors = false; - - foreach (var file in files ?? []) - { - string filePath = PathHelper.NormalizeAndRemap( - Path.Combine(torrent.Info.SavePath, file.Name), - _downloadClientConfig.DownloadDirectorySource, - _downloadClientConfig.DownloadDirectoryTarget); - - if (file.Priority <= 0) - { - _logger.LogDebug("skip | file is not downloaded | {File}", filePath); - continue; - } - - long hardlinkCount = _hardLinkFileService - .GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0); - - if (hardlinkCount < 0) - { - _logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath); - hasErrors = true; - break; - } - - if (hardlinkCount > 0) - { - hasHardlinks = true; - break; - } - } - - if (hasErrors) - { - continue; - } - - if (hasHardlinks) - { - _logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name); - continue; - } - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory)); - - await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory); - - _logger.LogInformation("category changed for {Name}", torrent.Name); - - torrent.Category = unlinkedConfig.TargetCategory; - } + return scanItems; } /// - public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) - { - ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name); - ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash); - SetDownloadClientContext(); - - string currentCategory = torrent.Category ?? string.Empty; - - await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory)); - - await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory); - - torrent.Category = targetCategory; - } + protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) => + ChangeLabel(torrent.Hash, targetCategory); protected virtual async Task ChangeLabel(string hash, string newLabel) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs index e01afd40..d941ab5d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs @@ -1,10 +1,7 @@ using Cleanuparr.Domain.Entities; using Cleanuparr.Domain.Entities.UTorrent.Response; using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions; -using Cleanuparr.Infrastructure.Services.Interfaces; -using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; @@ -82,45 +79,4 @@ public partial class UTorrentService return result; } - - private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) - { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); - - if (result.ShouldRemove) - { - return result; - } - - return await CheckIfStuck(wrapper); - } - - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsDownloading()) - { - _logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - if (wrapper.DownloadSpeed <= 0) - { - _logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); - } - - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) - { - if (!wrapper.IsStalled()) - { - _logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name); - return (false, DeleteReason.None, false, false); - } - - return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); - } } diff --git a/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts index de13cee9..5af7cda6 100644 --- a/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts @@ -49,6 +49,7 @@ const utorrent = new UTorrentDriver(); * Two scenarios may share the same physical client (e.g. qBittorrent category * vs tag) — they use different source categories so they never overlap, and * the physical client is prepared (ready/clear/reset) only once. + * Label-only clients (Deluge, uTorrent) fall back to a label change when useTag is on. */ interface Scenario { key: string; @@ -143,6 +144,18 @@ const scenarios: Scenario[] = [ return (await deluge.getTorrentLabel(hash)) === TARGET; }, }, + // Deluge: useTag falls back to the label. + { + key: 'Deluge (tag fallback)', driver: deluge, physicalSlug: 'deluge', source: 'dl-tag', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true, + async addSeeding(name, announce) { + const d = buildTorrent('deluge', name, announce); + await deluge.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'dl-tag', name: d.name, infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await deluge.getTorrentLabel(hash)) === TARGET; + }, + }, // µTorrent — label (bridge-networked → reaches opentracker via host.docker.internal). { key: 'uTorrent', driver: utorrent, physicalSlug: 'utorrent', source: 'ut-src', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_BRIDGE, soloSeedSparable: false, @@ -155,6 +168,18 @@ const scenarios: Scenario[] = [ return (await utorrent.getTorrentLabel(hash)) === TARGET; }, }, + // µTorrent: useTag falls back to the label. + { + key: 'uTorrent (tag fallback)', driver: utorrent, physicalSlug: 'utorrent', source: 'ut-tag', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_BRIDGE, soloSeedSparable: false, + async addSeeding(name, announce) { + const d = buildTorrent('utorrent', name, announce); + await utorrent.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'ut-tag', name: d.name, infoHash: d.infoHash }); + return d.infoHash; + }, + async isMoved(hash) { + return (await utorrent.getTorrentLabel(hash)) === TARGET; + }, + }, ]; async function waitForRegistered(driver: DriverLike, infoHash: string, timeoutMs = 20_000): Promise { diff --git a/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts b/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts index 8086b101..7ba18570 100644 --- a/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts +++ b/e2e/tests/download-cleaner/seeding-rule-stop.spec.ts @@ -16,6 +16,7 @@ import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; import { TransmissionDriver } from '../helpers/torrent-clients/transmission'; import { DelugeDriver } from '../helpers/torrent-clients/deluge'; import { UTorrentDriver } from '../helpers/torrent-clients/utorrent'; +import { RTorrentDriver } from '../helpers/torrent-clients/rtorrent'; import { buildFolderTorrent, resetDirectory } from '../helpers/torrent-fixtures'; import { mkdirShared } from '../helpers/shared-volume'; @@ -28,6 +29,7 @@ const qbit = new QBittorrentDriver(); const transmission = new TransmissionDriver(); const deluge = new DelugeDriver(); const utorrent = new UTorrentDriver(); +const rtorrent = new RTorrentDriver(); function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); @@ -98,6 +100,20 @@ const scenarios: Scenario[] = [ return fx; }, }, + { + key: 'rTorrent', driver: rtorrent, slug: 'rtorrent', category: 'rt-stop', + async addSeeding() { + const dir = join(HOST_DOWNLOADS, 'rtorrent'); + mkdirShared(dir); + const fx = buildFolderTorrent(dir, 'stop-rule-rtorrent', 32_768, ANNOUNCE_HOST); + await rtorrent.addSeedingTorrent({ metainfo: fx.metainfo, savePath: CLIENT_DOWNLOADS, category: 'rt-stop', name: fx.name, infoHash: fx.infoHash }); + // rTorrent lists a torrent as seeding only after its hash check completes. + await expect + .poll(() => rtorrent.getComplete(fx.infoHash), { message: 'rTorrent: hash check never completed', timeout: 30_000, intervals: [500] }) + .toBe(1); + return fx; + }, + }, ]; async function isPresent(driver: DriverLike, infoHash: string): Promise { diff --git a/e2e/tests/helpers/torrent-clients/deluge.ts b/e2e/tests/helpers/torrent-clients/deluge.ts index 04e2a904..cc8b10d9 100644 --- a/e2e/tests/helpers/torrent-clients/deluge.ts +++ b/e2e/tests/helpers/torrent-clients/deluge.ts @@ -175,4 +175,12 @@ export class DelugeDriver implements TorrentClientDriver { const result = await this.call>('core.get_torrents_status', [{}, ['name']]); return Object.entries(result ?? {}).map(([hash, info]) => ({ hash, name: info.name })); } + + async getFilePriorities(infoHash: string): Promise { + const result = await this.call>( + 'core.get_torrents_status', + [{ id: [infoHash] }, ['file_priorities']], + ); + return result?.[infoHash]?.file_priorities ?? []; + } } diff --git a/e2e/tests/helpers/torrent-clients/qbittorrent.ts b/e2e/tests/helpers/torrent-clients/qbittorrent.ts index 8e96e853..3dec3d9a 100644 --- a/e2e/tests/helpers/torrent-clients/qbittorrent.ts +++ b/e2e/tests/helpers/torrent-clients/qbittorrent.ts @@ -217,4 +217,15 @@ export class QBittorrentDriver implements TorrentClientDriver { const items: Array<{ hash: string; name: string }> = await res.json(); return items.map((t) => ({ hash: t.hash, name: t.name })); } + + async getFilePriorities(infoHash: string): Promise { + const res = await fetch(`${this.directHost}/api/v2/torrents/files?hash=${infoHash.toLowerCase()}`, { + headers: this.cookie ? { Cookie: this.cookie } : undefined, + }); + if (!res.ok) { + throw new Error(`qBittorrent files failed: ${res.status}`); + } + const items: Array<{ priority: number }> = await res.json(); + return items.map((f) => f.priority); + } } diff --git a/e2e/tests/helpers/torrent-clients/rtorrent.ts b/e2e/tests/helpers/torrent-clients/rtorrent.ts index 42543872..8428bf76 100644 --- a/e2e/tests/helpers/torrent-clients/rtorrent.ts +++ b/e2e/tests/helpers/torrent-clients/rtorrent.ts @@ -125,6 +125,12 @@ export class RTorrentDriver implements TorrentClientDriver { return { hash: String(arr[0]).toLowerCase(), name: String(arr[1]) }; }); } + + async getFilePriorities(infoHash: string): Promise { + const result = await this.call('f.multicall', [infoHash.toUpperCase(), '', 'f.priority=']); + if (!Array.isArray(result)) return []; + return result.map((row: unknown) => Number((row as unknown[])[0])); + } } type XmlRpcValue = string | number | boolean | { type: 'base64'; value: string }; @@ -189,7 +195,7 @@ function parseValue(xml: string): unknown { return out; } const scalar = xml.match(new RegExp(`<${type}>([\\s\\S]*?)<\\/${type}>`))?.[1] ?? ''; - if (type === 'int' || type === 'i4') return Number(scalar); + if (type === 'int' || type === 'i4' || type === 'i8') return Number(scalar); if (type === 'boolean') return scalar === '1'; if (type === 'double') return Number(scalar); return decodeXml(scalar); diff --git a/e2e/tests/helpers/torrent-clients/transmission.ts b/e2e/tests/helpers/torrent-clients/transmission.ts index 75ff0187..d9f18c9c 100644 --- a/e2e/tests/helpers/torrent-clients/transmission.ts +++ b/e2e/tests/helpers/torrent-clients/transmission.ts @@ -138,4 +138,12 @@ export class TransmissionDriver implements TorrentClientDriver { const args = await this.call('torrent-get', { fields: ['hashString', 'name'] }); return (args.torrents ?? []).map((t: { hashString: string; name: string }) => ({ hash: t.hashString, name: t.name })); } + + /** Maps fileStats.wanted to 0/1 like the other drivers. */ + async getFilePriorities(infoHash: string): Promise { + const args = await this.call('torrent-get', { ids: [infoHash], fields: ['hashString', 'fileStats'] }); + const t = (args.torrents ?? [])[0]; + const stats: Array<{ wanted: boolean }> = t?.fileStats ?? []; + return stats.map((f) => (f.wanted ? 1 : 0)); + } } diff --git a/e2e/tests/helpers/torrent-clients/types.ts b/e2e/tests/helpers/torrent-clients/types.ts index 423a29fd..5eeb3039 100644 --- a/e2e/tests/helpers/torrent-clients/types.ts +++ b/e2e/tests/helpers/torrent-clients/types.ts @@ -33,6 +33,11 @@ export interface TorrentClientDriver { * False when the client does not know the torrent. */ isStopped(infoHash: string): Promise; + /** + * Per-file priority in file order. + * 0 means the client skips the file. + */ + getFilePriorities(infoHash: string): Promise; /** * Remove every torrent currently registered with the client without deleting * data on disk. Called at the start of each test to make the spec diff --git a/e2e/tests/helpers/torrent-clients/utorrent.ts b/e2e/tests/helpers/torrent-clients/utorrent.ts index 0699ae34..0f9c8803 100644 --- a/e2e/tests/helpers/torrent-clients/utorrent.ts +++ b/e2e/tests/helpers/torrent-clients/utorrent.ts @@ -166,4 +166,16 @@ export class UTorrentDriver implements TorrentClientDriver { await fetch(url, { headers: this.requestHeaders() }); } } + + /** Priority sits at index 3 of each getfiles row. */ + async getFilePriorities(infoHash: string): Promise { + const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=getfiles&hash=${infoHash.toUpperCase()}`; + const res = await fetch(url, { headers: this.requestHeaders() }); + if (!res.ok) { + throw new Error(`uTorrent getfiles: ${res.status}`); + } + const body: { files?: [string, unknown[][]] } = await res.json(); + const rows = body.files?.[1] ?? []; + return rows.map((row) => Number(row[3])); + } } diff --git a/e2e/tests/malware-blocker/malware-blocker.spec.ts b/e2e/tests/malware-blocker/malware-blocker.spec.ts index db33f73e..d82c6679 100644 --- a/e2e/tests/malware-blocker/malware-blocker.spec.ts +++ b/e2e/tests/malware-blocker/malware-blocker.spec.ts @@ -16,6 +16,7 @@ import { buildMultiFileTorrent, resetDirectory } from '../helpers/torrent-fixtur import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client'; import { TEST_CONFIG, farFutureCron } from '../helpers/test-config'; import { mkdirShared, writeFileShared } from '../helpers/shared-volume'; +import { buildHubConnection, waitForEvent } from '../helpers/api/signalr'; /** * End-to-end coverage for the `DeleteIfAnyFileBlocked` setting on the @@ -397,3 +398,352 @@ function runClientScenarios( }); }); } + +/** + * End-to-end coverage for the `AllFilesBlocked` path. + * Every file matches the blocklist and `DeleteIfAnyFileBlocked` stays off. + * The suite pins removal with DeleteReason.AllFilesBlocked on every client. + */ +async function findEventWithDeleteReason( + token: string, + itemHash: string, + deleteReason: string, + timeoutMs: number, +): Promise | undefined> { + const connection = buildHubConnection({ accessToken: token, hubUrl: '/api/hubs/app' }); + await connection.start(); + + try { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const received = waitForEvent>>(connection, 'EventsReceived'); + await connection.invoke('GetRecentEvents', 20); + const events = await received; + const match = events.find( + (e) => String(e.itemHash).toLowerCase() === itemHash.toLowerCase() && e.deleteReason === deleteReason, + ); + if (match) { + return match; + } + await new Promise((r) => setTimeout(r, 500)); + } + return undefined; + } finally { + await connection.stop(); + } +} + +test.describe.serial('Malware blocker — AllFilesBlocked', () => { + let token: string; + const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); + let sonarrId: string | undefined; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + mkdirShared(join(HOST_DOWNLOADS, 'malware-blocker')); + writeFileShared(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n'); + + await arr.waitReady(); + await stubArrQueue(arr, []); + + const sonarrRes = await createArrInstance(token, 'sonarr', { + name: 'Malware Blocker AllFilesBlocked Stub', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'malware-blocker-all-files-e2e', + version: 4, + }); + expect(sonarrRes.status).toBe(201); + const sonarrBody = await sonarrRes.json(); + sonarrId = sonarrBody.id; + }); + + test.afterAll(async () => { + if (sonarrId) { + await deleteArrInstance(token, 'sonarr', sonarrId); + } + await arr.resetAll(); + }); + + for (const fixture of ALL_CLIENTS) { + runAllFilesBlockedScenario(fixture, () => ({ token, arr })); + } +}); + +function runAllFilesBlockedScenario( + fixture: TorrentClientFixture, + getCtx: () => { token: string; arr: WireMockClient }, +): void { + const { driver } = fixture; + const slug = SLUG_BY_TYPE[driver.typeName]; + const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip; + + describeFn(`${driver.typeName}`, () => { + let clientId: string; + let torrent: TorrentMeta; + const hostScanDir = join(HOST_DOWNLOADS, slug); + + test.beforeAll(async () => { + const { token } = getCtx(); + + resetDirectory(hostScanDir); + await driver.ready(); + await driver.clearAllTorrents(); + + const runId = Date.now().toString(36); + const fx = buildMultiFileTorrent(hostScanDir, `all-blocked-${slug}-${runId}`, [ + { filename: 'installer.exe', sizeBytes: 1024 }, + { filename: 'setup.exe', sizeBytes: 1024 }, + ]); + torrent = { name: fx.name, infoHash: fx.infoHash }; + + const createRes = await createDownloadClient(token, { + enabled: true, + name: `${driver.typeName} malware-blocker all-files-blocked e2e`, + typeName: driver.typeName, + type: 'Torrent', + host: driver.cleanuparrHost, + username: driver.username ?? '', + password: driver.password ?? '', + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200); + expect(createRes.status).toBeLessThan(300); + clientId = (await createRes.json()).id; + + await driver.addTorrent({ + metainfo: fx.metainfo, + savePath: CLIENT_DOWNLOADS, + name: fx.name, + infoHash: fx.infoHash, + }); + await waitForTorrents(driver, [torrent.infoHash]); + }); + + test.afterAll(async () => { + const { token } = getCtx(); + if (clientId) { + await deleteDownloadClient(token, clientId); + } + }); + + test('torrent with every file blocked is removed with DeleteReason.AllFilesBlocked', async () => { + test.setTimeout(120_000); + const { token, arr } = getCtx(); + + await setMalwareBlocker(token, { + enabled: true, + useAdvancedScheduling: true, + cronExpression: farFutureCron(), + deleteIfAnyFileBlocked: false, + ignorePrivate: false, + deletePrivate: true, + processNoContentId: false, + sonarr: { + enabled: true, + blocklistPath: APP_BLOCKLIST_PATH, + blocklistType: 'Blacklist', + }, + }); + + const queueId = 2001; + await stubArrQueue(arr, [ + { + id: queueId, + downloadId: torrent.infoHash, + title: torrent.name, + protocol: 'torrent', + seriesId: 3, + episodeId: 3, + }, + ]); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const start = Date.now(); + while (Date.now() - start < POLL_TIMEOUT_MS) { + if ((await readDeletes(arr)).some((d) => d.id === queueId)) { + break; + } + await new Promise((r) => setTimeout(r, 500)); + } + + const deletes = await readDeletes(arr); + const onDelete = deletes.find((d) => d.id === queueId); + expect(onDelete, `sonarr queue item ${queueId} should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined(); + expect(onDelete!.removeFromClient, 'malware blocker should ask sonarr to remove the torrent from the client').toBe(true); + expect(onDelete!.blocklist, 'malware blocker should ask sonarr to blocklist the release').toBe(true); + + const event = await findEventWithDeleteReason(token, torrent.infoHash, 'AllFilesBlocked', POLL_TIMEOUT_MS); + expect(event, `expected an event with deleteReason=AllFilesBlocked for ${torrent.infoHash}`).toBeDefined(); + }); + }); +} + +/** + * Covers `AllFilesBlocked` on a private torrent with `deletePrivate` off. + * The arr drops the queue item with removeFromClient=false. + * The torrent stays in the client with every file unwanted. + */ +test.describe.serial('Malware blocker: AllFilesBlocked, deletePrivate off', () => { + let token: string; + const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); + let sonarrId: string | undefined; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + mkdirShared(join(HOST_DOWNLOADS, 'malware-blocker')); + writeFileShared(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n'); + + await arr.waitReady(); + await stubArrQueue(arr, []); + + const sonarrRes = await createArrInstance(token, 'sonarr', { + name: 'Malware Blocker AllFilesBlocked Private Stub', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'malware-blocker-all-files-private-e2e', + version: 4, + }); + expect(sonarrRes.status).toBe(201); + const sonarrBody = await sonarrRes.json(); + sonarrId = sonarrBody.id; + }); + + test.afterAll(async () => { + if (sonarrId) { + await deleteArrInstance(token, 'sonarr', sonarrId); + } + await arr.resetAll(); + }); + + for (const fixture of ALL_CLIENTS) { + runAllFilesBlockedKeepPrivateScenario(fixture, () => ({ token, arr })); + } +}); + +function runAllFilesBlockedKeepPrivateScenario( + fixture: TorrentClientFixture, + getCtx: () => { token: string; arr: WireMockClient }, +): void { + const { driver } = fixture; + const slug = SLUG_BY_TYPE[driver.typeName]; + const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip; + + describeFn(`${driver.typeName}`, () => { + let clientId: string; + let torrent: TorrentMeta; + const hostScanDir = join(HOST_DOWNLOADS, slug); + + test.beforeAll(async () => { + const { token } = getCtx(); + + resetDirectory(hostScanDir); + await driver.ready(); + await driver.clearAllTorrents(); + + const runId = Date.now().toString(36); + // buildMultiFileTorrent marks every torrent private. + const fx = buildMultiFileTorrent(hostScanDir, `all-blocked-private-${slug}-${runId}`, [ + { filename: 'installer.exe', sizeBytes: 1024 }, + { filename: 'setup.exe', sizeBytes: 1024 }, + ]); + torrent = { name: fx.name, infoHash: fx.infoHash }; + + const createRes = await createDownloadClient(token, { + enabled: true, + name: `${driver.typeName} malware-blocker all-files-blocked-private e2e`, + typeName: driver.typeName, + type: 'Torrent', + host: driver.cleanuparrHost, + username: driver.username ?? '', + password: driver.password ?? '', + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200); + expect(createRes.status).toBeLessThan(300); + clientId = (await createRes.json()).id; + + await driver.addTorrent({ + metainfo: fx.metainfo, + savePath: CLIENT_DOWNLOADS, + name: fx.name, + infoHash: fx.infoHash, + }); + await waitForTorrents(driver, [torrent.infoHash]); + }); + + test.afterAll(async () => { + const { token } = getCtx(); + if (clientId) { + await deleteDownloadClient(token, clientId); + } + }); + + test('private torrent with every file blocked stays in the client with every file unwanted', async () => { + test.setTimeout(120_000); + const { token, arr } = getCtx(); + + await setMalwareBlocker(token, { + enabled: true, + useAdvancedScheduling: true, + cronExpression: farFutureCron(), + deleteIfAnyFileBlocked: false, + ignorePrivate: false, + deletePrivate: false, + processNoContentId: false, + sonarr: { + enabled: true, + blocklistPath: APP_BLOCKLIST_PATH, + blocklistType: 'Blacklist', + }, + }); + + const queueId = 3001; + await stubArrQueue(arr, [ + { + id: queueId, + downloadId: torrent.infoHash, + title: torrent.name, + protocol: 'torrent', + seriesId: 4, + episodeId: 4, + }, + ]); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const start = Date.now(); + while (Date.now() - start < POLL_TIMEOUT_MS) { + if ((await readDeletes(arr)).some((d) => d.id === queueId)) { + break; + } + await new Promise((r) => setTimeout(r, 500)); + } + + const deletes = await readDeletes(arr); + const onDelete = deletes.find((d) => d.id === queueId); + expect(onDelete, `sonarr queue item ${queueId} should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined(); + expect(onDelete!.removeFromClient, 'malware blocker must not ask sonarr to remove a private torrent from the client').toBe(false); + + const event = await findEventWithDeleteReason(token, torrent.infoHash, 'AllFilesBlocked', POLL_TIMEOUT_MS); + expect(event, `expected an event with deleteReason=AllFilesBlocked for ${torrent.infoHash}`).toBeDefined(); + + await new Promise((r) => setTimeout(r, TRIGGER_SETTLE_MS)); + + expect(await torrentPresent(driver, torrent.infoHash), `expected ${torrent.infoHash} to remain in ${driver.typeName} (deletePrivate off)`).toBe(true); + + const priorities = await driver.getFilePriorities(torrent.infoHash); + expect(priorities.length, `expected file priorities for ${torrent.infoHash}`).toBeGreaterThan(0); + expect(priorities.every((p) => p === 0), `expected every file to be unwanted, saw ${JSON.stringify(priorities)}`).toBe(true); + }); + }); +}