Add option to change the category instead of deleting queue items (#602)

This commit is contained in:
Flaminel
2026-05-05 15:59:42 +03:00
committed by GitHub
parent 48c36fab8f
commit ab792f5fad
45 changed files with 3321 additions and 203 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -74,11 +74,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -119,11 +119,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -211,11 +211,11 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -356,7 +356,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -397,7 +397,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -445,13 +445,14 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
.Returns((true, DeleteReason.SlowSpeed, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeFalse();
}
[Fact]
@@ -488,13 +489,57 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
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<Tracker>(),
DownloadLocation = "/downloads"
};
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(downloadStatus);
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0 } }
}
});
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<DelugeItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, false, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeFalse();
result.ChangeCategory.ShouldBeTrue();
}
}
}

View File

@@ -135,11 +135,11 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -193,11 +193,11 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -401,11 +401,11 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -654,7 +654,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -709,7 +709,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -764,7 +764,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true)); // Rule matched
.Returns((true, DeleteReason.SlowSpeed, true, false)); // Rule matched
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -773,6 +773,63 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
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<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
AdditionalData = new Dictionary<string, JToken>
{
{ "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<QBitItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, false, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeFalse();
result.ChangeCategory.ShouldBeTrue();
}
}
@@ -826,7 +883,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -880,7 +937,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.Stalled, true)); // Rule matched
.Returns((true, DeleteReason.Stalled, true, false)); // Rule matched
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -889,6 +946,62 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
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<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
.Returns(new[] { torrentInfo });
_fixture.ClientWrapper
.GetTorrentTrackersAsync(hash)
.Returns(Array.Empty<TorrentTracker>());
var properties = new TorrentProperties
{
AdditionalData = new Dictionary<string, JToken>
{
{ "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<QBitItemWrapper>())
.Returns((true, DeleteReason.Stalled, true, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeTrue();
}
}
@@ -943,7 +1056,7 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
// Slow check is skipped because not in downloading state
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -1001,11 +1114,11 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<QBitItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -132,11 +132,11 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -182,11 +182,11 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -300,11 +300,11 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -399,7 +399,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -447,7 +447,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -495,7 +495,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
.Returns((true, DeleteReason.SlowSpeed, true, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -504,6 +504,55 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
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<string>());
_fixture.ClientWrapper
.GetTorrentFilesAsync(hash)
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, false, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeFalse();
result.ChangeCategory.ShouldBeTrue();
}
}
@@ -550,7 +599,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -598,7 +647,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -607,6 +656,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeFalse();
}
}
@@ -654,7 +704,7 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
// Slow check is skipped because speed is 0
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -704,11 +754,11 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<RTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());

View File

@@ -108,11 +108,11 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -167,11 +167,11 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -294,11 +294,11 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -476,11 +476,11 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -542,7 +542,7 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -598,7 +598,7 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -661,13 +661,14 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
.Returns((true, DeleteReason.SlowSpeed, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeFalse();
}
[Fact]
@@ -719,13 +720,52 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
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<string[]>(), hash)
.Returns(torrents);
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<TransmissionItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, false, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeFalse();
result.ChangeCategory.ShouldBeTrue();
}
}
}

View File

@@ -79,11 +79,11 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -129,11 +129,11 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -231,11 +231,11 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -399,11 +399,11 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -456,7 +456,7 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -502,7 +502,7 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((false, DeleteReason.None, false));
.Returns((false, DeleteReason.None, false, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
@@ -555,13 +555,14 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, true));
.Returns((true, DeleteReason.SlowSpeed, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeFalse();
}
[Fact]
@@ -603,13 +604,111 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true));
.Returns((true, DeleteReason.Stalled, true, false));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
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<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.EvaluateSlowRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.SlowSpeed, false, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
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<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.RuleEvaluator
.EvaluateStallRulesAsync(Arg.Any<UTorrentItemWrapper>())
.Returns((true, DeleteReason.Stalled, true, true));
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.Stalled);
result.DeleteFromClient.ShouldBeTrue();
result.ChangeCategory.ShouldBeTrue();
}
}
}

View File

