From ab792f5faddacf069f58a798a412aab856c684d9 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 5 May 2026 15:59:42 +0300 Subject: [PATCH] Add option to change the category instead of deleting queue items (#602) --- .../Contracts/Requests/QueueRuleDto.cs | 2 + .../Controllers/QueueRulesController.cs | 4 + .../DownloadClient/DelugeServiceTests.cs | 65 +- .../DownloadClient/QBitServiceTests.cs | 141 +- .../DownloadClient/RTorrentServiceTests.cs | 78 +- .../TransmissionServiceTests.cs | 64 +- .../DownloadClient/UTorrentServiceTests.cs | 123 +- .../DownloadRemover/QueueItemRemoverTests.cs | 33 +- .../MalwareBlockerIntegrationTests.cs | 6 +- .../QueueCleanerIntegrationTests.cs | 8 +- .../Features/Jobs/QueueCleanerTests.cs | 159 ++ .../Services/QueueRuleEvaluatorTests.cs | 149 +- .../Features/Arr/ArrClient.cs | 39 +- .../Features/Arr/Interfaces/IArrClient.cs | 10 +- .../Features/Arr/LidarrClient.cs | 8 - .../Features/Arr/RadarrClient.cs | 8 - .../Features/Arr/ReadarrClient.cs | 8 - .../Features/Arr/SonarrClient.cs | 8 - .../Features/Arr/WhisparrV2Client.cs | 8 - .../Features/Arr/WhisparrV3Client.cs | 8 - .../DownloadClient/Deluge/DelugeServiceQC.cs | 16 +- .../DownloadClient/DownloadCheckResult.cs | 5 + .../QBittorrent/QBitServiceQC.cs | 22 +- .../RTorrent/RTorrentServiceQC.cs | 16 +- .../Transmission/TransmissionServiceQC.cs | 16 +- .../UTorrent/UTorrentServiceQC.cs | 16 +- .../Models/QueueItemRemoveRequest.cs | 6 + .../DownloadRemover/QueueItemRemover.cs | 2 +- .../Features/Jobs/GenericHandler.cs | 5 +- .../Features/Jobs/QueueCleaner.cs | 12 +- .../Interfaces/IQueueRuleEvaluator.cs | 4 +- .../Services/QueueRuleEvaluator.cs | 21 +- .../QueueCleaner/FailedImportConfigTests.cs | 73 + .../QueueCleaner/QueueRuleTests.cs | 59 + ..._AddQueueCleanerChangeCategory.Designer.cs | 2120 +++++++++++++++++ ...504193343_AddQueueCleanerChangeCategory.cs | 51 + .../Data/DataContextModelSnapshot.cs | 12 + .../QueueCleaner/FailedImportConfig.cs | 9 +- .../Configuration/QueueCleaner/QueueRule.cs | 9 +- .../core/services/documentation.service.ts | 3 + .../queue-cleaner.component.html | 40 +- .../queue-cleaner/queue-cleaner.component.ts | 38 +- .../models/queue-cleaner-config.model.ts | 1 + .../src/app/shared/models/queue-rule.model.ts | 3 + .../configuration/queue-cleaner/index.mdx | 36 + 45 files changed, 3321 insertions(+), 203 deletions(-) create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.cs diff --git a/code/backend/Cleanuparr.Api/Features/QueueCleaner/Contracts/Requests/QueueRuleDto.cs b/code/backend/Cleanuparr.Api/Features/QueueCleaner/Contracts/Requests/QueueRuleDto.cs index ec432953..507797e3 100644 --- a/code/backend/Cleanuparr.Api/Features/QueueCleaner/Contracts/Requests/QueueRuleDto.cs +++ b/code/backend/Cleanuparr.Api/Features/QueueCleaner/Contracts/Requests/QueueRuleDto.cs @@ -24,4 +24,6 @@ public abstract record QueueRuleDto public ushort MaxCompletionPercentage { get; set; } public bool DeletePrivateTorrentsFromClient { get; set; } = false; + + public bool ChangeCategory { get; set; } = false; } diff --git a/code/backend/Cleanuparr.Api/Features/QueueCleaner/Controllers/QueueRulesController.cs b/code/backend/Cleanuparr.Api/Features/QueueCleaner/Controllers/QueueRulesController.cs index 5deb1b1c..35bb650c 100644 --- a/code/backend/Cleanuparr.Api/Features/QueueCleaner/Controllers/QueueRulesController.cs +++ b/code/backend/Cleanuparr.Api/Features/QueueCleaner/Controllers/QueueRulesController.cs @@ -88,6 +88,7 @@ public class QueueRulesController : ControllerBase MaxCompletionPercentage = ruleDto.MaxCompletionPercentage, ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress, DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient, + ChangeCategory = ruleDto.ChangeCategory, MinimumProgress = ruleDto.MinimumProgress?.Trim(), }; @@ -161,6 +162,7 @@ public class QueueRulesController : ControllerBase MaxCompletionPercentage = ruleDto.MaxCompletionPercentage, ResetStrikesOnProgress = ruleDto.ResetStrikesOnProgress, DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient, + ChangeCategory = ruleDto.ChangeCategory, MinimumProgress = ruleDto.MinimumProgress?.Trim(), }; @@ -293,6 +295,7 @@ public class QueueRulesController : ControllerBase MaxTimeHours = ruleDto.MaxTimeHours, IgnoreAboveSize = ruleDto.IgnoreAboveSize, DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient, + ChangeCategory = ruleDto.ChangeCategory, }; var existingRules = await _dataContext.SlowRules.ToListAsync(); @@ -368,6 +371,7 @@ public class QueueRulesController : ControllerBase MaxTimeHours = ruleDto.MaxTimeHours, IgnoreAboveSize = ruleDto.IgnoreAboveSize, DeletePrivateTorrentsFromClient = ruleDto.DeletePrivateTorrentsFromClient, + ChangeCategory = ruleDto.ChangeCategory, }; var existingRules = await _dataContext.SlowRules diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs index b29c7d8d..4d6488cf 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs @@ -74,11 +74,11 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -119,11 +119,11 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -211,11 +211,11 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -356,7 +356,7 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -397,7 +397,7 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -445,13 +445,14 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.SlowSpeed, true)); + .Returns((true, DeleteReason.SlowSpeed, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); } [Fact] @@ -488,13 +489,57 @@ public class DelugeServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var downloadStatus = new DownloadStatus + { + Hash = hash, + Name = "Test Torrent", + State = DelugeState.Downloading, + Private = false, + DownloadSpeed = 1000, + Trackers = new List(), + DownloadLocation = "/downloads" + }; + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(downloadStatus); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } } + } + }); + + _fixture.RuleEvaluator + .EvaluateSlowRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.SlowSpeed, false, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); + result.DeleteFromClient.ShouldBeFalse(); + result.ChangeCategory.ShouldBeTrue(); } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs index ba8c9745..6a24e4b9 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -135,11 +135,11 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -193,11 +193,11 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -401,11 +401,11 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -654,7 +654,7 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -709,7 +709,7 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -764,7 +764,7 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.SlowSpeed, true)); // Rule matched + .Returns((true, DeleteReason.SlowSpeed, true, false)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -773,6 +773,63 @@ public class QBitServiceTests : IClassFixture result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000 + }; + + _fixture.ClientWrapper + .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) + .Returns(new[] { torrentInfo }); + + _fixture.ClientWrapper + .GetTorrentTrackersAsync(hash) + .Returns(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .GetTorrentPropertiesAsync(hash) + .Returns(properties); + + _fixture.ClientWrapper + .GetTorrentContentsAsync(hash) + .Returns(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .EvaluateSlowRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.SlowSpeed, false, true)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); + result.DeleteFromClient.ShouldBeFalse(); + result.ChangeCategory.ShouldBeTrue(); } } @@ -826,7 +883,7 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -880,7 +937,7 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); // Rule matched + .Returns((true, DeleteReason.Stalled, true, false)); // Rule matched // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -889,6 +946,62 @@ public class QBitServiceTests : IClassFixture result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task StalledDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + // Arrange + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Hash = hash, + Name = "Test Torrent", + State = TorrentState.StalledDownload + }; + + _fixture.ClientWrapper + .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) + .Returns(new[] { torrentInfo }); + + _fixture.ClientWrapper + .GetTorrentTrackersAsync(hash) + .Returns(Array.Empty()); + + var properties = new TorrentProperties + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(false) } + } + }; + + _fixture.ClientWrapper + .GetTorrentPropertiesAsync(hash) + .Returns(properties); + + _fixture.ClientWrapper + .GetTorrentContentsAsync(hash) + .Returns(new[] + { + new TorrentContent { Index = 0, Priority = TorrentContentPriority.Normal } + }); + + _fixture.RuleEvaluator + .EvaluateStallRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.Stalled, true, true)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.Stalled); + result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeTrue(); } } @@ -943,7 +1056,7 @@ public class QBitServiceTests : IClassFixture // Slow check is skipped because not in downloading state _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -1001,11 +1114,11 @@ public class QBitServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs index fe67b566..ef0ac2d4 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs @@ -132,11 +132,11 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -182,11 +182,11 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -300,11 +300,11 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -399,7 +399,7 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -447,7 +447,7 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -495,7 +495,7 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.SlowSpeed, true)); + .Returns((true, DeleteReason.SlowSpeed, true, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -504,6 +504,55 @@ public class RTorrentServiceTests : IClassFixture result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + // Arrange + const string hash = "TEST-HASH"; + var sut = _fixture.CreateSut(); + + var download = new RTorrentTorrent + { + Hash = hash, + Name = "Test Torrent", + IsPrivate = 0, + State = 1, + Complete = 0, + DownRate = 1000, + SizeBytes = 1000, + CompletedBytes = 500 + }; + + _fixture.ClientWrapper + .GetTorrentAsync(hash) + .Returns(download); + + _fixture.ClientWrapper + .GetTrackersAsync(hash) + .Returns(new List()); + + _fixture.ClientWrapper + .GetTorrentFilesAsync(hash) + .Returns(new List + { + new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 } + }); + + _fixture.RuleEvaluator + .EvaluateSlowRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.SlowSpeed, false, true)); + + // Act + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + // Assert + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); + result.DeleteFromClient.ShouldBeFalse(); + result.ChangeCategory.ShouldBeTrue(); } } @@ -550,7 +599,7 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -598,7 +647,7 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -607,6 +656,7 @@ public class RTorrentServiceTests : IClassFixture result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); } } @@ -654,7 +704,7 @@ public class RTorrentServiceTests : IClassFixture // Slow check is skipped because speed is 0 _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -704,11 +754,11 @@ public class RTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); // Act var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs index 73797db5..ec2f188c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs @@ -108,11 +108,11 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -167,11 +167,11 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -294,11 +294,11 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -476,11 +476,11 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -542,7 +542,7 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -598,7 +598,7 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -661,13 +661,14 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((true, DeleteReason.SlowSpeed, true)); + .Returns((true, DeleteReason.SlowSpeed, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); } [Fact] @@ -719,13 +720,52 @@ public class TransmissionServiceTests : IClassFixture()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentInfo = new TorrentInfo + { + Id = 1, + HashString = hash, + Name = "Test Torrent", + Status = 4, + IsPrivate = false, + RateDownload = 1000, + FileStats = new[] { new TransmissionTorrentFileStats { Wanted = true } } + }; + + var torrents = new TransmissionTorrents + { + Torrents = new[] { torrentInfo } + }; + + _fixture.ClientWrapper + .TorrentGetAsync(Arg.Any(), hash) + .Returns(torrents); + + _fixture.RuleEvaluator + .EvaluateSlowRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.SlowSpeed, false, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); + result.DeleteFromClient.ShouldBeFalse(); + result.ChangeCategory.ShouldBeTrue(); } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs index deb946a4..d8bc671e 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs @@ -79,11 +79,11 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -129,11 +129,11 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -231,11 +231,11 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -399,11 +399,11 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -456,7 +456,7 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -502,7 +502,7 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((false, DeleteReason.None, false)); + .Returns((false, DeleteReason.None, false, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); @@ -555,13 +555,14 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateSlowRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.SlowSpeed, true)); + .Returns((true, DeleteReason.SlowSpeed, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); } [Fact] @@ -603,13 +604,111 @@ public class UTorrentServiceTests : IClassFixture _fixture.RuleEvaluator .EvaluateStallRulesAsync(Arg.Any()) - .Returns((true, DeleteReason.Stalled, true)); + .Returns((true, DeleteReason.Stalled, true, false)); var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); result.ShouldRemove.ShouldBeTrue(); result.DeleteReason.ShouldBe(DeleteReason.Stalled); result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task SlowDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentItem = new UTorrentItem + { + Hash = hash, + Name = "Test Torrent", + Status = 9, + DownloadSpeed = 1000 + }; + + var torrentProperties = new UTorrentProperties + { + Hash = hash, + Pex = 1, + Trackers = "" + }; + + _fixture.ClientWrapper + .GetTorrentAsync(hash) + .Returns(torrentItem); + + _fixture.ClientWrapper + .GetTorrentPropertiesAsync(hash) + .Returns(torrentProperties); + + _fixture.ClientWrapper + .GetTorrentFilesAsync(hash) + .Returns(new List + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .EvaluateSlowRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.SlowSpeed, false, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed); + result.DeleteFromClient.ShouldBeFalse(); + result.ChangeCategory.ShouldBeTrue(); + } + + [Fact] + public async Task StalledDownload_RuleWithChangeCategory_PropagatesChangeCategoryFlag() + { + const string hash = "test-hash"; + var sut = _fixture.CreateSut(); + + var torrentItem = new UTorrentItem + { + Hash = hash, + Name = "Test Torrent", + Status = 9, + DownloadSpeed = 0, + ETA = 0 + }; + + var torrentProperties = new UTorrentProperties + { + Hash = hash, + Pex = 1, + Trackers = "" + }; + + _fixture.ClientWrapper + .GetTorrentAsync(hash) + .Returns(torrentItem); + + _fixture.ClientWrapper + .GetTorrentPropertiesAsync(hash) + .Returns(torrentProperties); + + _fixture.ClientWrapper + .GetTorrentFilesAsync(hash) + .Returns(new List + { + new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 } + }); + + _fixture.RuleEvaluator + .EvaluateStallRulesAsync(Arg.Any()) + .Returns((true, DeleteReason.Stalled, true, true)); + + var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty()); + + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.Stalled); + result.DeleteFromClient.ShouldBeTrue(); + result.ChangeCategory.ShouldBeTrue(); } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs index 93b63a51..5b06fa6a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/QueueItemRemoverTests.cs @@ -117,6 +117,7 @@ public class QueueItemRemoverTests : IDisposable request.Instance, request.Record, request.RemoveFromClient, + request.ChangeCategory, request.DeleteReason); } @@ -288,6 +289,7 @@ public class QueueItemRemoverTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); @@ -312,6 +314,7 @@ public class QueueItemRemoverTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound)); @@ -335,6 +338,7 @@ public class QueueItemRemoverTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(originalException); @@ -357,6 +361,7 @@ public class QueueItemRemoverTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()) .ThrowsAsync(originalException); @@ -390,6 +395,7 @@ public class QueueItemRemoverTests : IDisposable Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), deleteReason); } @@ -408,7 +414,28 @@ public class QueueItemRemoverTests : IDisposable await _arrClient.Received(1).DeleteQueueItemAsync( Arg.Any(), Arg.Any(), - removeFromClient, + Arg.Is(x => x == removeFromClient), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RemoveQueueItemAsync_PassesCorrectChangeCategoryFlag(bool changeCategory) + { + // Arrange + var request = CreateRemoveRequest(changeCategory: changeCategory); + + // Act + await _queueItemRemover.RemoveQueueItemAsync(request); + + // Assert + await _arrClient.Received(1).DeleteQueueItemAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(x => x == changeCategory), Arg.Any()); } @@ -420,7 +447,8 @@ public class QueueItemRemoverTests : IDisposable InstanceType instanceType = InstanceType.Sonarr, bool removeFromClient = true, DeleteReason deleteReason = DeleteReason.Stalled, - bool skipSearch = false) + bool skipSearch = false, + bool changeCategory = false) { // Use an ArrInstance that exists in the DB to satisfy FK constraint on SearchQueueItem var instance = GetOrCreateArrInstance(instanceType); @@ -431,6 +459,7 @@ public class QueueItemRemoverTests : IDisposable SearchItem = new SearchItem { Id = 123 }, Record = CreateQueueRecord(), RemoveFromClient = removeFromClient, + ChangeCategory = changeCategory, DeleteReason = deleteReason, SkipSearch = skipSearch, JobRunId = _jobRunId diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs index 3c3488b6..3ae04c00 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs @@ -88,7 +88,7 @@ public class MalwareBlockerIntegrationTests : IDisposable // Process through real QueueItemRemover _fixture.ArrClient.DeleteQueueItemAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -98,6 +98,7 @@ public class MalwareBlockerIntegrationTests : IDisposable Arg.Is(i => i.Id == instance.Id), Arg.Is(r => r.DownloadId == record.DownloadId), true, + false, DeleteReason.AllFilesBlocked); // Assert: Full event property verification @@ -215,7 +216,7 @@ public class MalwareBlockerIntegrationTests : IDisposable _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); _fixture.ArrClient.DeleteQueueItemAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -224,6 +225,7 @@ public class MalwareBlockerIntegrationTests : IDisposable Arg.Any(), Arg.Any(), false, + false, DeleteReason.AllFilesBlocked); // Full event property verification diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs index 48402512..dad28656 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs @@ -81,7 +81,7 @@ public class QueueCleanerIntegrationTests : IDisposable // Process the captured messages through the real QueueItemRemover pipeline _fixture.ArrClient.DeleteQueueItemAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -91,6 +91,7 @@ public class QueueCleanerIntegrationTests : IDisposable Arg.Is(i => i.Id == instance.Id), Arg.Is(r => r.DownloadId == record.DownloadId), true, + false, DeleteReason.Stalled); // Assert Phase 3: Events persisted with full property verification @@ -185,7 +186,7 @@ public class QueueCleanerIntegrationTests : IDisposable _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); _fixture.ArrClient.DeleteQueueItemAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -280,7 +281,7 @@ public class QueueCleanerIntegrationTests : IDisposable _fixture.GetCapturedRemoveRequests().Count.ShouldBe(1); _fixture.ArrClient.DeleteQueueItemAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); await _fixture.ProcessCapturedRemoveRequestsAsync(); @@ -290,6 +291,7 @@ public class QueueCleanerIntegrationTests : IDisposable Arg.Any(), Arg.Any(), false, + false, DeleteReason.Stalled); // Full event property verification diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs index d186e5a2..e32cba8a 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs @@ -1334,4 +1334,163 @@ public class QueueCleanerTests : IDisposable } #endregion + + #region ChangeCategory Tests + + [Fact] + public async Task ProcessInstanceAsync_WhenFailedImportWithChangeCategory_PublishesRequestWithChangeCategoryAndRemoveFromClientFalse() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var queueCleanerConfig = _fixture.DataContext.QueueCleanerConfigs.First(); + // Set DeletePrivate = true so RemoveFromClient would be true without the ChangeCategory override. + // This makes the RemoveFromClient == false assertion below conclusive. + queueCleanerConfig.FailedImport = queueCleanerConfig.FailedImport with { ChangeCategory = true, DeletePrivate = false }; + // Validate gate prevents both flags being true at once; we keep DeletePrivate=false here, but rely on + // IsPrivate=false from the mock so removeFromClient resolves to !changeCategory. + _fixture.DataContext.SaveChanges(); + + var mockArrClient = Substitute.For(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + mockArrClient.ShouldRemoveFromQueue( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ).Returns(true); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "failed-import-change-category", + Title = "Failed Import Change Category", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueIterator + .Iterate( + Arg.Any(), + Arg.Any(), + Arg.Any, Task>>() + ) + .Returns(async ci => + { + var callback = ci.ArgAt, Task>>(2); + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .ShouldRemoveFromArrQueueAsync( + Arg.Any(), + Arg.Any>() + ) + // IsPrivate=false ensures the failed-import path computes + // removeFromClient = !changeCategory && (!IsPrivate || DeletePrivate) = !changeCategory && true. + // So RemoveFromClient == false in the assertion is only satisfiable due to changeCategory=true. + .Returns(new DownloadCheckResult { Found = true, ShouldRemove = false, IsPrivate = false }); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _fixture.MessageBus.Received(1).Publish( + Arg.Is>(r => + r.DeleteReason == DeleteReason.FailedImport && + r.ChangeCategory == true && + r.RemoveFromClient == false + ), + Arg.Any() + ); + } + + [Fact] + public async Task ProcessInstanceAsync_WhenStallRuleHasChangeCategory_PublishesRequestWithChangeCategoryAndRemoveFromClientFalse() + { + // Arrange + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + + var mockArrClient = Substitute.For(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + var queueRecord = new QueueRecord + { + Id = 1, + DownloadId = "stall-change-category", + Title = "Stall Change Category", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueIterator + .Iterate( + Arg.Any(), + Arg.Any(), + Arg.Any, Task>>() + ) + .Returns(async ci => + { + var callback = ci.ArgAt, Task>>(2); + await callback([queueRecord]); + }); + + var mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .ShouldRemoveFromArrQueueAsync( + Arg.Any(), + Arg.Any>() + ) + .Returns(new DownloadCheckResult + { + Found = true, + ShouldRemove = true, + IsPrivate = true, + DeleteFromClient = true, + ChangeCategory = true, + DeleteReason = DeleteReason.Stalled, + }); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + var sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _fixture.MessageBus.Received(1).Publish( + Arg.Is>(r => + r.DeleteReason == DeleteReason.Stalled && + r.ChangeCategory == true && + r.RemoveFromClient == false + ), + Arg.Any() + ); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs index ac0ebf4d..9b7dfbb2 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/QueueRuleEvaluatorTests.cs @@ -694,7 +694,13 @@ public class QueueRuleEvaluatorTests : IDisposable return torrent; } - private static StallRule CreateStallRule(string name, bool resetOnProgress, int maxStrikes, string? minimumProgress = null, bool deletePrivateTorrentsFromClient = false) + private static StallRule CreateStallRule( + string name, + bool resetOnProgress, + int maxStrikes, + string? minimumProgress = null, + bool deletePrivateTorrentsFromClient = false, + bool changeCategory = false) { return new StallRule { @@ -709,6 +715,7 @@ public class QueueRuleEvaluatorTests : IDisposable ResetStrikesOnProgress = resetOnProgress, MinimumProgress = minimumProgress, DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient, + ChangeCategory = changeCategory, }; } @@ -718,7 +725,8 @@ public class QueueRuleEvaluatorTests : IDisposable int maxStrikes, string? minSpeed = null, double maxTimeHours = 1, - bool deletePrivateTorrentsFromClient = false) + bool deletePrivateTorrentsFromClient = false, + bool changeCategory = false) { return new SlowRule { @@ -735,6 +743,7 @@ public class QueueRuleEvaluatorTests : IDisposable MinSpeed = minSpeed ?? string.Empty, IgnoreAboveSize = string.Empty, DeletePrivateTorrentsFromClient = deletePrivateTorrentsFromClient, + ChangeCategory = changeCategory, }; } @@ -993,4 +1002,140 @@ public class QueueRuleEvaluatorTests : IDisposable result.Reason.ShouldBe(DeleteReason.None); result.DeleteFromClient.ShouldBeFalse(); } + + #region ChangeCategory Tests + + [Fact] + public async Task EvaluateStallRulesAsync_WhenRuleMatchesWithChangeCategory_ShouldReturnChangeCategoryTrueAndDeleteFromClientFalse() + { + var ruleManager = Substitute.For(); + var striker = Substitute.For(); + var logger = Substitute.For>(); + var context = CreateInMemoryEventsContext(); + + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + + var stallRule = CreateStallRule("Stall Change Category", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: true, changeCategory: true); + + ruleManager + .GetMatchingStallRule(Arg.Any()) + .Returns(stallRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.Stalled, Arg.Any()) + .Returns(true); + + var torrent = CreateTorrentMock(); + + var result = await evaluator.EvaluateStallRulesAsync(torrent); + + result.ShouldRemove.ShouldBeTrue(); + result.Reason.ShouldBe(DeleteReason.Stalled); + result.ChangeCategory.ShouldBeTrue(); + result.DeleteFromClient.ShouldBeFalse(); + } + + [Fact] + public async Task EvaluateStallRulesAsync_WhenRuleMatchesWithoutChangeCategory_ShouldReturnChangeCategoryFalse() + { + var ruleManager = Substitute.For(); + var striker = Substitute.For(); + var logger = Substitute.For>(); + var context = CreateInMemoryEventsContext(); + + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + + var stallRule = CreateStallRule("Stall Default", resetOnProgress: false, maxStrikes: 3, deletePrivateTorrentsFromClient: false, changeCategory: false); + + ruleManager + .GetMatchingStallRule(Arg.Any()) + .Returns(stallRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.Stalled, Arg.Any()) + .Returns(true); + + var torrent = CreateTorrentMock(); + + var result = await evaluator.EvaluateStallRulesAsync(torrent); + + result.ShouldRemove.ShouldBeTrue(); + result.ChangeCategory.ShouldBeFalse(); + } + + [Fact] + public async Task EvaluateSlowRulesAsync_WhenSpeedRuleMatchesWithChangeCategory_ShouldReturnChangeCategoryTrueAndDeleteFromClientFalse() + { + var ruleManager = Substitute.For(); + var striker = Substitute.For(); + var logger = Substitute.For>(); + var context = CreateInMemoryEventsContext(); + + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + + var slowRule = CreateSlowRule( + "Slow Speed Change Category", + resetOnProgress: false, + maxStrikes: 3, + minSpeed: "5 MB", + maxTimeHours: 0, + deletePrivateTorrentsFromClient: true, + changeCategory: true); + + ruleManager + .GetMatchingSlowRule(Arg.Any()) + .Returns(slowRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.SlowSpeed, Arg.Any()) + .Returns(true); + + var torrent = CreateTorrentMock(); + torrent.DownloadSpeed.Returns(ByteSize.Parse("1 MB").Bytes); + + var result = await evaluator.EvaluateSlowRulesAsync(torrent); + + result.ShouldRemove.ShouldBeTrue(); + result.Reason.ShouldBe(DeleteReason.SlowSpeed); + result.ChangeCategory.ShouldBeTrue(); + result.DeleteFromClient.ShouldBeFalse(); + } + + [Fact] + public async Task EvaluateSlowRulesAsync_WhenTimeRuleMatchesWithChangeCategory_ShouldReturnChangeCategoryTrueAndDeleteFromClientFalse() + { + var ruleManager = Substitute.For(); + var striker = Substitute.For(); + var logger = Substitute.For>(); + var context = CreateInMemoryEventsContext(); + + var evaluator = new QueueRuleEvaluator(ruleManager, striker, context, logger); + + var slowRule = CreateSlowRule( + "Slow Time Change Category", + resetOnProgress: false, + maxStrikes: 3, + maxTimeHours: 1, + deletePrivateTorrentsFromClient: true, + changeCategory: true); + + ruleManager + .GetMatchingSlowRule(Arg.Any()) + .Returns(slowRule); + + striker + .StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), StrikeType.SlowTime, Arg.Any()) + .Returns(true); + + var torrent = CreateTorrentMock(); + + var result = await evaluator.EvaluateSlowRulesAsync(torrent); + + result.ShouldRemove.ShouldBeTrue(); + result.Reason.ShouldBe(DeleteReason.SlowTime); + result.ChangeCategory.ShouldBeTrue(); + result.DeleteFromClient.ShouldBeFalse(); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs index ec35989a..27a9c54b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ArrClient.cs @@ -163,12 +163,13 @@ public abstract class ArrClient : IArrClient ArrInstance arrInstance, QueueRecord record, bool removeFromClient, + bool changeCategory, DeleteReason deleteReason ) { UriBuilder uriBuilder = new(arrInstance.Url); uriBuilder.Path = $"{uriBuilder.Path.TrimEnd('/')}/{GetQueueDeleteUrlPath(record.Id).TrimStart('/')}"; - uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient); + uriBuilder.Query = GetQueueDeleteUrlQuery(removeFromClient, changeCategory); try { @@ -177,11 +178,23 @@ public abstract class ArrClient : IArrClient HttpResponseMessage? response = await _dryRunInterceptor.InterceptAsync(SendRequestAsync, request); response?.Dispose(); - + + string logMessage; + if (changeCategory) + { + logMessage = "queue item category changed in arr with reason {reason} | {url} | {title}"; + } + else if (removeFromClient) + { + logMessage = "queue item deleted with reason {reason} | {url} | {title}"; + } + else + { + logMessage = "queue item removed from arr with reason {reason} | {url} | {title}"; + } + _logger.LogInformation( - removeFromClient - ? "queue item deleted with reason {reason} | {url} | {title}" - : "queue item removed from arr with reason {reason} | {url} | {title}", + logMessage, deleteReason.ToString(), arrInstance.Url, record.Title @@ -262,7 +275,21 @@ public abstract class ArrClient : IArrClient protected abstract string GetQueueDeleteUrlPath(long recordId); - protected abstract string GetQueueDeleteUrlQuery(bool removeFromClient); + protected virtual string GetQueueDeleteUrlQuery(bool removeFromClient, bool changeCategory) + { + string query = "blocklist=true&skipRedownload=true&"; + + if (changeCategory) + { + query += "changeCategory=true&removeFromClient=false"; + return query; + } + + query += "changeCategory=false"; + query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; + + return query; + } protected virtual void SetApiKey(HttpRequestMessage request, string apiKey) { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs index 13b12e81..80144ef6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/Interfaces/IArrClient.cs @@ -11,7 +11,15 @@ public interface IArrClient Task ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes); - Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason); + /// + /// Removes a queue item from the *arr instance. + /// + /// The *arr instance hosting the queue item. + /// The queue record to remove. + /// When true, also delete the download from the download client. Ignored when is true. + /// When true, instructs the *arr to change the download's category to the post-import category instead of removing it from the download client. Mutually exclusive with . + /// Reason for removal, used for logging and event publishing. + Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason); /// /// Triggers a search for the specified items and returns the arr command IDs diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs index 11073d2e..41f6d413 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/LidarrClient.cs @@ -42,14 +42,6 @@ public class LidarrClient : ArrClient, ILidarrClient return $"/api/v1/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs index 6e2dcfe7..551e835f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/RadarrClient.cs @@ -42,14 +42,6 @@ public class RadarrClient : ArrClient, IRadarrClient return $"/api/v3/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs index 1004821d..b14eda46 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/ReadarrClient.cs @@ -42,14 +42,6 @@ public class ReadarrClient : ArrClient, IReadarrClient return $"/api/v1/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs index 518add03..58350c6f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/SonarrClient.cs @@ -44,14 +44,6 @@ public class SonarrClient : ArrClient, ISonarrClient return $"/api/v3/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs index a20eb3be..9d7e6310 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV2Client.cs @@ -44,14 +44,6 @@ public class WhisparrV2Client : ArrClient, IWhisparrV2Client return $"/api/v3/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs index b0d1b238..736af098 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Arr/WhisparrV3Client.cs @@ -43,14 +43,6 @@ public class WhisparrV3Client : ArrClient, IWhisparrV3Client return $"/api/v3/queue/{recordId}"; } - protected override string GetQueueDeleteUrlQuery(bool removeFromClient) - { - string query = "blocklist=true&skipRedownload=true&changeCategory=false"; - query += removeFromClient ? "&removeFromClient=true" : "&removeFromClient=false"; - - return query; - } - public override async Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) { if (items?.Count is null or 0) diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs index 327ff5b5..8e918f47 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceQC.cs @@ -71,14 +71,14 @@ public partial class DelugeService } // remove if download is stuck - (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent); + (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient, result.ChangeCategory) = await EvaluateDownloadRemoval(torrent); return result; } - private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper); + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); if (result.ShouldRemove) { @@ -88,29 +88,29 @@ public partial class DelugeService return await CheckIfStuck(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper 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); + 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs index 6c2b7e43..f9faa25c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadCheckResult.cs @@ -22,4 +22,9 @@ public sealed record DownloadCheckResult /// True if the download should be deleted from the client; otherwise false. /// public bool DeleteFromClient { get; set; } + + /// + /// True if the matching queue rule asked to change the category in the *arr instead of deleting; otherwise false. + /// + public bool ChangeCategory { get; set; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs index b942661b..65d30207 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceQC.cs @@ -71,14 +71,14 @@ public partial class QBitService return result; } - (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent); + (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient, result.ChangeCategory) = await EvaluateDownloadRemoval(torrent); return result; } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) slowResult = await CheckIfSlow(wrapper); + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) slowResult = await CheckIfSlow(wrapper); if (slowResult.ShouldRemove) { @@ -88,24 +88,24 @@ public partial class QBitService return await CheckIfStuck(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper 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); + 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper) + private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper) { if (((QBitItemWrapper)wrapper).IsMetadataDownloading()) { @@ -119,17 +119,17 @@ public partial class QBitService queueCleanerConfig.DownloadingMetadataMaxStrikes, StrikeType.DownloadingMetadata ); - - return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove); + + return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove, false); } - return (false, DeleteReason.None, 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs index c65bbce5..4cafd8cc 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceQC.cs @@ -62,14 +62,14 @@ public partial class RTorrentService } // remove if download is stuck - (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent); + (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient, result.ChangeCategory) = await EvaluateDownloadRemoval(torrent); return result; } - private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper); + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); if (result.ShouldRemove) { @@ -79,29 +79,29 @@ public partial class RTorrentService return await CheckIfStuck(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper 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); + 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs index 1f103424..f745d1f3 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceQC.cs @@ -66,7 +66,7 @@ public partial class TransmissionService } // remove if download is stuck - (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent); + (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient, result.ChangeCategory) = await EvaluateDownloadRemoval(torrent); return result; } @@ -80,9 +80,9 @@ public partial class TransmissionService }); } - private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper); + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); if (result.ShouldRemove) { @@ -93,29 +93,29 @@ public partial class TransmissionService } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper 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); + 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs index 4d5c2674..e0b6d83f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceQC.cs @@ -70,14 +70,14 @@ public partial class UTorrentService } // remove if download is stuck - (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent); + (result.ShouldRemove, result.DeleteReason, result.DeleteFromClient, result.ChangeCategory) = await EvaluateDownloadRemoval(torrent); return result; } - private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) + private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper) { - (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper); + (bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper); if (result.ShouldRemove) { @@ -88,29 +88,29 @@ public partial class UTorrentService } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper 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); + 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper); } - private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper 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); + return (false, DeleteReason.None, false, false); } return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs index 15b7a5af..a26fccb1 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/Models/QueueItemRemoveRequest.cs @@ -17,6 +17,12 @@ public sealed record QueueItemRemoveRequest public required bool RemoveFromClient { get; init; } + /// + /// When true, the *arr is asked to change the download's category to its post-import category + /// instead of removing it from the download client. Mutually exclusive with . + /// + public bool ChangeCategory { get; init; } + public required DeleteReason DeleteReason { get; init; } public required Guid JobRunId { get; init; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs index be21cc61..ae869a28 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadRemover/QueueItemRemover.cs @@ -51,7 +51,7 @@ public sealed class QueueItemRemover : IQueueItemRemover { var instanceType = request.Instance.ArrConfig.Type; var arrClient = _arrClientFactory.GetClient(instanceType, request.Instance.Version); - await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.DeleteReason); + await arrClient.DeleteQueueItemAsync(request.Instance, request.Record, request.RemoveFromClient, request.ChangeCategory, request.DeleteReason); // Mark the download item as removed in the database await _eventsContext.DownloadItems diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs index 155369ba..067bc62f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/GenericHandler.cs @@ -131,7 +131,8 @@ public abstract class GenericHandler : IHandler bool removeFromClient, DeleteReason deleteReason, bool skipSearch = false, - DownloadClientConfig? downloadClient = null + DownloadClientConfig? downloadClient = null, + bool changeCategory = false ) { if (_cache.TryGetValue(downloadRemovalKey, out bool _)) @@ -150,6 +151,7 @@ public abstract class GenericHandler : IHandler Record = record, SearchItem = (SeriesSearchItem)GetRecordSearchItem(instanceType, instance.Version, record, isPack), RemoveFromClient = removeFromClient, + ChangeCategory = changeCategory, DeleteReason = deleteReason, JobRunId = ContextProvider.GetJobRunId(), SkipSearch = skipSearch, @@ -166,6 +168,7 @@ public abstract class GenericHandler : IHandler Record = record, SearchItem = GetRecordSearchItem(instanceType, instance.Version, record, isPack), RemoveFromClient = removeFromClient, + ChangeCategory = changeCategory, DeleteReason = deleteReason, JobRunId = ContextProvider.GetJobRunId(), SkipSearch = skipSearch, diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs index 41ca3cc0..b74824c8 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/QueueCleaner.cs @@ -192,7 +192,8 @@ public sealed class QueueCleaner : GenericHandler if (downloadCheckResult.ShouldRemove) { - bool removeFromClient = !downloadCheckResult.IsPrivate || downloadCheckResult.DeleteFromClient; + bool changeCategory = downloadCheckResult.ChangeCategory; + bool removeFromClient = !changeCategory && (!downloadCheckResult.IsPrivate || downloadCheckResult.DeleteFromClient); await PublishQueueItemRemoveRequest( downloadRemovalKey, @@ -202,7 +203,8 @@ public sealed class QueueCleaner : GenericHandler removeFromClient, downloadCheckResult.DeleteReason, skipSearch: !hasContentId, - downloadClient: foundInClient + downloadClient: foundInClient, + changeCategory: changeCategory ); continue; @@ -221,7 +223,8 @@ public sealed class QueueCleaner : GenericHandler if (shouldRemoveFromArr) { - bool removeFromClient = !downloadCheckResult.IsPrivate || queueCleanerConfig.FailedImport.DeletePrivate; + bool changeCategory = queueCleanerConfig.FailedImport.ChangeCategory; + bool removeFromClient = !changeCategory && (!downloadCheckResult.IsPrivate || queueCleanerConfig.FailedImport.DeletePrivate); await PublishQueueItemRemoveRequest( downloadRemovalKey, @@ -231,7 +234,8 @@ public sealed class QueueCleaner : GenericHandler removeFromClient, DeleteReason.FailedImport, skipSearch: !hasContentId, - downloadClient: foundInClient + downloadClient: foundInClient, + changeCategory: changeCategory ); continue; diff --git a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IQueueRuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IQueueRuleEvaluator.cs index 06e73151..5e249073 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IQueueRuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/Interfaces/IQueueRuleEvaluator.cs @@ -6,6 +6,6 @@ namespace Cleanuparr.Infrastructure.Services.Interfaces; public interface IQueueRuleEvaluator { - Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent); - Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent); + Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent); + Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent); } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs index a3de4016..d5509a88 100644 --- a/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs +++ b/code/backend/Cleanuparr.Infrastructure/Services/QueueRuleEvaluator.cs @@ -29,7 +29,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator _logger = logger; } - public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent) + public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateStallRulesAsync(ITorrentItemWrapper torrent) { _logger.LogTrace("Evaluating stall rules | {name}", torrent.Name); @@ -38,7 +38,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (rule is null) { _logger.LogTrace("skip | no stall rules matched | {name}", torrent.Name); - return (false, DeleteReason.None, false); + return (false, DeleteReason.None, false, false); } _logger.LogTrace("Applying stall rule {rule} | {name}", rule.Name, torrent.Name); @@ -61,13 +61,14 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (shouldRemove) { - return (true, DeleteReason.Stalled, rule.DeletePrivateTorrentsFromClient); + bool deleteFromClient = rule is { ChangeCategory: false, DeletePrivateTorrentsFromClient: true }; + return (true, DeleteReason.Stalled, deleteFromClient, rule.ChangeCategory); } - return (false, DeleteReason.None, false); + return (false, DeleteReason.None, false, false); } - public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent) + public async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateSlowRulesAsync(ITorrentItemWrapper torrent) { _logger.LogTrace("Evaluating slow rules | {name}", torrent.Name); @@ -76,7 +77,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (rule is null) { _logger.LogDebug("skip | no slow rules matched | {name}", torrent.Name); - return (false, DeleteReason.None, false); + return (false, DeleteReason.None, false, false); } _logger.LogTrace("Applying slow rule {rule} | {name}", rule.Name, torrent.Name); @@ -97,7 +98,8 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (shouldRemove) { - return (true, DeleteReason.SlowSpeed, rule.DeletePrivateTorrentsFromClient); + bool deleteFromClient = rule is { ChangeCategory: false, DeletePrivateTorrentsFromClient: true }; + return (true, DeleteReason.SlowSpeed, deleteFromClient, rule.ChangeCategory); } } else @@ -121,7 +123,8 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator if (shouldRemove) { - return (true, DeleteReason.SlowTime, rule.DeletePrivateTorrentsFromClient); + bool deleteFromClient = rule is { ChangeCategory: false, DeletePrivateTorrentsFromClient: true }; + return (true, DeleteReason.SlowTime, deleteFromClient, rule.ChangeCategory); } } else @@ -130,7 +133,7 @@ public class QueueRuleEvaluator : IQueueRuleEvaluator } } - return (false, DeleteReason.None, false); + return (false, DeleteReason.None, false, false); } private async Task ResetStalledStrikesAsync( diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs index 92e5e3b8..ce022d7e 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/FailedImportConfigTests.cs @@ -167,4 +167,77 @@ public sealed class FailedImportConfigTests } #endregion + + #region Validate - ChangeCategory Validation + + [Fact] + public void Validate_WithChangeCategoryDefault_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryAndDeletePrivateBothFalse_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + ChangeCategory = false, + DeletePrivate = false, + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryTrueAndDeletePrivateFalse_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + ChangeCategory = true, + DeletePrivate = false, + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryFalseAndDeletePrivateTrue_DoesNotThrow() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + ChangeCategory = false, + DeletePrivate = true, + }; + + Should.NotThrow(() => config.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryAndDeletePrivateBothTrue_ThrowsValidationException() + { + var config = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + ChangeCategory = true, + DeletePrivate = true, + }; + + var exception = Should.Throw(() => config.Validate()); + exception.Message.ShouldBe("Cannot enable both deletion and category changing"); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs index bbbe95d6..c71a0484 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/QueueCleaner/QueueRuleTests.cs @@ -291,4 +291,63 @@ public sealed class QueueRuleTests } #endregion + + #region Validate - ChangeCategory Validation + + [Fact] + public void Validate_WithChangeCategoryDefault_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryTrueAndDeletePrivateFromClientFalse_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + ChangeCategory = true, + DeletePrivateTorrentsFromClient = false, + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryFalseAndDeletePrivateFromClientTrue_DoesNotThrow() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + ChangeCategory = false, + DeletePrivateTorrentsFromClient = true, + }; + + Should.NotThrow(() => rule.Validate()); + } + + [Fact] + public void Validate_WithChangeCategoryAndDeletePrivateFromClientBothTrue_ThrowsValidationException() + { + var rule = new StallRule + { + Name = "test-rule", + MaxStrikes = 3, + ChangeCategory = true, + DeletePrivateTorrentsFromClient = true, + }; + + var exception = Should.Throw(() => rule.Validate()); + exception.Message.ShouldBe("Cannot enable both deletion and category changing"); + } + + #endregion } diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.Designer.cs new file mode 100644 index 00000000..38b79c9f --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.Designer.cs @@ -0,0 +1,2120 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260504193343_AddQueueCleanerChangeCategory")] + partial class AddQueueCleanerChangeCategory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.cs new file mode 100644 index 00000000..b1d34c00 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260504193343_AddQueueCleanerChangeCategory.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddQueueCleanerChangeCategory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "change_category", + table: "stall_rules", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "change_category", + table: "slow_rules", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "failed_import_change_category", + table: "queue_cleaner_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "change_category", + table: "stall_rules"); + + migrationBuilder.DropColumn( + name: "change_category", + table: "slow_rules"); + + migrationBuilder.DropColumn( + name: "failed_import_change_category", + table: "queue_cleaner_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 436455a1..08c952a8 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -1235,6 +1235,10 @@ namespace Cleanuparr.Persistence.Migrations.Data { b1.IsRequired(); + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + b1.Property("DeletePrivate") .HasColumnType("INTEGER") .HasColumnName("failed_import_delete_private"); @@ -1275,6 +1279,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + b.Property("DeletePrivateTorrentsFromClient") .HasColumnType("INTEGER") .HasColumnName("delete_private_torrents_from_client"); @@ -1342,6 +1350,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("id"); + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + b.Property("DeletePrivateTorrentsFromClient") .HasColumnType("INTEGER") .HasColumnName("delete_private_torrents_from_client"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs index bce94e1e..20829f8d 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/FailedImportConfig.cs @@ -19,7 +19,9 @@ public sealed record FailedImportConfig public IReadOnlyList Patterns { get; init; } = []; public PatternMode PatternMode { get; init; } = PatternMode.Include; - + + public bool ChangeCategory { get; init; } + public void Validate() { if (MaxStrikes is > 0 and < 3) @@ -31,5 +33,10 @@ public sealed record FailedImportConfig { throw new ValidationException("At least one pattern must be specified when using the Include pattern mode"); } + + if (ChangeCategory && DeletePrivate) + { + throw new ValidationException("Cannot enable both deletion and category changing"); + } } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs index 3725626c..84beb4d7 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/QueueCleaner/QueueRule.cs @@ -28,7 +28,9 @@ public abstract record QueueRule : IConfig, IQueueRule public ushort MaxCompletionPercentage { get; init; } = 100; public bool DeletePrivateTorrentsFromClient { get; init; } = false; - + + public bool ChangeCategory { get; init; } = false; + public abstract bool MatchesTorrent(ITorrentItemWrapper torrent); public virtual void Validate() @@ -62,6 +64,11 @@ public abstract record QueueRule : IConfig, IQueueRule { throw new Cleanuparr.Domain.Exceptions.ValidationException("Maximum completion percentage must be greater than or equal to the minimum completion percentage"); } + + if (ChangeCategory && DeletePrivateTorrentsFromClient) + { + throw new Cleanuparr.Domain.Exceptions.ValidationException("Cannot enable both deletion and category changing"); + } } protected bool MatchesPrivacyType(bool isPrivate) diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index a9570868..b555203c 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -19,6 +19,7 @@ export class DocumentationService { 'failedImport.maxStrikes': 'failed-import-max-strikes', 'failedImport.ignorePrivate': 'failed-import-ignore-private', 'failedImport.deletePrivate': 'failed-import-delete-private', + 'failedImport.changeCategory': 'failed-import-change-category', 'failedImport.skipIfNotFoundInClient': 'failed-import-skip-if-not-found-in-client', 'failedImport.pattern-mode': 'failed-import-pattern-mode', 'failedImport.patterns': 'failed-import-patterns', @@ -31,6 +32,7 @@ export class DocumentationService { 'stallRule.resetStrikesOnProgress': 'stalled-reset-strikes-on-progress', 'stallRule.minimumProgress': 'stalled-minimum-progress-to-reset', 'stallRule.deletePrivateTorrentsFromClient': 'stalled-delete-private-from-client', + 'stallRule.changeCategory': 'stalled-change-category', 'slowRule.name': 'slow-rule-name', 'slowRule.enabled': 'slow-enabled', 'slowRule.maxStrikes': 'slow-max-strikes', @@ -41,6 +43,7 @@ export class DocumentationService { 'slowRule.ignoreAboveSize': 'slow-ignore-above-size', 'slowRule.resetStrikesOnProgress': 'slow-reset-strikes-on-progress', 'slowRule.deletePrivateTorrentsFromClient': 'slow-delete-private-from-client', + 'slowRule.changeCategory': 'slow-change-category', }, 'general': { 'displaySupportBanner': 'display-support-banner', diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html index a4f22045..60a9f60b 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.html @@ -72,10 +72,16 @@ [disabled]="failedSubFieldsDisabled()" hint="When enabled, private torrents will not be checked for being failed imports" helpKey="queue-cleaner:failedImport.ignorePrivate" /> - + + @if (!failedChangeCategory()) { + + } } - + + @if (!stallChangeCategory()) { + + }
Cancel @@ -326,10 +337,15 @@ - + + @if (!slowChangeCategory()) { + + }
Cancel diff --git a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts index 7bc99166..2903a72c 100644 --- a/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts +++ b/code/frontend/src/app/features/settings/queue-cleaner/queue-cleaner.component.ts @@ -98,6 +98,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { readonly failedSkipNotFound = signal(false); readonly failedPatterns = signal([]); readonly failedPatternMode = signal(PatternMode.Exclude); + readonly failedChangeCategory = signal(false); readonly failedExpanded = signal(true); // Metadata @@ -121,6 +122,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { readonly stallResetOnProgress = signal(false); readonly stallMinProgress = signal(''); readonly stallDeletePrivate = signal(false); + readonly stallChangeCategory = signal(false); // Slow rules readonly slowRules = signal([]); @@ -141,6 +143,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { readonly slowIgnoreAboveSize = signal(''); readonly slowResetOnProgress = signal(false); readonly slowDeletePrivate = signal(false); + readonly slowChangeCategory = signal(false); constructor() { effect(() => { @@ -158,6 +161,24 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { untracked(() => this.failedDeletePrivate.set(false)); } }); + + effect(() => { + if (this.failedChangeCategory()) { + untracked(() => this.failedDeletePrivate.set(false)); + } + }); + + effect(() => { + if (this.stallChangeCategory()) { + untracked(() => this.stallDeletePrivate.set(false)); + } + }); + + effect(() => { + if (this.slowChangeCategory()) { + untracked(() => this.slowDeletePrivate.set(false)); + } + }); } // Validation @@ -300,6 +321,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { this.failedSkipNotFound.set(config.failedImport.skipIfNotFoundInClient); this.failedPatterns.set(config.failedImport.patterns ?? []); this.failedPatternMode.set(config.failedImport.patternMode ?? PatternMode.Exclude); + this.failedChangeCategory.set(config.failedImport.changeCategory ?? false); this.metadataMaxStrikes.set(config.downloadingMetadataMaxStrikes); this.loader.stop(); this.savedSnapshot.set(this.buildSnapshot()); @@ -360,6 +382,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { this.stallResetOnProgress.set(rule.resetStrikesOnProgress); this.stallMinProgress.set(rule.minimumProgress ?? ''); this.stallDeletePrivate.set(rule.deletePrivateTorrentsFromClient); + this.stallChangeCategory.set(rule.changeCategory ?? false); } else { this.stallName.set(''); this.stallEnabled.set(true); @@ -370,6 +393,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { this.stallResetOnProgress.set(false); this.stallMinProgress.set(''); this.stallDeletePrivate.set(false); + this.stallChangeCategory.set(false); } this.stallModalVisible.set(true); } @@ -377,6 +401,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { saveStallRule(): void { if (this.stallNameError() || this.stallMaxStrikesError() || this.stallCompletionError()) return; + const changeCategory = this.stallChangeCategory(); const dto: CreateStallRuleDto = { name: this.stallName().trim(), enabled: this.stallEnabled(), @@ -386,7 +411,8 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { maxCompletionPercentage: this.stallMaxCompletion() ?? 100, resetStrikesOnProgress: this.stallResetOnProgress(), minimumProgress: this.stallMinProgress().trim() || null, - deletePrivateTorrentsFromClient: this.stallDeletePrivate(), + deletePrivateTorrentsFromClient: changeCategory ? false : this.stallDeletePrivate(), + changeCategory, }; const editing = this.editingStallRule(); @@ -436,6 +462,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { this.slowIgnoreAboveSize.set(rule.ignoreAboveSize ?? ''); this.slowResetOnProgress.set(rule.resetStrikesOnProgress); this.slowDeletePrivate.set(rule.deletePrivateTorrentsFromClient); + this.slowChangeCategory.set(rule.changeCategory ?? false); } else { this.slowName.set(''); this.slowEnabled.set(true); @@ -448,6 +475,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { this.slowIgnoreAboveSize.set(''); this.slowResetOnProgress.set(false); this.slowDeletePrivate.set(false); + this.slowChangeCategory.set(false); } this.slowModalVisible.set(true); } @@ -455,6 +483,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { saveSlowRule(): void { if (this.slowNameError() || this.slowMaxStrikesError() || this.slowCompletionError()) return; + const changeCategory = this.slowChangeCategory(); const dto: CreateSlowRuleDto = { name: this.slowName().trim(), enabled: this.slowEnabled(), @@ -466,7 +495,8 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { minSpeed: this.slowMinSpeed().trim(), maxTimeHours: this.slowMaxTimeHours() ?? 0, ignoreAboveSize: this.slowIgnoreAboveSize().trim() || undefined, - deletePrivateTorrentsFromClient: this.slowDeletePrivate(), + deletePrivateTorrentsFromClient: changeCategory ? false : this.slowDeletePrivate(), + changeCategory, }; const editing = this.editingSlowRule(); @@ -519,10 +549,11 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { failedImport: { maxStrikes: this.failedMaxStrikes() ?? 3, ignorePrivate: this.failedIgnorePrivate(), - deletePrivate: this.failedDeletePrivate(), + deletePrivate: this.failedChangeCategory() ? false : this.failedDeletePrivate(), skipIfNotFoundInClient: this.failedSkipNotFound(), patterns: this.failedPatterns(), patternMode: this.failedPatternMode() as PatternMode, + changeCategory: this.failedChangeCategory(), }, downloadingMetadataMaxStrikes: this.metadataMaxStrikes() ?? 3, }; @@ -558,6 +589,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges { failedSkipNotFound: this.failedSkipNotFound(), failedPatterns: this.failedPatterns(), failedPatternMode: this.failedPatternMode(), + failedChangeCategory: this.failedChangeCategory(), metadataMaxStrikes: this.metadataMaxStrikes(), }); } diff --git a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts index 91f00687..f2d6d7d5 100644 --- a/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts +++ b/code/frontend/src/app/shared/models/queue-cleaner-config.model.ts @@ -13,6 +13,7 @@ export interface FailedImportConfig { skipIfNotFoundInClient: boolean; patterns: string[]; patternMode?: PatternMode; + changeCategory: boolean; } export interface QueueCleanerConfig { diff --git a/code/frontend/src/app/shared/models/queue-rule.model.ts b/code/frontend/src/app/shared/models/queue-rule.model.ts index 91484cd8..d75ab9ca 100644 --- a/code/frontend/src/app/shared/models/queue-rule.model.ts +++ b/code/frontend/src/app/shared/models/queue-rule.model.ts @@ -9,6 +9,7 @@ export interface QueueRule { minCompletionPercentage: number; maxCompletionPercentage: number; deletePrivateTorrentsFromClient: boolean; + changeCategory: boolean; } export interface StallRule extends QueueRule { @@ -32,6 +33,7 @@ export interface CreateStallRuleDto { maxCompletionPercentage: number; resetStrikesOnProgress: boolean; deletePrivateTorrentsFromClient: boolean; + changeCategory: boolean; minimumProgress?: string | null; } @@ -47,4 +49,5 @@ export interface CreateSlowRuleDto { maxTimeHours: number; ignoreAboveSize?: string; deletePrivateTorrentsFromClient: boolean; + changeCategory: boolean; } diff --git a/docs/docs/configuration/queue-cleaner/index.mdx b/docs/docs/configuration/queue-cleaner/index.mdx index b3593632..34cd05a8 100644 --- a/docs/docs/configuration/queue-cleaner/index.mdx +++ b/docs/docs/configuration/queue-cleaner/index.mdx @@ -120,6 +120,18 @@ This setting needs a download client to be configured. + + +When enabled, instead of deleting the item from the download client, Cleanuparr asks the *arr to change the download's category to the post-import category. + + +The post-import category is configured per download client in your *arr application's settings, not in Cleanuparr. Cleanuparr only signals the *arr to perform the category change. + + + + @@ -259,6 +271,18 @@ Only reset strikes after the torrent downloads at least this amount of data. Lea + + +When enabled, instead of deleting the item from the download client, Cleanuparr asks the *arr to change the torrent's category to the post-import category. + + +The post-import category is configured per download client in your *arr application's settings, not in Cleanuparr. Cleanuparr only signals the *arr to perform the category change. + + + + @@ -374,6 +398,18 @@ When enabled, the strike count resets to zero if the download speed improves abo + + +When enabled, instead of deleting the item from the download client, Cleanuparr asks the *arr to change the torrent's category to the post-import category. + + +The post-import category is configured per download client in your *arr application's settings, not in Cleanuparr. Cleanuparr only signals the *arr to perform the category change. + + + +