mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -04:00
Fix rTorrent seeding rules not respecting tracker settings (#808)
This commit is contained in:
1 parent
8920539c47
commit
5a1bd63c68
5 files changed
+330
-6
No files matched your search
+97
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
+15
-6
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user