Deduplicate download client logic (#812)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-25 16:26:12 +03:00
1 parent 124fcbe747
commit aff3880a0d
38 files changed
+2268 -1068

No files matched your search

@@ -749,6 +749,82 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Assert
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
await _fixture.EventPublisher.DidNotReceive().PublishCategoryChanged(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
public async Task NullContents_TreatedAsNoFiles_NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked"
};
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.GetTorrentFiles("hash1")
.Returns(new DelugeContents { Contents = null });
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabel("hash1", "unlinked");
_fixture.HardLinkFileService
.DidNotReceive()
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>());
}
[Fact]
public async Task UseTagTrue_StillChangesLabel_AndPublishesEventWithIsTagFalse()
{
// Arrange
var sut = _fixture.CreateSut();
var unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked",
UseTag = true
};
var downloads = new List<Domain.Entities.ITorrentItemWrapper>
{
new DelugeItemWrapper(new DownloadStatus { Hash = "hash1", Name = "Test", Label = "movies", Trackers = new List<Tracker>(), DownloadLocation = "/downloads" })
};
_fixture.ClientWrapper
.GetTorrentFiles("hash1")
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "file1.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "file1.mkv" } }
}
});
_fixture.HardLinkFileService
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabel("hash1", "unlinked");
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "unlinked", false);
downloads[0].Category.ShouldBe("unlinked");
}
[Fact]
@@ -8,6 +8,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
@@ -590,6 +591,77 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
}
}
public class ChangeTorrentCategoryAsync_Tests : DelugeServiceTests
{
public ChangeTorrentCategoryAsync_Tests(DelugeServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task UseTagFalse_ChangesLabel_PublishesCategoryEvent_UpdatesTorrentCategory()
{
var sut = _fixture.CreateSut();
var torrent = new DelugeItemWrapper(new DownloadStatus
{
Hash = "hash1",
Name = "Test",
Label = "movies",
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
});
await sut.ChangeTorrentCategoryAsync(torrent, "target", false);
await _fixture.ClientWrapper.Received(1).SetTorrentLabel("hash1", "target");
await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false);
torrent.Category.ShouldBe("target");
}
[Fact]
public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseDelugeHasNoTags()
{
var sut = _fixture.CreateSut();
var torrent = new DelugeItemWrapper(new DownloadStatus
{
Hash = "hash1",
Name = "Test",
Label = "movies",
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
});
await sut.ChangeTorrentCategoryAsync(torrent, "target", true);
await _fixture.ClientWrapper.Received(1).SetTorrentLabel("hash1", "target");
await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false);
torrent.Category.ShouldBe("target");
}
[Fact]
public async Task DryRun_SkipsLabelChange_ButStillPublishesEventAndUpdatesCategory()
{
var sut = _fixture.CreateSut();
var torrent = new DelugeItemWrapper(new DownloadStatus
{
Hash = "hash1",
Name = "Test",
Label = "movies",
Trackers = new List<Tracker>(),
DownloadLocation = "/downloads"
});
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
await sut.ChangeTorrentCategoryAsync(torrent, "target", false);
await _fixture.ClientWrapper.DidNotReceive().SetTorrentLabel(Arg.Any<string>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1).PublishCategoryChanged("movies", "target", false);
torrent.Category.ShouldBe("target");
}
}
public class BlockUnwantedFilesAsyncScenarios : DelugeServiceTests
{
public BlockUnwantedFilesAsyncScenarios(DelugeServiceFixture fixture) : base(fixture)
@@ -624,7 +696,7 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
};
[Fact]
public async Task AllFilesAreMalware_DoesNotCallChangeFilesPriority_AndMarksForRemoval()
public async Task AllFilesAreMalware_CallsChangeFilesPriority_AndMarksForRemoval()
{
const string hash = "all-malware-hash";
var sut = _fixture.CreateSut();
@@ -655,8 +727,48 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
.Received(1)
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 1 && p[0] == 0));
}
[Fact]
public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval()
{
const string hash = "all-malware-priority-throws-hash";
DelugeService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "malware.exe" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.ClientWrapper
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 1 && p[0] == 0))
.ThrowsAsync(new HttpRequestException("rejected"));
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 1 && p[0] == 0));
}
[Fact]
@@ -738,5 +850,260 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
[Fact]
public async Task NoUnwantedFiles_DoesNotCallChangeFilesPriority_AndDoesNotRemove()
{
const string hash = "no-unwanted-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
[Fact]
public async Task AlreadySkippedFilePlusMalwareFile_MarksAllFilesBlocked()
{
const string hash = "skipped-plus-malware-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "skipped.txt", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "skipped.txt" } },
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "malware.exe" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
public async Task DeleteIfAnyFileBlocked_AlreadySkippedFileAlone_DoesNotTriggerAtLeastOneFileBlocked()
{
const string hash = "skipped-alone-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true });
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "skipped.txt", new DelugeFileOrDirectory { Type = "file", Priority = 0, Index = 0, Path = "skipped.txt" } },
},
});
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
[Fact]
public async Task PartialMalware_NestedDirectory_ChangeFilesPriorityReceivesFullVectorWithOriginalPrioritiesPreserved()
{
const string hash = "nested-partial-malware-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } },
{
"Subs", new DelugeFileOrDirectory
{
Type = "dir",
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 5, Index = 1, Path = "Subs/malware.exe" } },
}
}
},
{ "sample.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 7, Index = 2, Path = "sample.mkv" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name == "malware.exe"), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name != "malware.exe"), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeFalse();
await _fixture.ClientWrapper
.Received(1)
.ChangeFilesPriority(hash, Arg.Is<List<int>>(p => p.Count == 3 && p[0] == 1 && p[1] == 0 && p[2] == 7));
}
[Fact]
public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck_EvaluatorNeverCalled()
{
const string hash = "private-ignore-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true });
DownloadStatus downloadStatus = MakeDownloadStatus(hash) with { Private = true };
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(downloadStatus);
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.IsPrivate.ShouldBeTrue();
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
[Fact]
public async Task DryRun_StillSetsAllFilesBlockedVerdict_ButSkipsChangeFilesPriority()
{
const string hash = "dry-run-all-malware-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "malware.exe" } },
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
var result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
}
[Fact]
public async Task NestedFile_ValidatesBareFileName_NotRelativePath()
{
const string hash = "nested-bare-name-hash";
var sut = _fixture.CreateSut();
SetMalwareBlockerContext();
_fixture.ClientWrapper
.GetTorrentStatus(hash)
.Returns(MakeDownloadStatus(hash));
_fixture.ClientWrapper
.GetTorrentFiles(hash)
.Returns(new DelugeContents
{
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{
"Season 01", new DelugeFileOrDirectory
{
Type = "dir",
Contents = new Dictionary<string, DelugeFileOrDirectory>
{
{ "episode.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "Season 01/episode.mkv" } },
}
}
},
},
});
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
_fixture.FilenameEvaluator
.Received(1)
.IsValid("episode.mkv", Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid("Season 01/episode.mkv", Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
}
}
}
@@ -1229,6 +1229,29 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", true);
}
[Fact]
public async Task DryRun_SkipsClientCall_ButPublishesEvent()
{
var sut = _fixture.CreateSut();
var torrent = Substitute.For<ITorrentItemWrapper>();
torrent.Hash.Returns("hash1");
torrent.Name.Returns("Test");
torrent.Category.Returns("movies");
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
await _fixture.ClientWrapper.DidNotReceive()
.SetTorrentCategoryAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.ClientWrapper.DidNotReceive()
.AddTorrentTagAsync(Arg.Any<IEnumerable<string>>(), Arg.Any<string>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : QBitServiceDCTests
@@ -7,6 +7,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Newtonsoft.Json.Linq;
using QBittorrent.Client;
using Shouldly;
@@ -1371,6 +1372,41 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip);
}
[Fact]
public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval()
{
const string hash = "all-malware-priority-throws-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.ClientWrapper
.SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip)
.ThrowsAsync(new HttpRequestException("rejected"));
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip);
}
[Fact]
@@ -1574,5 +1610,92 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
}
[Fact]
public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck()
{
const string hash = "private-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true });
StubClient(hash,
[
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
], isPrivate: true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.IsPrivate.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
}
[Fact]
public async Task FileWithNullIndex_ExcludedFromTotals_MalwareFileStillMarksAllFilesBlocked()
{
const string hash = "null-index-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "unindexed.mkv", Index = null, Priority = TorrentContentPriority.Normal },
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 0, TorrentContentPriority.Skip);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(hash, Arg.Is<int>(i => i != 0), Arg.Any<TorrentContentPriority>());
}
[Fact]
public async Task DryRun_AllFilesMalware_VerdictSet_ButNoSetFilePriorityCall()
{
const string hash = "dry-run-hash";
QBitService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
}
}
}
@@ -528,6 +528,71 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
}
}
public class ChangeTorrentCategoryAsync_Tests : RTorrentServiceDCTests
{
public ChangeTorrentCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task UseTagFalse_ChangesLabel_PublishesEvent_UpdatesCategory()
{
// Arrange
RTorrentService sut = _fixture.CreateSut();
RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetLabelAsync("HASH1", "target");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
[Fact]
public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseRTorrentDoesNotSupportTags()
{
// Arrange
RTorrentService sut = _fixture.CreateSut();
RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: true);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetLabelAsync("HASH1", "target");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
[Fact]
public async Task DryRun_SkipsLabelCall_ButPublishesEventAndUpdatesCategory()
{
// Arrange
RTorrentService sut = _fixture.CreateSut();
RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" }, null, TimeProvider.System);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false);
// Assert
await _fixture.ClientWrapper.DidNotReceive()
.SetLabelAsync(Arg.Any<string>(), Arg.Any<string>());
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : RTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
@@ -859,6 +924,44 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.PublishCategoryChanged("movies", "unlinked", false);
}
[Fact]
public async Task UseTagTrue_ChangesLabel_PublishesEventWithIsTagFalse_UpdatesCategory()
{
// Arrange
RTorrentService sut = _fixture.CreateSut();
UnlinkedConfig unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked",
UseTag = true
};
RTorrentItemWrapper wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" }, null, TimeProvider.System);
List<ITorrentItemWrapper> downloads = new List<ITorrentItemWrapper> { wrapper };
_fixture.ClientWrapper
.GetTorrentFilesAsync("HASH1")
.Returns(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - rTorrent does not support tags, so useTag falls back to a category change
await _fixture.ClientWrapper.Received(1)
.SetLabelAsync("HASH1", "unlinked");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "unlinked", false);
wrapper.Category.ShouldBe("unlinked");
}
[Fact]
public async Task UsesDirectoryOverBasePathForFilePath()
{
@@ -833,6 +833,38 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 0, 0);
}
[Fact]
public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval()
{
const string hash = "ALL-MALWARE-PRIORITY-THROWS-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.ClientWrapper
.SetFilePriorityAsync(hash, 0, 0)
.ThrowsAsync(new HttpRequestException("rejected"));
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilePriorityAsync(hash, 0, 0);
}
[Fact]
@@ -944,5 +976,107 @@ public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
[Fact]
public async Task PrivateTorrent_WithIgnorePrivate_SkipsFileCheck_NeverEvaluatesFilenames()
{
const string hash = "IGNORE-PRIVATE-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true });
StubClient(hash, [new RTorrentFile { Index = 0, Path = "installer.exe", Priority = 1 }], isPrivate: true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.IsPrivate.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task DryRun_AllFilesMalware_StillMarksForRemoval_SkipsPriorityCall()
{
const string hash = "DRY-RUN-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
}
[Fact]
public async Task PartialMalware_MultipleUnwantedFiles_CallsSetFilePriorityForExactIndicesOnly()
{
const string hash = "MULTI-PARTIAL-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 },
new RTorrentFile { Index = 1, Path = "malware1.exe", Priority = 1 },
new RTorrentFile { Index = 2, Path = "malware2.exe", Priority = 1 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.StartsWith("malware")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeFalse();
await _fixture.ClientWrapper.Received(1).SetFilePriorityAsync(hash, 1, 0);
await _fixture.ClientWrapper.Received(1).SetFilePriorityAsync(hash, 2, 0);
await _fixture.ClientWrapper.DidNotReceive().SetFilePriorityAsync(hash, 0, 0);
}
[Fact]
public async Task NestedFilePath_ValidatesBareFilename()
{
const string hash = "NESTED-PATH-HASH";
RTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new RTorrentFile { Index = 0, Path = "Season 01/Episode.01.mkv", Priority = 1 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
_fixture.FilenameEvaluator
.Received(1)
.IsValid("Episode.01.mkv", Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
}
}
}
@@ -580,6 +580,106 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
}
}
public class ChangeTorrentCategoryAsync_Tests : TransmissionServiceDCTests
{
public ChangeTorrentCategoryAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task CategoryMode_ChangesLocation_PublishesEvent_AndUpdatesCategory()
{
// Arrange
var sut = _fixture.CreateSut();
var baseDownloadDir = Path.Combine("downloads", "movies");
var expectedNewLocation = string.Join(Path.DirectorySeparatorChar,
Path.Combine(baseDownloadDir, "cleanuparr-dead").Split(['\\', '/']));
var torrent = new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir
});
// Act
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetLocationAsync(Arg.Is<long[]>(ids => ids.Contains(123)), expectedNewLocation, true);
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
torrent.Category.ShouldBe("cleanuparr-dead");
}
[Fact]
public async Task TagMode_SetsLabels_PublishesEvent_AndKeepsCategoryUnchanged()
{
// Arrange
var sut = _fixture.CreateSut();
var baseDownloadDir = Path.Combine("downloads", "movies");
var torrent = new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = baseDownloadDir,
Labels = ["existing", "CLEANUPARR-DEAD"]
});
// Act
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: true);
// Assert
await _fixture.ClientWrapper.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.Ids.Contains(123L)
&& s.Labels.Contains("existing")
&& s.Labels.Contains("CLEANUPARR-DEAD")
&& s.Labels.Length == 2));
await _fixture.ClientWrapper.DidNotReceive()
.TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", true);
torrent.Category.ShouldBe("movies");
}
[Fact]
public async Task DryRun_SkipsClientCall_ButPublishesEvent()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
var torrent = new TransmissionItemWrapper(new TorrentInfo
{
Id = 123,
HashString = "hash1",
Name = "Test",
DownloadDir = Path.Combine("downloads", "movies")
});
// Act
await sut.ChangeTorrentCategoryAsync(torrent, "cleanuparr-dead", useTag: false);
// Assert
await _fixture.ClientWrapper.DidNotReceive()
.TorrentSetLocationAsync(Arg.Any<long[]>(), Arg.Any<string>(), Arg.Any<bool>());
await _fixture.ClientWrapper.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
await _fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "cleanuparr-dead", false);
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : TransmissionServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(TransmissionServiceFixture fixture) : base(fixture)
@@ -6,6 +6,7 @@ using Cleanuparr.Infrastructure.Features.DownloadClient;
using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
using Shouldly;
@@ -592,6 +593,38 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval()
{
const string hash = "all-malware-priority-throws-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("malware.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.ClientWrapper
.TorrentSetAsync(Arg.Any<TorrentSettings>())
.ThrowsAsync(new HttpRequestException("rejected"));
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
[Fact]
@@ -691,5 +724,85 @@ public class TransmissionServiceTests : IClassFixture<TransmissionServiceFixture
result.ShouldRemove.ShouldBeFalse();
result.DeleteReason.ShouldBe(DeleteReason.None);
}
[Fact]
public async Task IgnorePrivate_PrivateTorrent_SkipsFileCheck()
{
const string hash = "private-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true });
StubClient(hash, [("malware.exe", true)], isPrivate: true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.IsPrivate.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
}
[Fact]
public async Task PartialMalware_TorrentSetContainsExactUnwantedIndices()
{
const string hash = "exact-indices-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("movie.mkv", true), ("installer.exe", true), ("readme.txt", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("readme.txt")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
await _fixture.ClientWrapper
.Received(1)
.TorrentSetAsync(Arg.Is<TorrentSettings>(s =>
s.FilesUnwanted != null
&& s.FilesUnwanted.Length == 2
&& s.FilesUnwanted.Contains(1L)
&& s.FilesUnwanted.Contains(2L)));
}
[Fact]
public async Task DryRun_AllFilesMalware_VerdictSet_ButNoTorrentSetCall()
{
const string hash = "dry-run-hash";
TransmissionService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [("malware.exe", true)]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.TorrentSetAsync(Arg.Any<TorrentSettings>());
}
}
}
@@ -515,6 +515,77 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
}
}
public class ChangeTorrentCategoryAsync_Tests : UTorrentServiceDCTests
{
public ChangeTorrentCategoryAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task UseTagFalse_ChangesLabel_PublishesEvent_UpdatesCategory()
{
// Arrange
UTorrentService sut = _fixture.CreateSut();
UTorrentItemWrapper wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabelAsync("hash1", "target");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
[Fact]
public async Task UseTagTrue_BehavesLikeUseTagFalse_BecauseUTorrentDoesNotSupportTags()
{
// Arrange
UTorrentService sut = _fixture.CreateSut();
UTorrentItemWrapper wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: true);
// Assert
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabelAsync("hash1", "target");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
[Fact]
public async Task DryRun_SkipsLabelCall_ButPublishesEventAndUpdatesCategory()
{
// Arrange
UTorrentService sut = _fixture.CreateSut();
UTorrentItemWrapper wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
// Act
await sut.ChangeTorrentCategoryAsync(wrapper, "target", useTag: false);
// Assert
await _fixture.ClientWrapper.DidNotReceive()
.SetTorrentLabelAsync(Arg.Any<string>(), Arg.Any<string>());
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "target", false);
wrapper.Category.ShouldBe("target");
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : UTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(UTorrentServiceFixture fixture) : base(fixture)
@@ -826,6 +897,46 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
.SetTorrentLabelAsync("hash1", "unlinked");
}
[Fact]
public async Task UseTagTrue_ChangesLabel_PublishesEventWithIsTagFalse_UpdatesCategory()
{
// Arrange
UTorrentService sut = _fixture.CreateSut();
UnlinkedConfig unlinkedConfig = new UnlinkedConfig
{
Id = Guid.NewGuid(),
TargetCategory = "unlinked",
UseTag = true
};
UTorrentItemWrapper wrapper = new UTorrentItemWrapper(
new UTorrentItem { Hash = "hash1", Name = "Test", Label = "movies", SavePath = "/downloads" },
new UTorrentProperties { Hash = "hash1", Pex = 1, Trackers = "" }, TimeProvider.System);
List<Domain.Entities.ITorrentItemWrapper> downloads = new List<Domain.Entities.ITorrentItemWrapper> { wrapper };
_fixture.ClientWrapper
.GetTorrentFilesAsync("hash1")
.Returns(new List<UTorrentFile>
{
new UTorrentFile { Name = "file1.mkv", Priority = 1, Index = 0, Size = 1000, Downloaded = 500 }
});
_fixture.HardLinkFileService
.GetHardLinkCount(Arg.Any<string>(), Arg.Any<bool>())
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads, unlinkedConfig);
// Assert - uTorrent does not support tags, so useTag falls back to a category change
await _fixture.ClientWrapper.Received(1)
.SetTorrentLabelAsync("hash1", "unlinked");
_fixture.EventPublisher.Received(1)
.PublishCategoryChanged("movies", "unlinked", false);
wrapper.Category.ShouldBe("unlinked");
}
[Fact]
public async Task NullFilesResponse_ChangesLabel()
{
@@ -799,6 +799,38 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 1 && idx[0] == 0), 0);
}
[Fact]
public async Task AllFilesAreMalware_PriorityUpdateThrows_StillMarksForRemoval()
{
const string hash = "all-malware-priority-throws-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.ClientWrapper
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 1 && idx[0] == 0), 0)
.ThrowsAsync(new HttpRequestException("rejected"));
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.Received(1)
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 1 && idx[0] == 0), 0);
}
[Fact]
@@ -1009,5 +1041,89 @@ public class UTorrentServiceTests : IClassFixture<UTorrentServiceFixture>
await _fixture.ClientWrapper.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task PrivateTorrent_WithIgnorePrivate_NeverEvaluatesFilenames()
{
const string hash = "ignore-private-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext(new ContentBlockerConfig { IgnorePrivate = true });
StubClient(hash,
[
new UTorrentFile { Name = "installer.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 },
], isPrivate: true);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.IsPrivate.ShouldBeTrue();
result.ShouldRemove.ShouldBeFalse();
_fixture.FilenameEvaluator
.DidNotReceive()
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>());
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task DryRun_AllFilesMalware_StillMarksForRemoval_SkipsPriorityCall()
{
const string hash = "dry-run-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]);
_fixture.FilenameEvaluator
.IsValid(Arg.Any<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
_fixture.DryRunInterceptor
.InterceptAsync(Arg.Any<Func<Task>>(), Arg.Any<string?>())
.Returns(Task.CompletedTask);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.Found.ShouldBeTrue();
result.ShouldRemove.ShouldBeTrue();
result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
await _fixture.ClientWrapper
.DidNotReceive()
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
}
[Fact]
public async Task PartialMalware_MultipleUnwantedFiles_CallsSetFilesPriorityWithExactIndicesOnly()
{
const string hash = "multi-partial-hash";
UTorrentService sut = _fixture.CreateSut();
SetMalwareBlockerContext();
StubClient(hash,
[
new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 },
new UTorrentFile { Name = "malware1.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 },
new UTorrentFile { Name = "malware2.exe", Index = 2, Priority = 2, Size = 1024, Downloaded = 1024 },
]);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(true);
_fixture.FilenameEvaluator
.IsValid(Arg.Is<string>(name => name.StartsWith("malware")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
.Returns(false);
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
result.ShouldRemove.ShouldBeFalse();
await _fixture.ClientWrapper
.Received(1)
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(idx => idx.Count == 2 && idx.Contains(1) && idx.Contains(2) && !idx.Contains(0)), 0);
}
}
}
@@ -545,8 +545,8 @@ public class DownloadCleanerIntegrationTests : IDisposable
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(
List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) => [];
public override Task ChangeCategoryForNoHardLinksAsync(
List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) => Task.CompletedTask;
protected override Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(
ITorrentItemWrapper torrent) => throw new NotSupportedException();
public override Task<List<ITorrentItemWrapper>> GetAllTorrentsLite() => Task.FromResult(_seedingDownloads);
@@ -564,7 +564,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
public override Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(
string hash, IReadOnlyList<string> ignoredDownloads) => throw new NotSupportedException();
public override Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
throw new NotSupportedException();
public override Task CreateCategoryAsync(string name) => throw new NotSupportedException();
@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
@@ -19,9 +17,9 @@ public partial class DelugeService
DownloadStatus? download = await _client.GetTorrentStatus(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
if (download?.Hash is null || download?.Name is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -32,7 +30,7 @@ public partial class DelugeService
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
_logger.LogInformation("skip | download is ignored | {Name}", download.Name);
return result;
}
@@ -41,7 +39,7 @@ public partial class DelugeService
if (malwareBlockerConfig.IgnorePrivate && download.Private)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
_logger.LogDebug("skip files check | download is private | {Name}", download.Name);
return result;
}
@@ -53,94 +51,37 @@ public partial class DelugeService
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to find files in the download client | {name}", download.Name);
_logger.LogWarning(exception, "failed to find files in the download client | {Name}", download.Name);
}
if (contents is null)
{
return result;
}
Dictionary<int, int> priorities = [];
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
// Deluge's priority API takes the full per-file vector, so keep every original priority
Dictionary<int, int> originalPriorities = [];
List<(int Index, string ValidationName, string LogName, FileBlockAction Action)> scanItems = [];
ProcessFiles(contents.Contents, (name, file) =>
{
totalFiles++;
int priority = file.Priority;
if (result.ShouldRemove)
{
return;
}
if (file.Priority is 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
}
if (file.Priority is not 0 && !_filenameEvaluator.IsValid(name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
priority = 0;
hasPriorityUpdates = true;
_logger.LogInformation("unwanted file found | {file}", file.Path);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return;
}
}
_logger.LogTrace("File is valid | {file}", file.Path);
priorities.Add(file.Index, priority);
originalPriorities[file.Index] = file.Priority;
scanItems.Add((file.Index, name, file.Path, file.Priority is 0
? FileBlockAction.AlreadySkipped
: FileBlockAction.CheckBlocklist));
});
if (result.ShouldRemove)
await ApplyFileBlockingAsync(result, download.Name, scanItems, malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices =>
{
return result;
}
HashSet<int> unwantedLookup = [..unwantedIndices];
List<int> sortedPriorities = originalPriorities
.OrderBy(x => x.Key)
.Select(x => unwantedLookup.Contains(x.Key) ? 0 : x.Value)
.ToList();
if (!hasPriorityUpdates)
{
return result;
}
_logger.LogDebug("changing priorities | torrent {hash}", hash);
List<int> sortedPriorities = priorities
.OrderBy(x => x.Key)
.Select(x => x.Value)
.ToList();
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
return result;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, sortedPriorities));
await _client.ChangeFilesPriority(hash, sortedPriorities);
});
return result;
}
protected virtual async Task ChangeFilesPriority(string hash, List<int> sortedPriorities)
{
await _client.ChangeFilesPriority(hash, sortedPriorities);
}
}
@@ -3,8 +3,6 @@ using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
@@ -67,17 +65,6 @@ public partial class DelugeService
return relativePaths;
});
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
@@ -107,103 +94,44 @@ public partial class DelugeService
await _dryRunInterceptor.InterceptAsync(() => CreateLabel(name));
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
/// <inheritdoc/>
protected override async Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
DelugeItemWrapper delugeTorrent = (DelugeItemWrapper)torrent;
DelugeContents? contents;
try
{
return;
contents = await _client.GetTorrentFiles(delugeTorrent.Hash);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to find torrent files for {Name}", delugeTorrent.Name);
return null;
}
foreach (DelugeItemWrapper torrent in downloads.Cast<DelugeItemWrapper>())
List<(string FilePath, HardLinkScanAction Action)> scanItems = [];
ProcessFiles(contents?.Contents, (_, file) =>
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(delugeTorrent.Info.DownloadLocation, file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
HardLinkScanAction action = file.Priority <= 0
? HardLinkScanAction.SkipUnwanted
: HardLinkScanAction.CheckHardLinks;
DelugeContents? contents;
try
{
contents = await _client.GetTorrentFiles(torrent.Hash);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "failed to find torrent files for {name}", torrent.Name);
continue;
}
scanItems.Add((filePath, action));
});
bool hasHardlinks = false;
bool hasErrors = false;
ProcessFiles(contents?.Contents, (_, file) =>
{
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.DownloadLocation, file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
return;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
return;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
}
});
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
torrent.Category = unlinkedConfig.TargetCategory;
}
return scanItems;
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
ChangeLabel(torrent.Hash, targetCategory);
protected async Task CreateLabel(string name)
{
@@ -2,9 +2,6 @@ using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Deluge;
@@ -76,44 +73,4 @@ public partial class DelugeService
return result;
}
private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Domain.Enums;
@@ -72,6 +74,55 @@ public abstract class DownloadService : IDownloadService
return Task.FromResult(false);
}
/// <summary>
/// Runs the slow check, then the stall check; the first removal wins.
/// </summary>
protected async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
/// <summary>
/// Alt-speed-limit probe for the slow rules.
/// Only qBittorrent and Transmission report that state.
/// </summary>
protected virtual Func<Task<bool>>? GetAltSpeedLimitProbe() => null;
protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, GetAltSpeedLimitProbe());
}
protected virtual async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
public abstract void Dispose();
public abstract Task LoginAsync();
@@ -176,10 +227,17 @@ public abstract class DownloadService : IDownloadService
.TrimEnd(Path.DirectorySeparatorChar);
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules);
public virtual List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
/// <inheritdoc/>
public abstract List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
public virtual List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public virtual async Task CleanDownloadsAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
@@ -272,10 +330,230 @@ public abstract class DownloadService : IDownloadService
}
/// <inheritdoc/>
public abstract Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig);
public async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
if (downloads?.Count is null or 0)
{
return;
}
foreach (ITorrentItemWrapper torrent in downloads)
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
IEnumerable<(string FilePath, HardLinkScanAction Action)>? files = await GetHardLinkScanItemsAsync(torrent);
if (files is null)
{
continue;
}
(bool hasHardlinks, bool hasErrors) = ScanForHardLinks(files, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name);
continue;
}
await ChangeTorrentCategoryAsync(torrent, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);
_logger.LogInformation(unlinkedConfig.UseTag && SupportsTags ? "tag added for {Name}" : "category changed for {Name}", torrent.Name);
}
}
/// <summary>
/// Maps a torrent's files to (path, <see cref="HardLinkScanAction"/>) pairs.
/// Returns null to skip the torrent, such as when its files can't be read.
/// </summary>
protected abstract Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent);
/// <summary>
/// Stops at the first hardlink or unreadable file.
/// Each client maps its files to (path, <see cref="HardLinkScanAction"/>) pairs.
/// </summary>
protected (bool HasHardlinks, bool HasErrors) ScanForHardLinks(
IEnumerable<(string FilePath, HardLinkScanAction Action)> files,
bool ignoreRootDirs)
{
foreach ((string filePath, HardLinkScanAction action) in files)
{
if (action is HardLinkScanAction.TreatAsLinked)
{
return (true, false);
}
if (action is HardLinkScanAction.SkipUnwanted)
{
_logger.LogDebug("skip | file is not downloaded | {File}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, ignoreRootDirs);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath);
return (false, true);
}
if (hardlinkCount > 0)
{
return (true, false);
}
}
return (false, false);
}
/// <summary>
/// Counts unwanted files and collects the indices to block.
/// Don't yield a file the client excludes from the count, such as one with no index.
/// Deluge and rTorrent validate the bare filename but log the relative path.
/// With <paramref name="deleteIfAnyFileBlocked"/> on, the first unwanted file sets <c>DeleteImmediately</c> and ends the scan.
/// </summary>
protected (List<int> UnwantedIndices, long TotalFiles, long TotalUnwantedFiles, bool DeleteImmediately) ScanFilesForBlocking(
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files,
BlocklistType blocklistType,
ConcurrentBag<string> patterns,
ConcurrentBag<Regex> regexes,
bool deleteIfAnyFileBlocked)
{
List<int> unwantedIndices = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
foreach ((int index, string validationName, string logName, FileBlockAction action) in files)
{
totalFiles++;
if (action is FileBlockAction.AlreadySkipped)
{
_logger.LogTrace("File is already skipped | {File}", logName);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(validationName, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {File}", logName);
continue;
}
_logger.LogInformation("unwanted file found | {File}", logName);
totalUnwantedFiles++;
if (deleteIfAnyFileBlocked)
{
return (unwantedIndices, totalFiles, totalUnwantedFiles, true);
}
unwantedIndices.Add(index);
}
return (unwantedIndices, totalFiles, totalUnwantedFiles, false);
}
/// <summary>
/// Scans the files, sets the removal verdict on <paramref name="result"/> and marks the unwanted files as skipped.
/// </summary>
/// <param name="markFilesAsSkipped">Marks the given file indices as skipped in the client.</param>
protected async Task ApplyFileBlockingAsync(
BlockFilesResult result,
string name,
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> files,
bool deleteIfAnyFileBlocked,
Func<List<int>, Task> markFilesAsSkipped)
{
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
(List<int> unwantedIndices, long totalFiles, long totalUnwantedFiles, bool deleteImmediately) =
ScanFilesForBlocking(files, blocklistType, patterns, regexes, deleteIfAnyFileBlocked);
if (deleteImmediately)
{
_logger.LogDebug("at least one file is blocked for {Name}", name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return;
}
if (unwantedIndices.Count is 0)
{
_logger.LogDebug("No unwanted files found for {Name}", name);
return;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {Name}", name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {Count} unwanted files as skipped for {Name}", unwantedIndices.Count, name);
await _dryRunInterceptor.InterceptAsync(() => MarkFilesAsSkipped(name, unwantedIndices, markFilesAsSkipped));
}
/// <summary>
/// A failed priority update leaves the verdict in place.
/// </summary>
private async Task MarkFilesAsSkipped(string name, List<int> unwantedIndices, Func<List<int>, Task> markFilesAsSkipped)
{
try
{
await markFilesAsSkipped(unwantedIndices);
}
catch (Exception exception)
{
_logger.LogError(exception, "Failed to mark files as skipped | {Name}", name);
}
}
/// <inheritdoc/>
public abstract Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
public async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
useTag = useTag && SupportsTags;
await _dryRunInterceptor.InterceptAsync(() => ChangeCategoryInClientAsync(torrent, targetCategory, useTag));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag);
if (!useTag)
{
torrent.Category = targetCategory;
}
}
/// <summary>
/// Clients without tags fall back to a category change when asked for a tag.
/// </summary>
protected virtual bool SupportsTags => false;
/// <summary>
/// Applies the category, or the tag when <paramref name="useTag"/> is set, in the client.
/// </summary>
protected abstract Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag);
/// <inheritdoc/>
public abstract Task CreateCategoryAsync(string name);
@@ -0,0 +1,17 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// What <see cref="DownloadService.ScanFilesForBlocking"/> does with one file.
/// </summary>
public enum FileBlockAction
{
/// <summary>
/// Already unwanted in the client; count it and skip the blocklist.
/// </summary>
AlreadySkipped,
/// <summary>
/// Check the file name against the blocklist.
/// </summary>
CheckBlocklist
}
@@ -0,0 +1,22 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient;
/// <summary>
/// What <see cref="DownloadService.ScanForHardLinks"/> does with one file.
/// </summary>
public enum HardLinkScanAction
{
/// <summary>
/// File not downloaded; log and skip it.
/// </summary>
SkipUnwanted,
/// <summary>
/// Read the file's hardlink count from disk.
/// </summary>
CheckHardLinks,
/// <summary>
/// Count the torrent as hardlinked without touching disk.
/// </summary>
TreatAsLinked
}
@@ -1,6 +1,4 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
@@ -73,77 +71,30 @@ public partial class QBitService
return result;
}
List<int> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
foreach (TorrentContent file in files)
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems()
{
if (!file.Index.HasValue)
foreach (TorrentContent file in files)
{
_logger.LogTrace("Skipping file with no index | {File}", file.Name);
continue;
if (!file.Index.HasValue)
{
_logger.LogTrace("Skipping file with no index | {File}", file.Name);
continue;
}
yield return (file.Index.Value, file.Name, file.Name, file.Priority is TorrentContentPriority.Skip
? FileBlockAction.AlreadySkipped
: FileBlockAction.CheckBlocklist);
}
totalFiles++;
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogTrace("File is already skipped | {File}", file.Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {File}", file.Name);
continue;
}
_logger.LogInformation("unwanted file found | {File}", file.Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {Name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
unwantedFiles.Add(file.Index.Value);
totalUnwantedFiles++;
}
if (unwantedFiles.Count is 0)
await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices =>
{
_logger.LogDebug("No unwanted files found for {Name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {Name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {Count} unwanted files as skipped for {Name}", totalUnwantedFiles, download.Name);
foreach (int fileIndex in unwantedIndices)
{
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
}
});
foreach (int fileIndex in unwantedFiles)
{
await _dryRunInterceptor.InterceptAsync(() => MarkFileAsSkipped(hash, fileIndex));
}
return result;
}
protected virtual async Task MarkFileAsSkipped(string hash, int fileIndex)
{
await _client.SetFilePriorityAsync(hash, fileIndex, TorrentContentPriority.Skip);
}
}
@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
@@ -125,115 +124,49 @@ public partial class QBitService
await _dryRunInterceptor.InterceptAsync(() => CreateCategory(name));
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
/// <inheritdoc/>
protected override async Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
QBitItemWrapper qBitTorrent = (QBitItemWrapper)torrent;
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(qBitTorrent.Hash);
if (files is null)
{
return;
_logger.LogDebug("failed to find files for {Name}", qBitTorrent.Name);
return null;
}
foreach (QBitItemWrapper torrent in downloads.Cast<QBitItemWrapper>())
IEnumerable<(string FilePath, HardLinkScanAction Action)> BuildScanItems()
{
if (string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
IReadOnlyList<TorrentContent>? files = await _client.GetTorrentContentsAsync(torrent.Hash);
if (files is null)
{
_logger.LogDebug("failed to find files for {Name}", torrent.Name);
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
bool hasHardlinks = false;
bool hasErrors = false;
foreach (TorrentContent file in files)
{
if (!file.Index.HasValue)
{
_logger.LogDebug("skip | file index is null for {Name}", torrent.Name);
hasHardlinks = true;
break;
_logger.LogDebug("skip | file index is null for {Name}", qBitTorrent.Name);
yield return (string.Empty, HardLinkScanAction.TreatAsLinked);
yield break;
}
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.SavePath, file.Name),
Path.Combine(qBitTorrent.Info.SavePath, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority is TorrentContentPriority.Skip)
{
_logger.LogDebug("skip | file is not downloaded | {File}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath);
hasErrors = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag));
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory, unlinkedConfig.UseTag);
if (unlinkedConfig.UseTag)
{
_logger.LogInformation("tag added for {Name}", torrent.Name);
}
else
{
_logger.LogInformation("category changed for {Name}", torrent.Name);
torrent.Category = unlinkedConfig.TargetCategory;
yield return (filePath, file.Priority is TorrentContentPriority.Skip
? HardLinkScanAction.SkipUnwanted
: HardLinkScanAction.CheckHardLinks);
}
}
return BuildScanItems();
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
protected override bool SupportsTags => true;
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeCategory(torrent.Hash, targetCategory, useTag));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, useTag);
if (!useTag)
{
torrent.Category = targetCategory;
}
}
/// <inheritdoc/>
protected override async Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
await ChangeCategory(torrent.Hash, targetCategory, useTag);
protected async Task CreateCategory(string name)
{
@@ -83,62 +83,34 @@ public partial class QBitService
return result;
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
/// <inheritdoc/>
protected override Func<Task<bool>>? GetAltSpeedLimitProbe() => IsAltSpeedLimitActiveAsync;
/// <summary>
/// qBittorrent can stall on metadata before any content arrives.
/// That stall gets its own strike before the shared stall check.
/// </summary>
protected override async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) slowResult = await CheckIfSlow(wrapper);
if (slowResult.ShouldRemove)
if (!((QBitItemWrapper)wrapper).IsMetadataDownloading())
{
return slowResult;
return await base.CheckIfStuck(wrapper);
}
return await CheckIfStuck(wrapper);
}
QueueCleanerConfig queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
if (queueCleanerConfig.DownloadingMetadataMaxStrikes <= 0)
{
_logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
bool shouldRemove = await _striker.StrikeAndCheckLimit(
wrapper.Hash,
wrapper.Name,
queueCleanerConfig.DownloadingMetadataMaxStrikes,
StrikeType.DownloadingMetadata
);
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, IsAltSpeedLimitActiveAsync);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (((QBitItemWrapper)wrapper).IsMetadataDownloading())
{
var queueCleanerConfig = ContextProvider.Get<QueueCleanerConfig>(nameof(QueueCleanerConfig));
if (queueCleanerConfig.DownloadingMetadataMaxStrikes > 0)
{
bool shouldRemove = await _striker.StrikeAndCheckLimit(
wrapper.Hash,
wrapper.Name,
queueCleanerConfig.DownloadingMetadataMaxStrikes,
StrikeType.DownloadingMetadata
);
return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove, false);
}
return (false, DeleteReason.None, false, false);
}
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
return (shouldRemove, DeleteReason.DownloadingMetadata, shouldRemove, false);
}
}
@@ -1,5 +1,3 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -21,7 +19,7 @@ public partial class RTorrentService
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name);
return result;
}
@@ -44,7 +42,7 @@ public partial class RTorrentService
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
{
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
_logger.LogDebug("skip files check | download is private | {Name}", download.Name);
return result;
}
@@ -56,7 +54,7 @@ public partial class RTorrentService
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
_logger.LogDebug(exception, "failed to find files in the download client | {Name}", download.Name);
return result;
}
@@ -65,84 +63,24 @@ public partial class RTorrentService
return result;
}
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
List<(int Index, int Priority)> priorityUpdates = [];
foreach (var file in files)
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems()
{
totalFiles++;
string fileName = Path.GetFileName(file.Path);
if (result.ShouldRemove)
foreach (RTorrentFile file in files)
{
continue;
yield return (file.Index, Path.GetFileName(file.Path), file.Path, file.Priority == 0
? FileBlockAction.AlreadySkipped
: FileBlockAction.CheckBlocklist);
}
}
if (file.Priority == 0)
await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices =>
{
foreach (int index in unwantedIndices)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
continue;
await _client.SetFilePriorityAsync(hash, index, 0);
}
if (!_filenameEvaluator.IsValid(fileName, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
hasPriorityUpdates = true;
priorityUpdates.Add((file.Index, 0));
_logger.LogInformation("unwanted file found | {file}", file.Path);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
continue;
}
_logger.LogTrace("File is valid | {file}", file.Path);
}
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", priorityUpdates.Count, download.Name);
foreach (var (index, priority) in priorityUpdates)
{
await _dryRunInterceptor.InterceptAsync(() => SetFilePriority(hash, index, priority));
}
});
return result;
}
protected virtual async Task SetFilePriority(string hash, int index, int priority)
{
await _client.SetFilePriorityAsync(hash, index, priority);
}
}
@@ -1,7 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
@@ -66,17 +64,6 @@ public partial class RTorrentService
return Task.FromResult<IReadOnlyList<string>>(claimed.ToList());
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
@@ -113,104 +100,42 @@ public partial class RTorrentService
return Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
/// <inheritdoc/>
protected override async Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
RTorrentItemWrapper rTorrent = (RTorrentItemWrapper)torrent;
List<RTorrentFile> files;
try
{
return;
files = await _client.GetTorrentFilesAsync(rTorrent.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {Name}", rTorrent.Name);
return null;
}
foreach (RTorrentItemWrapper torrent in downloads.Cast<RTorrentItemWrapper>())
List<(string FilePath, HardLinkScanAction Action)> scanItems = [];
foreach (RTorrentFile file in files)
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(rTorrent.Info.Directory ?? rTorrent.Info.BasePath ?? "", file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(torrent.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
foreach (var file in files)
{
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.Directory ?? torrent.Info.BasePath ?? "", file.Path),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
continue;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
torrent.Category = unlinkedConfig.TargetCategory;
scanItems.Add((filePath, file.Priority <= 0
? HardLinkScanAction.SkipUnwanted
: HardLinkScanAction.CheckHardLinks));
}
return scanItems;
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
ChangeLabel(torrent.Hash, targetCategory);
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
@@ -67,44 +67,4 @@ public partial class RTorrentService
return result;
}
private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}
@@ -1,10 +1,9 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
@@ -17,21 +16,21 @@ public partial class TransmissionService
TorrentInfo? download = await GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.FileStats is null || download.FileStats.Length == 0)
if (download?.FileStats is null || download.FileStats.Length == 0 || download.Name is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
_logger.LogDebug("Failed to find torrent {Hash} in the {Name} download client", hash, _downloadClientConfig.Name);
return result;
}
if (download.Files is null)
{
_logger.LogDebug("torrent {hash} has no files", hash);
_logger.LogDebug("Torrent {Hash} has no files", hash);
return result;
}
if (ignoredDownloads.Count > 0 && download.ShouldIgnore(ignoredDownloads))
{
_logger.LogDebug("skip | download is ignored | {name}", download.Name);
_logger.LogDebug("skip | download is ignored | {Name}", download.Name);
return result;
}
@@ -46,72 +45,34 @@ public partial class TransmissionService
if (malwareBlockerConfig.IgnorePrivate && isPrivate)
{
// ignore private trackers
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
_logger.LogDebug("skip files check | download is private | {Name}", download.Name);
return result;
}
List<long> unwantedFiles = [];
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
for (int i = 0; i < download.Files.Length; i++)
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems()
{
if (download.FileStats?[i].Wanted == null)
for (int i = 0; i < download.Files.Length; i++)
{
_logger.LogTrace("Skipping file with no stats | {file}", download.Files[i].Name);
continue;
if (download.FileStats?[i].Wanted == null)
{
_logger.LogTrace("Skipping file with no stats | {File}", download.Files[i].Name);
continue;
}
yield return (i, download.Files[i].Name, download.Files[i].Name, download.FileStats[i].Wanted!.Value
? FileBlockAction.CheckBlocklist
: FileBlockAction.AlreadySkipped);
}
totalFiles++;
if (!download.FileStats[i].Wanted.Value)
{
_logger.LogTrace("File is already skipped | {file}", download.Files[i].Name);
totalUnwantedFiles++;
continue;
}
if (_filenameEvaluator.IsValid(download.Files[i].Name, blocklistType, patterns, regexes))
{
_logger.LogTrace("File is valid | {file}", download.Files[i].Name);
continue;
}
_logger.LogInformation("unwanted file found | {file}", download.Files[i].Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
unwantedFiles.Add(i);
totalUnwantedFiles++;
}
if (unwantedFiles.Count is 0)
await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices =>
{
_logger.LogDebug("No unwanted files found for {name}", download.Name);
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", totalUnwantedFiles, download.Name);
await _dryRunInterceptor.InterceptAsync(() => SetUnwantedFiles(download.Id, unwantedFiles.ToArray()));
await _client.TorrentSetAsync(new TorrentSettings
{
Ids = [download.Id],
FilesUnwanted = unwantedIndices.Select(i => (long)i).ToArray(),
});
});
return result;
}
@@ -1,6 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
@@ -54,13 +53,6 @@ public partial class TransmissionService
});
/// <inheritdoc/>
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules)
{
return downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
{
return downloads
@@ -98,37 +90,28 @@ public partial class TransmissionService
await Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
/// <inheritdoc/>
protected override Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)torrent;
if (string.IsNullOrEmpty(transmissionTorrent.Info.DownloadDir))
{
return;
return Task.FromResult<IEnumerable<(string FilePath, HardLinkScanAction Action)>?>(null);
}
foreach (TransmissionItemWrapper torrent in downloads.Cast<TransmissionItemWrapper>())
if (transmissionTorrent.Info.Files is null || transmissionTorrent.Info.FileStats is null)
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Info.DownloadDir))
_logger.LogDebug("skip | download has no files | {name}", transmissionTorrent.Name);
return Task.FromResult<IEnumerable<(string FilePath, HardLinkScanAction Action)>?>(null);
}
IEnumerable<(string FilePath, HardLinkScanAction Action)> BuildScanItems()
{
for (int i = 0; i < transmissionTorrent.Info.Files.Length; i++)
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
if (torrent.Info.Files is null || torrent.Info.FileStats is null)
{
_logger.LogDebug("skip | download has no files | {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
for (int i = 0; i < torrent.Info.Files.Length; i++)
{
TransmissionTorrentFiles file = torrent.Info.Files[i];
TransmissionTorrentFileStats stats = torrent.Info.FileStats[i];
TransmissionTorrentFiles file = transmissionTorrent.Info.Files[i];
TransmissionTorrentFileStats stats = transmissionTorrent.Info.FileStats[i];
if (stats.Wanted is null or false || string.IsNullOrEmpty(file.Name))
{
@@ -136,99 +119,40 @@ public partial class TransmissionService
}
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.DownloadDir, file.Name),
Path.Combine(transmissionTorrent.Info.DownloadDir, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
long hardlinkCount = _hardLinkFileService.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
yield return (filePath, HardLinkScanAction.CheckHardLinks);
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
string currentCategory = torrent.Category ?? string.Empty;
if (unlinkedConfig.UseTag)
{
string[] newLabels = torrent.Tags
.Append(unlinkedConfig.TargetCategory)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(torrent.Info.Id, newLabels));
_logger.LogInformation("label added for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory, isTag: true);
continue;
}
string newLocation = torrent.Info.GetNewLocationByAppend(unlinkedConfig.TargetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(torrent.Info.Id, newLocation));
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(currentCategory, unlinkedConfig.TargetCategory);
torrent.Category = unlinkedConfig.TargetCategory;
}
return Task.FromResult<IEnumerable<(string FilePath, HardLinkScanAction Action)>?>(BuildScanItems());
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
protected override bool SupportsTags => true;
/// <inheritdoc/>
protected override async Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
var transmissionTorrent = (TransmissionItemWrapper)torrent;
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
TransmissionItemWrapper transmissionTorrent = (TransmissionItemWrapper)torrent;
if (useTag)
{
string[] newLabels = torrent.Tags
string[] newLabels = transmissionTorrent.Tags
.Append(targetCategory)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
await _dryRunInterceptor.InterceptAsync(() => ChangeLabels(transmissionTorrent.Info.Id, newLabels));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory, isTag: true);
await ChangeLabels(transmissionTorrent.Info.Id, newLabels);
return;
}
string newLocation = transmissionTorrent.Info.GetNewLocationByAppend(targetCategory);
await _dryRunInterceptor.InterceptAsync(() => ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
await ChangeDownloadLocation(transmissionTorrent.Info.Id, newLocation);
}
protected virtual async Task ChangeDownloadLocation(long downloadId, string newLocation)
@@ -1,11 +1,5 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Cleanuparr.Domain.Enums;
using Microsoft.Extensions.Logging;
using Transmission.API.RPC.Arguments;
using Transmission.API.RPC.Entity;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.Transmission;
@@ -72,53 +66,6 @@ public partial class TransmissionService
return result;
}
protected virtual async Task SetUnwantedFiles(long downloadId, long[] unwantedFiles)
{
await _client.TorrentSetAsync(new TorrentSettings
{
Ids = [downloadId],
FilesUnwanted = unwantedFiles,
});
}
private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper, IsAltSpeedLimitActiveAsync);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
/// <inheritdoc/>
protected override Func<Task<bool>>? GetAltSpeedLimitProbe() => IsAltSpeedLimitActiveAsync;
}
@@ -1,5 +1,3 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
@@ -63,61 +61,22 @@ public partial class UTorrentService
return result;
}
List<int> fileIndexes = new(files.Count);
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
for (int i = 0; i < files.Count; i++)
IEnumerable<(int Index, string ValidationName, string LogName, FileBlockAction Action)> BuildScanItems()
{
var file = files[i];
if (file.Priority == 0) // Already skipped
for (int i = 0; i < files.Count; i++)
{
totalUnwantedFiles++;
continue;
}
if (file.Priority != 0 && !_filenameEvaluator.IsValid(file.Name, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
fileIndexes.Add(i);
_logger.LogInformation("unwanted file found | {File}", file.Name);
if (malwareBlockerConfig.DeleteIfAnyFileBlocked)
{
_logger.LogDebug("at least one file is blocked for {Name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AtLeastOneFileBlocked;
return result;
}
UTorrentFile file = files[i];
yield return (i, file.Name, file.Name, file.Priority == 0
? FileBlockAction.AlreadySkipped
: FileBlockAction.CheckBlocklist);
}
}
if (fileIndexes.Count is 0)
await ApplyFileBlockingAsync(result, download.Name, BuildScanItems(), malwareBlockerConfig.DeleteIfAnyFileBlocked, async unwantedIndices =>
{
return result;
}
_logger.LogDebug("changing priorities | torrent {Hash}", hash);
if (totalUnwantedFiles == files.Count)
{
_logger.LogDebug("All files are blocked for {Name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
await _dryRunInterceptor.InterceptAsync(() => ChangeFilesPriority(hash, fileIndexes));
await _client.SetFilesPriorityAsync(hash, unwantedIndices, 0);
});
return result;
}
protected virtual async Task ChangeFilesPriority(string hash, List<int> fileIndexes)
{
await _client.SetFilesPriorityAsync(hash, fileIndexes, 0);
}
}
}
@@ -1,9 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
@@ -60,17 +58,6 @@ public partial class UTorrentService
return files?.Select(f => f.Name).Where(name => !string.IsNullOrEmpty(name)).ToList() ?? [];
});
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<ISeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(rule => rule.Categories.Any(cat => cat.Equals(x.Category, StringComparison.OrdinalIgnoreCase))))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => unlinkedConfig.Categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
@@ -89,95 +76,32 @@ public partial class UTorrentService
await Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads, UnlinkedConfig unlinkedConfig)
/// <inheritdoc/>
protected override async Task<IEnumerable<(string FilePath, HardLinkScanAction Action)>?> GetHardLinkScanItemsAsync(ITorrentItemWrapper torrent)
{
if (downloads?.Count is null or 0)
UTorrentItemWrapper uTorrent = (UTorrentItemWrapper)torrent;
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(uTorrent.Hash);
List<(string FilePath, HardLinkScanAction Action)> scanItems = [];
foreach (UTorrentFile file in files ?? [])
{
return;
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(uTorrent.Info.SavePath, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
scanItems.Add((filePath, file.Priority <= 0
? HardLinkScanAction.SkipUnwanted
: HardLinkScanAction.CheckHardLinks));
}
foreach (UTorrentItemWrapper torrent in downloads.Cast<UTorrentItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
List<UTorrentFile>? files = await _client.GetTorrentFilesAsync(torrent.Hash);
bool hasHardlinks = false;
bool hasErrors = false;
foreach (var file in files ?? [])
{
string filePath = PathHelper.NormalizeAndRemap(
Path.Combine(torrent.Info.SavePath, file.Name),
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {File}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, unlinkedConfig.IgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {File}", filePath);
hasErrors = true;
break;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {Name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, unlinkedConfig.TargetCategory));
await _eventPublisher.PublishCategoryChanged(torrent.Category, unlinkedConfig.TargetCategory);
_logger.LogInformation("category changed for {Name}", torrent.Name);
torrent.Category = unlinkedConfig.TargetCategory;
}
return scanItems;
}
/// <inheritdoc/>
public override async Task ChangeTorrentCategoryAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag)
{
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
SetDownloadClientContext();
string currentCategory = torrent.Category ?? string.Empty;
await _dryRunInterceptor.InterceptAsync(() => ChangeLabel(torrent.Hash, targetCategory));
await _eventPublisher.PublishCategoryChanged(currentCategory, targetCategory);
torrent.Category = targetCategory;
}
protected override Task ChangeCategoryInClientAsync(ITorrentItemWrapper torrent, string targetCategory, bool useTag) =>
ChangeLabel(torrent.Hash, targetCategory);
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
@@ -1,10 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.Extensions;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent;
@@ -82,45 +79,4 @@ public partial class UTorrentService
return result;
}
private async Task<(bool, DeleteReason, bool, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient, bool ChangeCategory)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {Name}", wrapper.Name);
return (false, DeleteReason.None, false, false);
}
return await _queueRuleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}