@@ -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<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
@@ -312,6 +314,7 @@ public class QueueItemRemoverTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(new HttpRequestException("Not found", null, HttpStatusCode.NotFound));
@@ -335,6 +338,7 @@ public class QueueItemRemoverTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
@@ -357,6 +361,7 @@ public class QueueItemRemoverTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
Arg.Any<DeleteReason>())
.ThrowsAsync(originalException);
@@ -390,6 +395,7 @@ public class QueueItemRemoverTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<bool>(),
deleteReason);
}
@@ -408,7 +414,28 @@ public class QueueItemRemoverTests : IDisposable
await _arrClient.Received(1).DeleteQueueItemAsync(
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
removeFromClient,
Arg.Is<bool>(x => x == removeFromClient),
Arg.Any<bool>(),
Arg.Any<DeleteReason>());
}
[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<ArrInstance>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Is<bool>(x => x == changeCategory),
Arg.Any<DeleteReason>());
}
@@ -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

View File

@@ -88,7 +88,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
// Process through real QueueItemRemover
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -98,6 +98,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -224,6 +225,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
false,
false,
DeleteReason.AllFilesBlocked);
// Full event property verification

View File

@@ -81,7 +81,7 @@ public class QueueCleanerIntegrationTests : IDisposable
// Process the captured messages through the real QueueItemRemover pipeline
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -91,6 +91,7 @@ public class QueueCleanerIntegrationTests : IDisposable
Arg.Is<ArrInstance>(i => i.Id == instance.Id),
Arg.Is<QueueRecord>(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<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -280,7 +281,7 @@ public class QueueCleanerIntegrationTests : IDisposable
_fixture.GetCapturedRemoveRequests().Count.ShouldBe(1);
_fixture.ArrClient.DeleteQueueItemAsync(
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
Arg.Any<ArrInstance>(), Arg.Any<QueueRecord>(), Arg.Any<bool>(), Arg.Any<bool>(), Arg.Any<DeleteReason>())
.Returns(Task.CompletedTask);
await _fixture.ProcessCapturedRemoveRequestsAsync();
@@ -290,6 +291,7 @@ public class QueueCleanerIntegrationTests : IDisposable
Arg.Any<ArrInstance>(),
Arg.Any<QueueRecord>(),
false,
false,
DeleteReason.Stalled);
// Full event property verification

View File

@@ -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<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.ShouldRemoveFromQueue(
Arg.Any<InstanceType>(),
Arg.Any<QueueRecord>(),
Arg.Any<bool>(),
Arg.Any<short>()
).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.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<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>()
)
.Returns(async ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
// 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<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.MessageBus.Received(1).Publish(
Arg.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.DeleteReason == DeleteReason.FailedImport &&
r.ChangeCategory == true &&
r.RemoveFromClient == false
),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task ProcessInstanceAsync_WhenStallRuleHasChangeCategory_PublishesRequestWithChangeCategoryAndRemoveFromClientFalse()
{
// Arrange
TestDataContextFactory.AddSonarrInstance(_fixture.DataContext);
TestDataContextFactory.AddDownloadClient(_fixture.DataContext);
var mockArrClient = Substitute.For<IArrClient>();
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
_fixture.ArrClientFactory
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
.Returns(mockArrClient);
var queueRecord = new QueueRecord
{
Id = 1,
DownloadId = "stall-change-category",
Title = "Stall Change Category",
Protocol = "torrent",
SeriesId = 1,
EpisodeId = 1
};
_fixture.ArrQueueIterator
.Iterate(
Arg.Any<IArrClient>(),
Arg.Any<ArrInstance>(),
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>()
)
.Returns(async ci =>
{
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
await callback([queueRecord]);
});
var mockDownloadService = _fixture.CreateMockDownloadService();
mockDownloadService
.ShouldRemoveFromArrQueueAsync(
Arg.Any<string>(),
Arg.Any<List<string>>()
)
.Returns(new DownloadCheckResult
{
Found = true,
ShouldRemove = true,
IsPrivate = true,
DeleteFromClient = true,
ChangeCategory = true,
DeleteReason = DeleteReason.Stalled,
});
_fixture.DownloadServiceFactory
.GetDownloadService(Arg.Any<DownloadClientConfig>())
.Returns(mockDownloadService);
var sut = CreateSut();
// Act
await sut.ExecuteAsync();
// Assert
await _fixture.MessageBus.Received(1).Publish(
Arg.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
r.DeleteReason == DeleteReason.Stalled &&
r.ChangeCategory == true &&
r.RemoveFromClient == false
),
Arg.Any<CancellationToken>()
);
}
#endregion
}

