From b95dbcc82409a68c56869d9a37aaea152ab7e98e Mon Sep 17 00:00:00 2001 From: Cucumberrbob <128094686+Cucumberrbob@users.noreply.github.com> Date: Mon, 3 Mar 2025 11:52:19 +0000 Subject: [PATCH] Add `DownloadableFileFilter` + unit tests `DownloadableFileFilter` is an abstraction of the filtering we do in most of the `TorrentClient`s. It operates on a single file at a time, not the entire torrent. --- .../Services/DownloadableFileFilterTest.cs | 261 ++++++++++++++++++ .../Services/DownloadableFileFilter.cs | 79 ++++++ 2 files changed, 340 insertions(+) create mode 100644 server/RdtClient.Service.Test/Services/DownloadableFileFilterTest.cs create mode 100644 server/RdtClient.Service/Services/DownloadableFileFilter.cs diff --git a/server/RdtClient.Service.Test/Services/DownloadableFileFilterTest.cs b/server/RdtClient.Service.Test/Services/DownloadableFileFilterTest.cs new file mode 100644 index 0000000..3858ee2 --- /dev/null +++ b/server/RdtClient.Service.Test/Services/DownloadableFileFilterTest.cs @@ -0,0 +1,261 @@ +using Microsoft.Extensions.Logging; +using Moq; +using RdtClient.Data.Models.Data; +using RdtClient.Service.Services; + +namespace RdtClient.Service.Test.Services; + +public class DownloadableFileFilterTest +{ + [Fact] + public void IsDownloadable_WhenNoFilterSpecified_ReturnsTrue() + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1" + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, "file.txt", 10000); + + // Assert + Assert.True(result); + } + + [Theory] + // downloadMinSize is in MB, fileSize is in B + [InlineData(100, 20 * 1024 * 1024)] + [InlineData(2, 2 * 1024 * 1024)] + [InlineData(2, 2 * (1000 * 1000 + 1))] // mostly to show we use 1024 not 1000 for conversion + public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadBelowSize_ReturnsFalse(Int32 downloadMinSize, Int64 fileSize) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + DownloadMinSize = downloadMinSize + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, "file.txt", fileSize); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(100, 110 * 1024 * 1024)] + [InlineData(2, 2 * 1024 * 1024 + 1)] + public void IsDownloadable_WhenDownloadMinSizeSpecified_AndDownloadAboveSize_ReturnsTrue(Int32 downloadMinSize, Int64 fileSize) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + DownloadMinSize = downloadMinSize + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, "file.txt", fileSize); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("file", "no-match")] + [InlineData("file", "even/in/a/subdirectory.txt")] + [InlineData("ch[aA]racter c[lL]asses", "nope.txt")] + [InlineData("digits\\d+", "123 not matching.txt")] + public void IsDownloadable_WhenIncludeRegexSpecified_AndPathDoesNotMatchRegex_ReturnsFalse(String includeRegex, String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = includeRegex + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, Int64.MaxValue); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("file", "file.txt")] + [InlineData("file", "file/in/a/subdirectory.txt")] + [InlineData("ch[aA]racter c[lL]asses", "character cLasses")] + [InlineData("digits\\d+", "digits123456.txt")] + public void IsDownloadable_WhenIncludeRegexSpecified_AndPathMatchesRegex_ReturnsTrue(String includeRegex, String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = includeRegex + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, Int64.MaxValue); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("file", "no-match")] + [InlineData("file", "even/in/a/subdirectory.txt")] + [InlineData("ch[aA]racter c[lL]asses", "nope.txt")] + [InlineData("digits\\d+", "123 not matching.txt")] + public void IsDownloadable_WhenExcludeRegexSpecified_AndPathDoesNotMatchRegex_ReturnsTrue(String excludeRegex, String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + ExcludeRegex = excludeRegex + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, Int64.MaxValue); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("file", "file.txt")] + [InlineData("file", "file/in/a/subdirectory.txt")] + [InlineData("ch[aA]racter c[lL]asses", "character cLasses")] + [InlineData("digits\\d+", "digits123456.txt")] + public void IsDownloadable_WhenExcludeRegexSpecified_AndPathMatchesRegex_ReturnsFalse(String excludeRegex, String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + ExcludeRegex = excludeRegex + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, Int64.MaxValue); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData("file", "file", "file.txt")] + [InlineData("file", "in/a", "file/in/a/subdirectory.txt")] + [InlineData("ch[aA]racter c[lL]asses", "character", "character cLasses")] + [InlineData("digits\\d+", "123456", "digits123456.txt")] + public void IsDownloadable_WhenBothIncludeAndExcludeRegexSpecified_AndPathMatchesIncludeAndExcludeRegex_ReturnsTrue(String includeRegex, String excludeRegex, String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = includeRegex, + ExcludeRegex = excludeRegex + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, Int64.MaxValue); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(10, "file", 10 * 1024 * 1024 + 1, "no-match.txt")] + public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadAboveSizeAndDoesNotMatchRegex_ReturnsFalse( + Int32 minSize, + String includeRegex, + Int64 fileSize, + String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = includeRegex, + DownloadMinSize = minSize + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, fileSize); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(10, "file", 10 * 1024 * 1024 - 1, "file.txt")] + public void IsDownloadable_WhenBothDownloadMinSizeAndIncludeRegexSpecified_AndDownloadBelowSizeAndMatchesRegex_ReturnsFalse( + Int32 minSize, + String includeRegex, + Int64 fileSize, + String filePath) + { + // Arrange + var mocks = new Mocks(); + + var torrent = new Torrent + { + RdId = "1", + IncludeRegex = includeRegex, + DownloadMinSize = minSize + }; + + var fileFilter = new DownloadableFileFilter(mocks.LoggerMock.Object); + + // Act + var result = fileFilter.IsDownloadable(torrent, filePath, fileSize); + + // Assert + Assert.False(result); + } + private class Mocks + { + public readonly Mock> LoggerMock = new(); + } +} \ No newline at end of file diff --git a/server/RdtClient.Service/Services/DownloadableFileFilter.cs b/server/RdtClient.Service/Services/DownloadableFileFilter.cs new file mode 100644 index 0000000..fcc4931 --- /dev/null +++ b/server/RdtClient.Service/Services/DownloadableFileFilter.cs @@ -0,0 +1,79 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.Data; + +namespace RdtClient.Service.Services; + +public interface IDownloadableFileFilter +{ + public Boolean IsDownloadable(Torrent torrent, String filePath, Int64 fileSize); +} + +public class DownloadableFileFilter(ILogger logger) : IDownloadableFileFilter +{ + public Boolean IsDownloadable(Torrent torrent, String filePath, Int64 fileSize) + { + var isDownloadable = SatisfiesMinSize(torrent, filePath, fileSize) && + SatisfiesFileNameRegex(torrent, filePath); + + if (isDownloadable) + { + logger.LogDebug("File {filePath} was included after filtering", filePath); + } + + return isDownloadable; + } + + private Boolean SatisfiesMinSize(Torrent torrent, String filePath, Int64 fileSize) + { + if (torrent is { ClientKind: Provider.RealDebrid, DownloadAction: TorrentDownloadAction.DownloadManual }) + { + return true; + } + + if (torrent.DownloadMinSize <= 0 || fileSize > torrent.DownloadMinSize * 1024 * 1024) + { + return true; + } + + logger.LogDebug("Not downloading file {filePath} file size {fileSize} smaller than minimum {downloadMinSize}", filePath, fileSize, torrent.DownloadMinSize); + + return false; + } + + private Boolean SatisfiesFileNameRegex(Torrent torrent, String filePath) + { + return IncludeFileName(torrent, filePath) && ExcludeFileName(torrent, filePath); + } + + private Boolean IncludeFileName(Torrent torrent, String filePath) + { + if (String.IsNullOrWhiteSpace(torrent.IncludeRegex) || Regex.IsMatch(filePath, torrent.IncludeRegex)) + { + return true; + } + + logger.LogDebug("Not downloading file {filePath} does not match regex {includeRegex}", filePath, torrent.IncludeRegex); + + return false; + } + + private Boolean ExcludeFileName(Torrent torrent, String filePath) + { + // If the IncludeRegex is set, ignore the ExcludeRegex + if (!String.IsNullOrWhiteSpace(torrent.IncludeRegex)) + { + return true; + } + + if (String.IsNullOrWhiteSpace(torrent.ExcludeRegex) || !Regex.IsMatch(filePath, torrent.ExcludeRegex)) + { + return true; + } + + logger.LogDebug("Not downloading file {filePath} matches regex {excludeRegex}", filePath, torrent.ExcludeRegex); + + return false; + } +}