diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs index 6cf16737..cae567a2 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceDCTests.cs @@ -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 .GetAllTorrentsAsync() .Returns(downloads); + _fixture.ClientWrapper + .GetTrackersAsync("HASH1") + .Returns(new List()); + + _fixture.ClientWrapper + .GetTrackersAsync("HASH3") + .Returns(new List()); + // Act var result = await sut.GetSeedingDownloads(); @@ -84,6 +93,10 @@ public class RTorrentServiceDCTests : IClassFixture .GetAllTorrentsAsync() .Returns(downloads); + _fixture.ClientWrapper + .GetTrackersAsync("HASH1") + .Returns(new List()); + // Act var result = await sut.GetSeedingDownloads(); @@ -91,6 +104,44 @@ public class RTorrentServiceDCTests : IClassFixture result.ShouldHaveSingleItem(); result[0].Hash.ShouldBe("HASH1"); } + + [Fact] + public async Task FetchesTrackerDataForSeedingTorrents() + { + // Arrange + var sut = _fixture.CreateSut(); + + var downloads = new List + { + new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", State = 1, Complete = 1, IsPrivate = 0, Label = "" } + }; + + var trackers = new List + { + "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 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(); + 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 diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Services/SeedingRuleEvaluatorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Services/SeedingRuleEvaluatorTests.cs index a6b9d4fc..c17c8793 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Services/SeedingRuleEvaluatorTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Services/SeedingRuleEvaluatorTests.cs @@ -64,6 +64,23 @@ public class SeedingRuleEvaluatorTests }; } + private static RTorrentSeedingRule CreateRTorrentRule( + int priority = 1, + List? categories = null, + List? 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 // ────────────────────────────────────────────────────────────────────── diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs index e696bc8a..413adf6c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceDC.cs @@ -13,12 +13,16 @@ public partial class RTorrentService { var downloads = await _client.GetAllTorrentsAsync(); - return downloads + var result = new List(); + 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; } /// @@ -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); } diff --git a/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts b/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts new file mode 100644 index 00000000..6283501f --- /dev/null +++ b/e2e/tests/download-cleaner/rtorrent-seeding-rule-tracker.spec.ts @@ -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 { + 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 { + 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 { + 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); + } + }); +}); diff --git a/e2e/tests/helpers/torrent-clients/rtorrent.ts b/e2e/tests/helpers/torrent-clients/rtorrent.ts index 57f2841e..42543872 100644 --- a/e2e/tests/helpers/torrent-clients/rtorrent.ts +++ b/e2e/tests/helpers/torrent-clients/rtorrent.ts @@ -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 { + 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 { + 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 { + return Number(await this.call('d.complete', [infoHash.toUpperCase()])); + } + + /** d.custom1: the label/category field Cleanuparr reads as Category. */ + async getLabel(infoHash: string): Promise { + 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 { return (await this.call('d.state', [infoHash.toUpperCase()])) === 0;