View File

@@ -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<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
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<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.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<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
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<ITorrentItemWrapper>())
.Returns(stallRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.Stalled, Arg.Any<long?>())
.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<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
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<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowSpeed, Arg.Any<long?>())
.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<IQueueRuleManager>();
var striker = Substitute.For<IStriker>();
var logger = Substitute.For<ILogger<QueueRuleEvaluator>>();
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<ITorrentItemWrapper>())
.Returns(slowRule);
striker
.StrikeAndCheckLimit(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<ushort>(), StrikeType.SlowTime, Arg.Any<long?>())
.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
}

View File

@@ -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<HttpResponseMessage>(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)
{

View File

@@ -11,7 +11,15 @@ public interface IArrClient
Task<bool> ShouldRemoveFromQueue(InstanceType instanceType, QueueRecord record, bool isPrivateDownload, short arrMaxStrikes);
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, DeleteReason deleteReason);
/// <summary>
/// Removes a queue item from the *arr instance.
/// </summary>
/// <param name="arrInstance">The *arr instance hosting the queue item.</param>
/// <param name="record">The queue record to remove.</param>
/// <param name="removeFromClient">When true, also delete the download from the download client. Ignored when <paramref name="changeCategory"/> is true.</param>
/// <param name="changeCategory">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 <paramref name="removeFromClient"/>.</param>
/// <param name="deleteReason">Reason for removal, used for logging and event publishing.</param>
Task DeleteQueueItemAsync(ArrInstance arrInstance, QueueRecord record, bool removeFromClient, bool changeCategory, DeleteReason deleteReason);
/// <summary>
/// Triggers a search for the specified items and returns the arr command IDs

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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<List<long>> SearchItemsAsync(ArrInstance arrInstance, HashSet<SearchItem>? items)
{
if (items?.Count is null or 0)

View File

@@ -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);

View File

@@ -22,4 +22,9 @@ public sealed record DownloadCheckResult
/// True if the download should be deleted from the client; otherwise false.
/// </summary>
public bool DeleteFromClient { get; set; }
/// <summary>
/// True if the matching queue rule asked to change the category in the *arr instead of deleting; otherwise false.
/// </summary>
public bool ChangeCategory { get; set; }
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -17,6 +17,12 @@ public sealed record QueueItemRemoveRequest<T>
public required bool RemoveFromClient { get; init; }
/// <summary>
/// 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 <see cref="RemoveFromClient"/>.
/// </summary>
public bool ChangeCategory { get; init; }
public required DeleteReason DeleteReason { get; init; }
public required Guid JobRunId { get; init; }

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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<ValidationException>(() => config.Validate());
exception.Message.ShouldBe("Cannot enable both deletion and category changing");
}
#endregion
}

View File

@@ -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<ValidationException>(() => rule.Validate());
exception.Message.ShouldBe("Cannot enable both deletion and category changing");
}
#endregion
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddQueueCleanerChangeCategory : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "change_category",
table: "stall_rules",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "change_category",
table: "slow_rules",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "failed_import_change_category",
table: "queue_cleaner_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -1235,6 +1235,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b1.IsRequired();
b1.Property<bool>("ChangeCategory")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_change_category");
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
@@ -1275,6 +1279,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("ChangeCategory")
.HasColumnType("INTEGER")
.HasColumnName("change_category");
b.Property<bool>("DeletePrivateTorrentsFromClient")
.HasColumnType("INTEGER")
.HasColumnName("delete_private_torrents_from_client");
@@ -1342,6 +1350,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("ChangeCategory")
.HasColumnType("INTEGER")
.HasColumnName("change_category");
b.Property<bool>("DeletePrivateTorrentsFromClient")
.HasColumnType("INTEGER")
.HasColumnName("delete_private_torrents_from_client");

View File

@@ -19,7 +19,9 @@ public sealed record FailedImportConfig
public IReadOnlyList<string> 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");
}
}
}

View File

@@ -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)

View File

@@ -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',

View File

