Fix rTorrent seeding rules not respecting tracker settings (#808)

This commit is contained in:
Flaminel authored and GitHub committed 2026-09-24 20:49:06 +03:00
1 parent 8920539c47
commit 5a1bd63c68
5 files changed
+330 -6

No files matched your search

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
@@ -43,6 +44,14 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.GetAllTorrentsAsync()
.Returns(downloads);
_fixture.ClientWrapper
.GetTrackersAsync("HASH1")
.Returns(new List<string>());
_fixture.ClientWrapper
.GetTrackersAsync("HASH3")
.Returns(new List<string>());
// Act
var result = await sut.GetSeedingDownloads();
@@ -84,6 +93,10 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
.GetAllTorrentsAsync()
.Returns(downloads);
_fixture.ClientWrapper
.GetTrackersAsync("HASH1")
.Returns(new List<string>());
// Act
var result = await sut.GetSeedingDownloads();
@@ -91,6 +104,44 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
result.ShouldHaveSingleItem();
result[0].Hash.ShouldBe("HASH1");
}
[Fact]
public async Task FetchesTrackerDataForSeedingTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<RTorrentTorrent>
{
new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", State = 1, Complete = 1, IsPrivate = 0, Label = "" }
};
var trackers = new List<string>
{
"http://tracker.example.com:6969/announce",
"http://tracker.other.org:8080/announce"
};
_fixture.ClientWrapper
.GetAllTorrentsAsync()
.Returns(downloads);
_fixture.ClientWrapper
.GetTrackersAsync("HASH1")
.Returns(trackers);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
result.ShouldHaveSingleItem();
var wrapper = result[0];
wrapper.TrackerDomains.ShouldContain("tracker.example.com");
wrapper.TrackerDomains.ShouldContain("tracker.other.org");
await _fixture.ClientWrapper.Received(1)
.GetTrackersAsync("HASH1");
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : RTorrentServiceDCTests
@@ -297,6 +348,52 @@ public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
await _fixture.ClientWrapper.Received(1)
.DeleteTorrentAsync("LOWERCASE");
}
[Fact]
public async Task RemapsSavePathBeforeDeletingFiles()
{
// Arrange
string targetRoot = Directory.CreateTempSubdirectory().FullName;
string targetPath = Path.Combine(targetRoot, "seed-rule-tracker");
Directory.CreateDirectory(targetPath);
File.WriteAllText(Path.Combine(targetPath, "file.txt"), "content");
DownloadClientConfig config = new()
{
Id = Guid.NewGuid(),
Name = "Test rTorrent Client",
TypeName = Domain.Enums.DownloadClientTypeName.rTorrent,
Type = Domain.Enums.DownloadClientType.Torrent,
Enabled = true,
Host = new Uri("http://localhost/RPC2"),
Username = "admin",
Password = "admin",
UrlBase = "",
DownloadDirectorySource = "/downloads",
DownloadDirectoryTarget = targetRoot,
};
var sut = _fixture.CreateSut(config);
var mockTorrent = Substitute.For<ITorrentItemWrapper>();
mockTorrent.Hash.Returns("hash");
mockTorrent.SavePath.Returns("/downloads/seed-rule-tracker");
try
{
// Act
await sut.DeleteDownload(mockTorrent, deleteSourceFiles: true);
// Assert
Directory.Exists(targetPath).ShouldBeFalse();
}
finally
{
if (Directory.Exists(targetRoot))
{
Directory.Delete(targetRoot, true);
}
}
}
}
public class StopDownload_Tests : RTorrentServiceDCTests
@@ -64,6 +64,23 @@ public class SeedingRuleEvaluatorTests
};
}
private static RTorrentSeedingRule CreateRTorrentRule(
int priority = 1,
List<string>? categories = null,
List<string>? trackerPatterns = null,
TorrentPrivacyType privacyType = TorrentPrivacyType.Both)
{
return new RTorrentSeedingRule
{
Id = Guid.NewGuid(),
Name = "Test Rule",
Priority = priority,
Categories = categories ?? ["movies"],
TrackerPatterns = trackerPatterns ?? [],
PrivacyType = privacyType,
};
}
// ──────────────────────────────────────────────────────────────────────
// Empty / null rules
// ──────────────────────────────────────────────────────────────────────
@@ -272,6 +289,19 @@ public class SeedingRuleEvaluatorTests
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
[Fact]
public void GetMatchingRule_RTorrentRule_TrackerPatternMatches()
{
var torrent = CreateTorrent(
category: "movies",
trackerDomains: ["tracker.example.com"]);
var rule = CreateRTorrentRule(
categories: ["movies"],
trackerPatterns: ["example.com"]);
_sut.GetMatchingRule(torrent, [rule]).ShouldBe(rule);
}
// ──────────────────────────────────────────────────────────────────────
// Priority ordering
// ──────────────────────────────────────────────────────────────────────
@@ -13,12 +13,16 @@ public partial class RTorrentService
{
var downloads = await _client.GetAllTorrentsAsync();
return downloads
var result = new List<ITorrentItemWrapper>();
foreach (var torrent in downloads
.Where(x => !string.IsNullOrEmpty(x.Hash))
// Seeding: complete=1 (finished) and state=1 (started)
.Where(x => x is { Complete: 1, State: 1 })
.Select(ITorrentItemWrapper (x) => new RTorrentItemWrapper(x, null, _timeProvider))
.ToList();
.Where(x => x is { Complete: 1, State: 1 }))
{
var trackers = await _client.GetTrackersAsync(torrent.Hash);
result.Add(new RTorrentItemWrapper(torrent, trackers, _timeProvider));
}
return result;
}
/// <inheritdoc/>
@@ -81,7 +85,12 @@ public partial class RTorrentService
if (deleteSourceFiles)
{
if (!TryDeleteFiles(torrent.SavePath, true))
string savePath = PathHelper.NormalizeAndRemap(
torrent.SavePath,
_downloadClientConfig.DownloadDirectorySource,
_downloadClientConfig.DownloadDirectoryTarget);
if (!TryDeleteFiles(savePath, true))
{
_logger.LogWarning("Failed to delete files | {name}", torrent.Name);
}