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);
}
@@ -0,0 +1,162 @@
import { test, expect } from '@playwright/test';
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import {
loginAndGetToken,
createDownloadClient,
listDownloadClients,
deleteDownloadClient,
updateDownloadCleanerConfig,
getDownloadCleanerConfig,
createSeedingRule,
triggerJob,
} from '../helpers/app-api';
import { RTorrentDriver } from '../helpers/torrent-clients/rtorrent';
import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures';
import { mkdirShared } from '../helpers/shared-volume';
const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads');
const RTORRENT_DOWNLOADS = join(HOST_DOWNLOADS, 'rtorrent');
const CLIENT_DOWNLOADS = '/downloads';
const CATEGORY = 'rt-seed';
// GetDomain resolves 'http://127.0.0.1:6969/announce' to host '127.0.0.1'.
const ANNOUNCE = 'http://127.0.0.1:6969/announce';
const TRACKER_PATTERN = '127.0.0.1';
const rtorrent = new RTorrentDriver();
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// A torrent must finish its hash check and hold the category before the rule can match it.
async function waitForSeeding(infoHash: string, timeoutMs = 30_000): Promise<void> {
const start = Date.now();
let state: number | undefined;
let complete: number | undefined;
let label: string | undefined;
while (Date.now() - start < timeoutMs) {
state = await rtorrent.getState(infoHash);
complete = await rtorrent.getComplete(infoHash);
label = await rtorrent.getLabel(infoHash);
if (state === 1 && complete === 1 && label === CATEGORY) return;
await sleep(500);
}
throw new Error(`torrent ${infoHash} is in state ${state}, complete ${complete}, with label ${label}`);
}
async function stillPresent(infoHashes: string[]): Promise<string[]> {
const present = (await rtorrent.listTorrents()).map((t) => t.hash.toLowerCase());
return infoHashes.filter((h) => present.includes(h.toLowerCase()));
}
/**
* Regression test for #807: rTorrent tracker patterns never matched.
* The rule below matches only by tracker pattern, so it fails on main.
*/
test.describe.serial('rTorrent seeding rule tracker pattern cleanup', () => {
let token: string;
let clientId: string;
const hashes: string[] = [];
const contentPaths: string[] = [];
test.beforeAll(async () => {
test.setTimeout(120_000);
token = await loginAndGetToken();
for (const c of await listDownloadClients(token)) {
await deleteDownloadClient(token, c.id);
}
const dc = await (await getDownloadCleanerConfig(token)).json();
await updateDownloadCleanerConfig(token, {
enabled: true,
cronExpression: dc.cronExpression || '0 0 * * * ?',
useAdvancedScheduling: dc.useAdvancedScheduling ?? false,
ignoredDownloads: [],
});
mkdirShared(HOST_DOWNLOADS);
resetDirectory(RTORRENT_DOWNLOADS);
await rtorrent.ready();
await rtorrent.clearAllTorrents();
});
test.afterAll(async () => {
await rtorrent.clearAllTorrents().catch(() => {});
if (clientId) {
await deleteDownloadClient(token, clientId).catch(() => {});
}
});
test('sets up a seeding torrent and a tracker-pattern rule', async () => {
test.setTimeout(120_000);
chmodIgnoringEPERM(RTORRENT_DOWNLOADS, 0o777);
const fx = buildFolderTorrent(RTORRENT_DOWNLOADS, 'seed-rule-tracker', 32_768, ANNOUNCE);
await rtorrent.addSeedingTorrent({
metainfo: fx.metainfo,
savePath: CLIENT_DOWNLOADS,
category: CATEGORY,
name: fx.name,
infoHash: fx.infoHash,
});
await waitForSeeding(fx.infoHash);
hashes.push(fx.infoHash);
contentPaths.push(fx.contentPath);
const createRes = await createDownloadClient(token, {
enabled: true,
name: 'rTorrent seeding rule e2e',
typeName: rtorrent.typeName,
type: 'Torrent',
host: rtorrent.cleanuparrHost,
username: rtorrent.username,
password: rtorrent.password,
downloadDirectorySource: '/downloads',
downloadDirectoryTarget: '/e2e-downloads/rtorrent',
});
expect(createRes.status).toBeGreaterThanOrEqual(200);
expect(createRes.status).toBeLessThan(300);
clientId = (await createRes.json()).id;
// Category alone would match every torrent; only trackerPatterns filters here.
const ruleRes = await createSeedingRule(token, clientId, {
name: 'rtorrent tracker pattern',
categories: [CATEGORY],
trackerPatterns: [TRACKER_PATTERN],
tagsAny: [],
tagsAll: [],
privacyType: 'Both',
maxRatio: -1,
minSeedTime: 0,
maxSeedTime: 0,
minSeeders: 0,
maxInactiveDays: -1,
deleteSourceFiles: true,
});
expect(ruleRes.status).toBe(201);
expect(await stillPresent(hashes)).toHaveLength(1);
});
test('matches the tracker pattern and removes the torrent and its files', async () => {
test.setTimeout(180_000);
const trig = await triggerJob(token, 'DownloadCleaner');
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
await sleep(10_000); // The job waits 10 s for the Arr queue sync.
await expect
.poll(() => stillPresent(hashes), {
message: 'torrent survived the cleanup cycle (tracker pattern rule did not match)',
timeout: 60_000,
intervals: [1_000],
})
.toEqual([]);
for (const path of contentPaths) {
expect(existsSync(path), `torrent data was not deleted: ${path}`).toBe(false);
}
});
});
@@ -69,6 +69,32 @@ export class RTorrentDriver implements TorrentClientDriver {
void infoHash;
}
/** Like addTorrent, but sets d.custom1 (Category) to `category`, not `name`. */
async addSeedingTorrent({ metainfo, savePath, category, name }: { metainfo: Buffer; savePath: string; category: string; name: string; infoHash: string }): Promise<void> {
await this.call('load.raw_start_verbose', [
'',
{ type: 'base64', value: metainfo.toString('base64') },
`d.directory.set="${savePath}"`,
`d.custom1.set="${category}"`,
]);
void name;
}
/** d.state: 0 = stopped, 1 = started. */
async getState(infoHash: string): Promise<number> {
return Number(await this.call('d.state', [infoHash.toUpperCase()]));
}
/** d.complete: 1 once the hash check confirms all pieces are present. */
async getComplete(infoHash: string): Promise<number> {
return Number(await this.call('d.complete', [infoHash.toUpperCase()]));
}
/** d.custom1: the label/category field Cleanuparr reads as Category. */
async getLabel(infoHash: string): Promise<string> {
return String(await this.call('d.custom1', [infoHash.toUpperCase()]));
}
/** rTorrent reports d.state 0 for a stopped torrent and 1 for a started one. */
async isStopped(infoHash: string): Promise<boolean> {
return (await this.call('d.state', [infoHash.toUpperCase()])) === 0;