@@ -72,10 +72,16 @@
[disabled]="failedSubFieldsDisabled()"
hint="When enabled, private torrents will not be checked for being failed imports"
helpKey="queue-cleaner:failedImport.ignorePrivate" />
<app-toggle label="Delete Private from Client" [(checked)]="failedDeletePrivate"
[disabled]="failedDeletePrivateDisabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:failedImport.deletePrivate" />
<app-toggle label="Change category instead of delete" [(checked)]="failedChangeCategory"
[disabled]="failedSubFieldsDisabled()"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:failedImport.changeCategory" />
@if (!failedChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="failedDeletePrivate"
[disabled]="failedDeletePrivateDisabled()"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:failedImport.deletePrivate" />
}
<app-toggle label="Skip if Not Found in Client" [(checked)]="failedSkipNotFound"
[disabled]="failedSubFieldsDisabled()"
hint="Skip failed import check for torrents not found in any enabled torrent client"
@@ -275,10 +281,15 @@
hint="Only reset strikes after the torrent downloads at least this amount. Leave blank to reset on any progress."
helpKey="queue-cleaner:stallRule.minimumProgress" />
}
<app-toggle label="Delete Private from Client" [(checked)]="stallDeletePrivate"
[disabled]="stallPrivacyType() === 'Public'"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:stallRule.deletePrivateTorrentsFromClient" />
<app-toggle label="Change category instead of delete" [(checked)]="stallChangeCategory"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:stallRule.changeCategory" />
@if (!stallChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="stallDeletePrivate"
[disabled]="stallPrivacyType() === 'Public'"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:stallRule.deletePrivateTorrentsFromClient" />
}
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="stallModalVisible.set(false)">Cancel</app-button>
@@ -326,10 +337,15 @@
<app-toggle label="Reset Strikes on Progress" [(checked)]="slowResetOnProgress"
hint="Reset strike count when torrent shows progress"
helpKey="queue-cleaner:slowRule.resetStrikesOnProgress" />
<app-toggle label="Delete Private from Client" [(checked)]="slowDeletePrivate"
[disabled]="slowPrivacyType() === 'Public'"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:slowRule.deletePrivateTorrentsFromClient" />
<app-toggle label="Change category instead of delete" [(checked)]="slowChangeCategory"
hint="Changes the category to the post-import category set in your arr"
helpKey="queue-cleaner:slowRule.changeCategory" />
@if (!slowChangeCategory()) {
<app-toggle label="Delete Private from Client" [(checked)]="slowDeletePrivate"
[disabled]="slowPrivacyType() === 'Public'"
hint="Disable this if you want to keep private torrents in the download client even if they are removed from the arrs"
helpKey="queue-cleaner:slowRule.deletePrivateTorrentsFromClient" />
}
</div>
<div modal-footer>
<app-button variant="secondary" (clicked)="slowModalVisible.set(false)">Cancel</app-button>

View File

@@ -98,6 +98,7 @@ export class QueueCleanerComponent implements OnInit, HasPendingChanges {
readonly failedSkipNotFound = signal(false);
readonly failedPatterns = signal<string[]>([]);
readonly failedPatternMode = signal<unknown>(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<SlowRule[]>([]);
@@ -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(),
});
}

View File

@@ -13,6 +13,7 @@ export interface FailedImportConfig {
skipIfNotFoundInClient: boolean;
patterns: string[];
patternMode?: PatternMode;
changeCategory: boolean;
}
export interface QueueCleanerConfig {

View File

@@ -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;
}

View File

@@ -120,6 +120,18 @@ This setting needs a download client to be configured.
</ConfigSection>
<ConfigSection
title="Change Category"
>
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.
<Important>
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.
</Important>
</ConfigSection>
<ConfigSection
title="Delete Private"
>
@@ -259,6 +271,18 @@ Only reset strikes after the torrent downloads at least this amount of data. Lea
</ConfigSection>
<ConfigSection
title="Change Category"
>
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.
<Important>
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.
</Important>
</ConfigSection>
<ConfigSection
title="Delete Private from Client"
>
@@ -374,6 +398,18 @@ When enabled, the strike count resets to zero if the download speed improves abo
</ConfigSection>
<ConfigSection
title="Change Category"
>
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.
<Important>
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.
</Important>
</ConfigSection>
<ConfigSection
title="Delete Private from Client"
>