mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Deduplicate download client logic (#812)
This commit is contained in:
1 parent
124fcbe747
commit
aff3880a0d
38 files changed
+2268
-1068
No files matched your search
+76
@@ -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]
|
||||
|
||||
+370
-3
@@ -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>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
|
||||
+123
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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()
|
||||
{
|
||||
|
||||
+134
@@ -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>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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)
|
||||
|
||||
+113
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -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()
|
||||
{
|
||||
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
|
||||
+22
-81
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
-99
@@ -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)
|
||||
{
|
||||
|
||||
-43
@@ -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
|
||||
}
|
||||
+18
-67
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
-89
@@ -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)
|
||||
{
|
||||
|
||||
+19
-47
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
-76
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
-101
@@ -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)
|
||||
{
|
||||
|
||||
-40
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
-64
@@ -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;
|
||||
}
|
||||
|
||||
+28
-104
@@ -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)
|
||||
|
||||
+3
-56
@@ -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;
|
||||
}
|
||||
+10
-51
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-95
@@ -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)
|
||||
{
|
||||
|
||||
-44
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ const utorrent = new UTorrentDriver();
|
||||
* Two scenarios may share the same physical client (e.g. qBittorrent category
|
||||
* vs tag) — they use different source categories so they never overlap, and
|
||||
* the physical client is prepared (ready/clear/reset) only once.
|
||||
* Label-only clients (Deluge, uTorrent) fall back to a label change when useTag is on.
|
||||
*/
|
||||
interface Scenario {
|
||||
key: string;
|
||||
@@ -143,6 +144,18 @@ const scenarios: Scenario[] = [
|
||||
return (await deluge.getTorrentLabel(hash)) === TARGET;
|
||||
},
|
||||
},
|
||||
// Deluge: useTag falls back to the label.
|
||||
{
|
||||
key: 'Deluge (tag fallback)', driver: deluge, physicalSlug: 'deluge', source: 'dl-tag', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_HOST, soloSeedSparable: true,
|
||||
async addSeeding(name, announce) {
|
||||
const d = buildTorrent('deluge', name, announce);
|
||||
await deluge.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'dl-tag', name: d.name, infoHash: d.infoHash });
|
||||
return d.infoHash;
|
||||
},
|
||||
async isMoved(hash) {
|
||||
return (await deluge.getTorrentLabel(hash)) === TARGET;
|
||||
},
|
||||
},
|
||||
// µTorrent — label (bridge-networked → reaches opentracker via host.docker.internal).
|
||||
{
|
||||
key: 'uTorrent', driver: utorrent, physicalSlug: 'utorrent', source: 'ut-src', useTag: false, aliveAnnounce: ALIVE_ANNOUNCE_BRIDGE, soloSeedSparable: false,
|
||||
@@ -155,6 +168,18 @@ const scenarios: Scenario[] = [
|
||||
return (await utorrent.getTorrentLabel(hash)) === TARGET;
|
||||
},
|
||||
},
|
||||
// µTorrent: useTag falls back to the label.
|
||||
{
|
||||
key: 'uTorrent (tag fallback)', driver: utorrent, physicalSlug: 'utorrent', source: 'ut-tag', useTag: true, aliveAnnounce: ALIVE_ANNOUNCE_BRIDGE, soloSeedSparable: false,
|
||||
async addSeeding(name, announce) {
|
||||
const d = buildTorrent('utorrent', name, announce);
|
||||
await utorrent.addSeedingTorrent({ metainfo: d.metainfo, savePath: CLIENT_DOWNLOADS, category: 'ut-tag', name: d.name, infoHash: d.infoHash });
|
||||
return d.infoHash;
|
||||
},
|
||||
async isMoved(hash) {
|
||||
return (await utorrent.getTorrentLabel(hash)) === TARGET;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function waitForRegistered(driver: DriverLike, infoHash: string, timeoutMs = 20_000): Promise<void> {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent';
|
||||
import { TransmissionDriver } from '../helpers/torrent-clients/transmission';
|
||||
import { DelugeDriver } from '../helpers/torrent-clients/deluge';
|
||||
import { UTorrentDriver } from '../helpers/torrent-clients/utorrent';
|
||||
import { RTorrentDriver } from '../helpers/torrent-clients/rtorrent';
|
||||
import { buildFolderTorrent, resetDirectory } from '../helpers/torrent-fixtures';
|
||||
import { mkdirShared } from '../helpers/shared-volume';
|
||||
|
||||
@@ -28,6 +29,7 @@ const qbit = new QBittorrentDriver();
|
||||
const transmission = new TransmissionDriver();
|
||||
const deluge = new DelugeDriver();
|
||||
const utorrent = new UTorrentDriver();
|
||||
const rtorrent = new RTorrentDriver();
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
@@ -98,6 +100,20 @@ const scenarios: Scenario[] = [
|
||||
return fx;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rTorrent', driver: rtorrent, slug: 'rtorrent', category: 'rt-stop',
|
||||
async addSeeding() {
|
||||
const dir = join(HOST_DOWNLOADS, 'rtorrent');
|
||||
mkdirShared(dir);
|
||||
const fx = buildFolderTorrent(dir, 'stop-rule-rtorrent', 32_768, ANNOUNCE_HOST);
|
||||
await rtorrent.addSeedingTorrent({ metainfo: fx.metainfo, savePath: CLIENT_DOWNLOADS, category: 'rt-stop', name: fx.name, infoHash: fx.infoHash });
|
||||
// rTorrent lists a torrent as seeding only after its hash check completes.
|
||||
await expect
|
||||
.poll(() => rtorrent.getComplete(fx.infoHash), { message: 'rTorrent: hash check never completed', timeout: 30_000, intervals: [500] })
|
||||
.toBe(1);
|
||||
return fx;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function isPresent(driver: DriverLike, infoHash: string): Promise<boolean> {
|
||||
|
||||
@@ -175,4 +175,12 @@ export class DelugeDriver implements TorrentClientDriver {
|
||||
const result = await this.call<Record<string, { name: string }>>('core.get_torrents_status', [{}, ['name']]);
|
||||
return Object.entries(result ?? {}).map(([hash, info]) => ({ hash, name: info.name }));
|
||||
}
|
||||
|
||||
async getFilePriorities(infoHash: string): Promise<number[]> {
|
||||
const result = await this.call<Record<string, { file_priorities: number[] }>>(
|
||||
'core.get_torrents_status',
|
||||
[{ id: [infoHash] }, ['file_priorities']],
|
||||
);
|
||||
return result?.[infoHash]?.file_priorities ?? [];
|
||||
}
|
||||
}
|
||||
@@ -217,4 +217,15 @@ export class QBittorrentDriver implements TorrentClientDriver {
|
||||
const items: Array<{ hash: string; name: string }> = await res.json();
|
||||
return items.map((t) => ({ hash: t.hash, name: t.name }));
|
||||
}
|
||||
|
||||
async getFilePriorities(infoHash: string): Promise<number[]> {
|
||||
const res = await fetch(`${this.directHost}/api/v2/torrents/files?hash=${infoHash.toLowerCase()}`, {
|
||||
headers: this.cookie ? { Cookie: this.cookie } : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`qBittorrent files failed: ${res.status}`);
|
||||
}
|
||||
const items: Array<{ priority: number }> = await res.json();
|
||||
return items.map((f) => f.priority);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,12 @@ export class RTorrentDriver implements TorrentClientDriver {
|
||||
return { hash: String(arr[0]).toLowerCase(), name: String(arr[1]) };
|
||||
});
|
||||
}
|
||||
|
||||
async getFilePriorities(infoHash: string): Promise<number[]> {
|
||||
const result = await this.call('f.multicall', [infoHash.toUpperCase(), '', 'f.priority=']);
|
||||
if (!Array.isArray(result)) return [];
|
||||
return result.map((row: unknown) => Number((row as unknown[])[0]));
|
||||
}
|
||||
}
|
||||
|
||||
type XmlRpcValue = string | number | boolean | { type: 'base64'; value: string };
|
||||
@@ -189,7 +195,7 @@ function parseValue(xml: string): unknown {
|
||||
return out;
|
||||
}
|
||||
const scalar = xml.match(new RegExp(`<${type}>([\\s\\S]*?)<\\/${type}>`))?.[1] ?? '';
|
||||
if (type === 'int' || type === 'i4') return Number(scalar);
|
||||
if (type === 'int' || type === 'i4' || type === 'i8') return Number(scalar);
|
||||
if (type === 'boolean') return scalar === '1';
|
||||
if (type === 'double') return Number(scalar);
|
||||
return decodeXml(scalar);
|
||||
|
||||
@@ -138,4 +138,12 @@ export class TransmissionDriver implements TorrentClientDriver {
|
||||
const args = await this.call('torrent-get', { fields: ['hashString', 'name'] });
|
||||
return (args.torrents ?? []).map((t: { hashString: string; name: string }) => ({ hash: t.hashString, name: t.name }));
|
||||
}
|
||||
|
||||
/** Maps fileStats.wanted to 0/1 like the other drivers. */
|
||||
async getFilePriorities(infoHash: string): Promise<number[]> {
|
||||
const args = await this.call('torrent-get', { ids: [infoHash], fields: ['hashString', 'fileStats'] });
|
||||
const t = (args.torrents ?? [])[0];
|
||||
const stats: Array<{ wanted: boolean }> = t?.fileStats ?? [];
|
||||
return stats.map((f) => (f.wanted ? 1 : 0));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,11 @@ export interface TorrentClientDriver {
|
||||
* False when the client does not know the torrent.
|
||||
*/
|
||||
isStopped(infoHash: string): Promise<boolean>;
|
||||
/**
|
||||
* Per-file priority in file order.
|
||||
* 0 means the client skips the file.
|
||||
*/
|
||||
getFilePriorities(infoHash: string): Promise<number[]>;
|
||||
/**
|
||||
* Remove every torrent currently registered with the client without deleting
|
||||
* data on disk. Called at the start of each test to make the spec
|
||||
|
||||
@@ -166,4 +166,16 @@ export class UTorrentDriver implements TorrentClientDriver {
|
||||
await fetch(url, { headers: this.requestHeaders() });
|
||||
}
|
||||
}
|
||||
|
||||
/** Priority sits at index 3 of each getfiles row. */
|
||||
async getFilePriorities(infoHash: string): Promise<number[]> {
|
||||
const url = `${this.directHost}/gui/?token=${encodeURIComponent(this.token)}&action=getfiles&hash=${infoHash.toUpperCase()}`;
|
||||
const res = await fetch(url, { headers: this.requestHeaders() });
|
||||
if (!res.ok) {
|
||||
throw new Error(`uTorrent getfiles: ${res.status}`);
|
||||
}
|
||||
const body: { files?: [string, unknown[][]] } = await res.json();
|
||||
const rows = body.files?.[1] ?? [];
|
||||
return rows.map((row) => Number(row[3]));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { buildMultiFileTorrent, resetDirectory } from '../helpers/torrent-fixtur
|
||||
import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client';
|
||||
import { TEST_CONFIG, farFutureCron } from '../helpers/test-config';
|
||||
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
|
||||
import { buildHubConnection, waitForEvent } from '../helpers/api/signalr';
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the `DeleteIfAnyFileBlocked` setting on the
|
||||
@@ -397,3 +398,352 @@ function runClientScenarios(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the `AllFilesBlocked` path.
|
||||
* Every file matches the blocklist and `DeleteIfAnyFileBlocked` stays off.
|
||||
* The suite pins removal with DeleteReason.AllFilesBlocked on every client.
|
||||
*/
|
||||
async function findEventWithDeleteReason(
|
||||
token: string,
|
||||
itemHash: string,
|
||||
deleteReason: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const connection = buildHubConnection({ accessToken: token, hubUrl: '/api/hubs/app' });
|
||||
await connection.start();
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const received = waitForEvent<Array<Record<string, unknown>>>(connection, 'EventsReceived');
|
||||
await connection.invoke('GetRecentEvents', 20);
|
||||
const events = await received;
|
||||
const match = events.find(
|
||||
(e) => String(e.itemHash).toLowerCase() === itemHash.toLowerCase() && e.deleteReason === deleteReason,
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
await connection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe.serial('Malware blocker — AllFilesBlocked', () => {
|
||||
let token: string;
|
||||
const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl);
|
||||
let sonarrId: string | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
token = await loginAndGetToken();
|
||||
|
||||
for (const client of await listDownloadClients(token)) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
|
||||
mkdirShared(join(HOST_DOWNLOADS, 'malware-blocker'));
|
||||
writeFileShared(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n');
|
||||
|
||||
await arr.waitReady();
|
||||
await stubArrQueue(arr, []);
|
||||
|
||||
const sonarrRes = await createArrInstance(token, 'sonarr', {
|
||||
name: 'Malware Blocker AllFilesBlocked Stub',
|
||||
url: TEST_CONFIG.mocks.arrUrl,
|
||||
apiKey: 'malware-blocker-all-files-e2e',
|
||||
version: 4,
|
||||
});
|
||||
expect(sonarrRes.status).toBe(201);
|
||||
const sonarrBody = await sonarrRes.json();
|
||||
sonarrId = sonarrBody.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (sonarrId) {
|
||||
await deleteArrInstance(token, 'sonarr', sonarrId);
|
||||
}
|
||||
await arr.resetAll();
|
||||
});
|
||||
|
||||
for (const fixture of ALL_CLIENTS) {
|
||||
runAllFilesBlockedScenario(fixture, () => ({ token, arr }));
|
||||
}
|
||||
});
|
||||
|
||||
function runAllFilesBlockedScenario(
|
||||
fixture: TorrentClientFixture,
|
||||
getCtx: () => { token: string; arr: WireMockClient },
|
||||
): void {
|
||||
const { driver } = fixture;
|
||||
const slug = SLUG_BY_TYPE[driver.typeName];
|
||||
const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip;
|
||||
|
||||
describeFn(`${driver.typeName}`, () => {
|
||||
let clientId: string;
|
||||
let torrent: TorrentMeta;
|
||||
const hostScanDir = join(HOST_DOWNLOADS, slug);
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const { token } = getCtx();
|
||||
|
||||
resetDirectory(hostScanDir);
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const runId = Date.now().toString(36);
|
||||
const fx = buildMultiFileTorrent(hostScanDir, `all-blocked-${slug}-${runId}`, [
|
||||
{ filename: 'installer.exe', sizeBytes: 1024 },
|
||||
{ filename: 'setup.exe', sizeBytes: 1024 },
|
||||
]);
|
||||
torrent = { name: fx.name, infoHash: fx.infoHash };
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: `${driver.typeName} malware-blocker all-files-blocked e2e`,
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
});
|
||||
expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200);
|
||||
expect(createRes.status).toBeLessThan(300);
|
||||
clientId = (await createRes.json()).id;
|
||||
|
||||
await driver.addTorrent({
|
||||
metainfo: fx.metainfo,
|
||||
savePath: CLIENT_DOWNLOADS,
|
||||
name: fx.name,
|
||||
infoHash: fx.infoHash,
|
||||
});
|
||||
await waitForTorrents(driver, [torrent.infoHash]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const { token } = getCtx();
|
||||
if (clientId) {
|
||||
await deleteDownloadClient(token, clientId);
|
||||
}
|
||||
});
|
||||
|
||||
test('torrent with every file blocked is removed with DeleteReason.AllFilesBlocked', async () => {
|
||||
test.setTimeout(120_000);
|
||||
const { token, arr } = getCtx();
|
||||
|
||||
await setMalwareBlocker(token, {
|
||||
enabled: true,
|
||||
useAdvancedScheduling: true,
|
||||
cronExpression: farFutureCron(),
|
||||
deleteIfAnyFileBlocked: false,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: true,
|
||||
processNoContentId: false,
|
||||
sonarr: {
|
||||
enabled: true,
|
||||
blocklistPath: APP_BLOCKLIST_PATH,
|
||||
blocklistType: 'Blacklist',
|
||||
},
|
||||
});
|
||||
|
||||
const queueId = 2001;
|
||||
await stubArrQueue(arr, [
|
||||
{
|
||||
id: queueId,
|
||||
downloadId: torrent.infoHash,
|
||||
title: torrent.name,
|
||||
protocol: 'torrent',
|
||||
seriesId: 3,
|
||||
episodeId: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
const trig = await triggerJob(token, 'MalwareBlocker');
|
||||
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
||||
if ((await readDeletes(arr)).some((d) => d.id === queueId)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const deletes = await readDeletes(arr);
|
||||
const onDelete = deletes.find((d) => d.id === queueId);
|
||||
expect(onDelete, `sonarr queue item ${queueId} should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined();
|
||||
expect(onDelete!.removeFromClient, 'malware blocker should ask sonarr to remove the torrent from the client').toBe(true);
|
||||
expect(onDelete!.blocklist, 'malware blocker should ask sonarr to blocklist the release').toBe(true);
|
||||
|
||||
const event = await findEventWithDeleteReason(token, torrent.infoHash, 'AllFilesBlocked', POLL_TIMEOUT_MS);
|
||||
expect(event, `expected an event with deleteReason=AllFilesBlocked for ${torrent.infoHash}`).toBeDefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Covers `AllFilesBlocked` on a private torrent with `deletePrivate` off.
|
||||
* The arr drops the queue item with removeFromClient=false.
|
||||
* The torrent stays in the client with every file unwanted.
|
||||
*/
|
||||
test.describe.serial('Malware blocker: AllFilesBlocked, deletePrivate off', () => {
|
||||
let token: string;
|
||||
const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl);
|
||||
let sonarrId: string | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
token = await loginAndGetToken();
|
||||
|
||||
for (const client of await listDownloadClients(token)) {
|
||||
await deleteDownloadClient(token, client.id);
|
||||
}
|
||||
|
||||
mkdirShared(join(HOST_DOWNLOADS, 'malware-blocker'));
|
||||
writeFileShared(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n');
|
||||
|
||||
await arr.waitReady();
|
||||
await stubArrQueue(arr, []);
|
||||
|
||||
const sonarrRes = await createArrInstance(token, 'sonarr', {
|
||||
name: 'Malware Blocker AllFilesBlocked Private Stub',
|
||||
url: TEST_CONFIG.mocks.arrUrl,
|
||||
apiKey: 'malware-blocker-all-files-private-e2e',
|
||||
version: 4,
|
||||
});
|
||||
expect(sonarrRes.status).toBe(201);
|
||||
const sonarrBody = await sonarrRes.json();
|
||||
sonarrId = sonarrBody.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (sonarrId) {
|
||||
await deleteArrInstance(token, 'sonarr', sonarrId);
|
||||
}
|
||||
await arr.resetAll();
|
||||
});
|
||||
|
||||
for (const fixture of ALL_CLIENTS) {
|
||||
runAllFilesBlockedKeepPrivateScenario(fixture, () => ({ token, arr }));
|
||||
}
|
||||
});
|
||||
|
||||
function runAllFilesBlockedKeepPrivateScenario(
|
||||
fixture: TorrentClientFixture,
|
||||
getCtx: () => { token: string; arr: WireMockClient },
|
||||
): void {
|
||||
const { driver } = fixture;
|
||||
const slug = SLUG_BY_TYPE[driver.typeName];
|
||||
const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip;
|
||||
|
||||
describeFn(`${driver.typeName}`, () => {
|
||||
let clientId: string;
|
||||
let torrent: TorrentMeta;
|
||||
const hostScanDir = join(HOST_DOWNLOADS, slug);
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const { token } = getCtx();
|
||||
|
||||
resetDirectory(hostScanDir);
|
||||
await driver.ready();
|
||||
await driver.clearAllTorrents();
|
||||
|
||||
const runId = Date.now().toString(36);
|
||||
// buildMultiFileTorrent marks every torrent private.
|
||||
const fx = buildMultiFileTorrent(hostScanDir, `all-blocked-private-${slug}-${runId}`, [
|
||||
{ filename: 'installer.exe', sizeBytes: 1024 },
|
||||
{ filename: 'setup.exe', sizeBytes: 1024 },
|
||||
]);
|
||||
torrent = { name: fx.name, infoHash: fx.infoHash };
|
||||
|
||||
const createRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: `${driver.typeName} malware-blocker all-files-blocked-private e2e`,
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
username: driver.username ?? '',
|
||||
password: driver.password ?? '',
|
||||
});
|
||||
expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200);
|
||||
expect(createRes.status).toBeLessThan(300);
|
||||
clientId = (await createRes.json()).id;
|
||||
|
||||
await driver.addTorrent({
|
||||
metainfo: fx.metainfo,
|
||||
savePath: CLIENT_DOWNLOADS,
|
||||
name: fx.name,
|
||||
infoHash: fx.infoHash,
|
||||
});
|
||||
await waitForTorrents(driver, [torrent.infoHash]);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
const { token } = getCtx();
|
||||
if (clientId) {
|
||||
await deleteDownloadClient(token, clientId);
|
||||
}
|
||||
});
|
||||
|
||||
test('private torrent with every file blocked stays in the client with every file unwanted', async () => {
|
||||
test.setTimeout(120_000);
|
||||
const { token, arr } = getCtx();
|
||||
|
||||
await setMalwareBlocker(token, {
|
||||
enabled: true,
|
||||
useAdvancedScheduling: true,
|
||||
cronExpression: farFutureCron(),
|
||||
deleteIfAnyFileBlocked: false,
|
||||
ignorePrivate: false,
|
||||
deletePrivate: false,
|
||||
processNoContentId: false,
|
||||
sonarr: {
|
||||
enabled: true,
|
||||
blocklistPath: APP_BLOCKLIST_PATH,
|
||||
blocklistType: 'Blacklist',
|
||||
},
|
||||
});
|
||||
|
||||
const queueId = 3001;
|
||||
await stubArrQueue(arr, [
|
||||
{
|
||||
id: queueId,
|
||||
downloadId: torrent.infoHash,
|
||||
title: torrent.name,
|
||||
protocol: 'torrent',
|
||||
seriesId: 4,
|
||||
episodeId: 4,
|
||||
},
|
||||
]);
|
||||
|
||||
const trig = await triggerJob(token, 'MalwareBlocker');
|
||||
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
||||
if ((await readDeletes(arr)).some((d) => d.id === queueId)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const deletes = await readDeletes(arr);
|
||||
const onDelete = deletes.find((d) => d.id === queueId);
|
||||
expect(onDelete, `sonarr queue item ${queueId} should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined();
|
||||
expect(onDelete!.removeFromClient, 'malware blocker must not ask sonarr to remove a private torrent from the client').toBe(false);
|
||||
|
||||
const event = await findEventWithDeleteReason(token, torrent.infoHash, 'AllFilesBlocked', POLL_TIMEOUT_MS);
|
||||
expect(event, `expected an event with deleteReason=AllFilesBlocked for ${torrent.infoHash}`).toBeDefined();
|
||||
|
||||
await new Promise((r) => setTimeout(r, TRIGGER_SETTLE_MS));
|
||||
|
||||
expect(await torrentPresent(driver, torrent.infoHash), `expected ${torrent.infoHash} to remain in ${driver.typeName} (deletePrivate off)`).toBe(true);
|
||||
|
||||
const priorities = await driver.getFilePriorities(torrent.infoHash);
|
||||
expect(priorities.length, `expected file priorities for ${torrent.infoHash}`).toBeGreaterThan(0);
|
||||
expect(priorities.every((p) => p === 0), `expected every file to be unwanted, saw ${JSON.stringify(priorities)}`).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user