Compare commits

..

8 Commits

Author SHA1 Message Date
Flaminel
6b94e05092 fixed some comments 2026-02-16 19:07:50 +02:00
Flaminel
d4ac8c8ddf added some tests 2026-02-16 18:14:40 +02:00
Flaminel
9c6560b159 fixed context variables 2026-02-16 17:49:29 +02:00
Flaminel
8fdc49f65a decreased number of concurrent deletes 2026-02-16 17:49:08 +02:00
Flaminel
f906e6ed14 fixed frontend inputs 2026-02-16 16:46:58 +02:00
Flaminel
69b50499b5 fixed files call 2026-02-16 16:46:49 +02:00
Flaminel
cc735bd4e2 fixed with main 2026-02-15 18:03:25 +02:00
Flaminel
76767adb1f added rTorrent support 2026-02-15 17:44:53 +02:00
38 changed files with 3494 additions and 158 deletions

View File

@@ -56,8 +56,8 @@ public static class MainDI
{
e.ConfigureConsumer<DownloadRemoverConsumer<SearchItem>>(context);
e.ConfigureConsumer<DownloadRemoverConsumer<SeriesSearchItem>>(context);
e.ConcurrentMessageLimit = 2;
e.PrefetchCount = 2;
e.ConcurrentMessageLimit = 1;
e.PrefetchCount = 1;
});
cfg.ReceiveEndpoint("download-hunter-queue", e =>

View File

@@ -27,6 +27,8 @@ public interface ITorrentItemWrapper
long SeedingTimeSeconds { get; }
string? Category { get; set; }
string SavePath { get; }
bool IsDownloading();

View File

@@ -0,0 +1,37 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a file within a torrent from rTorrent's XML-RPC f.multicall response
/// </summary>
public sealed record RTorrentFile
{
/// <summary>
/// File index within the torrent (0-based)
/// </summary>
public int Index { get; init; }
/// <summary>
/// File path relative to the torrent base directory
/// </summary>
public required string Path { get; init; }
/// <summary>
/// File size in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Download priority: 0 = skip/don't download, 1 = normal, 2 = high
/// </summary>
public int Priority { get; init; }
/// <summary>
/// Number of completed chunks for this file
/// </summary>
public long CompletedChunks { get; init; }
/// <summary>
/// Total number of chunks for this file
/// </summary>
public long SizeChunks { get; init; }
}

View File

@@ -0,0 +1,72 @@
namespace Cleanuparr.Domain.Entities.RTorrent.Response;
/// <summary>
/// Represents a torrent from rTorrent's XML-RPC multicall response
/// </summary>
public sealed record RTorrentTorrent
{
/// <summary>
/// Torrent info hash (40-character hex string, uppercase)
/// </summary>
public required string Hash { get; init; }
/// <summary>
/// Torrent name
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Whether the torrent is from a private tracker (0 or 1)
/// </summary>
public int IsPrivate { get; init; }
/// <summary>
/// Total size of the torrent in bytes
/// </summary>
public long SizeBytes { get; init; }
/// <summary>
/// Number of bytes completed/downloaded
/// </summary>
public long CompletedBytes { get; init; }
/// <summary>
/// Current download rate in bytes per second
/// </summary>
public long DownRate { get; init; }
/// <summary>
/// Upload/download ratio multiplied by 1000 (e.g., 1500 = 1.5 ratio)
/// </summary>
public long Ratio { get; init; }
/// <summary>
/// Torrent state: 0 = stopped, 1 = started
/// </summary>
public int State { get; init; }
/// <summary>
/// Completion status: 0 = incomplete, 1 = complete
/// </summary>
public int Complete { get; init; }
/// <summary>
/// Unix timestamp when the torrent finished downloading (0 if not finished)
/// </summary>
public long TimestampFinished { get; init; }
/// <summary>
/// Label/category from d.custom1 (commonly used by ruTorrent for labels)
/// </summary>
public string? Label { get; init; }
/// <summary>
/// Base path where the torrent data is stored
/// </summary>
public string? BasePath { get; init; }
/// <summary>
/// List of tracker URLs for this torrent
/// </summary>
public IReadOnlyList<string>? Trackers { get; init; }
}

View File

@@ -6,4 +6,5 @@ public enum DownloadClientTypeName
Deluge,
Transmission,
uTorrent,
rTorrent,
}

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Domain.Exceptions;
public class RTorrentClientException : Exception
{
public RTorrentClientException(string message) : base(message)
{
}
public RTorrentClientException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.Deluge.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -340,13 +341,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -360,13 +363,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -380,13 +385,15 @@ public class DelugeServiceDCTests : IClassFixture<DelugeServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrents(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
await sut.DeleteDownload(mockTorrent.Object, false);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent;
@@ -503,13 +504,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.Is<IEnumerable<string>>(h => h.Contains(hash)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -523,13 +526,15 @@ public class QBitServiceDCTests : IClassFixture<QBitServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.DeleteAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<bool>()))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -0,0 +1,582 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentItemWrapperTests
{
public class PropertyMapping_Tests
{
[Fact]
public void MapsHash()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123DEF456", Name = "Test" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("ABC123DEF456", wrapper.Hash);
}
[Fact]
public void MapsName()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test Torrent Name" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("Test Torrent Name", wrapper.Name);
}
[Fact]
public void MapsIsPrivate_True()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 1 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsPrivate);
}
[Fact]
public void MapsIsPrivate_False()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", IsPrivate = 0 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsPrivate);
}
[Fact]
public void MapsSize()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", SizeBytes = 1024000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(1024000, wrapper.Size);
}
[Fact]
public void MapsDownloadSpeed()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", DownRate = 500000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(500000, wrapper.DownloadSpeed);
}
[Fact]
public void MapsDownloadedBytes()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", CompletedBytes = 750000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(750000, wrapper.DownloadedBytes);
}
[Fact]
public void MapsCategory()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal("movies", wrapper.Category);
}
[Fact]
public void CategoryIsSettable()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
wrapper.Category = "tv";
// Assert
Assert.Equal("tv", wrapper.Category);
}
}
public class Ratio_Tests
{
[Fact]
public void ConvertsRatioFromRTorrentFormat()
{
// rTorrent returns ratio * 1000, so 1500 = 1.5 ratio
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 1500 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(1.5, wrapper.Ratio);
}
[Fact]
public void HandlesZeroRatio()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 0 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Ratio);
}
[Fact]
public void HandlesHighRatio()
{
// Arrange - 10.0 ratio = 10000 in rTorrent
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Ratio = 10000 };
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(10.0, wrapper.Ratio);
}
}
public class CompletionPercentage_Tests
{
[Fact]
public void CalculatesCorrectPercentage()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(50.0, wrapper.CompletionPercentage);
}
[Fact]
public void ReturnsZero_WhenSizeIsZero()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 0,
CompletedBytes = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0.0, wrapper.CompletionPercentage);
}
[Fact]
public void ReturnsHundred_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 1000
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(100.0, wrapper.CompletionPercentage);
}
}
public class IsDownloading_Tests
{
[Fact]
public void ReturnsTrue_WhenStateIsStartedAndNotComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1, // Started
Complete = 0 // Not complete
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsDownloading());
}
[Fact]
public void ReturnsFalse_WhenStopped()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 0, // Stopped
Complete = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsDownloading());
}
[Fact]
public void ReturnsFalse_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1, // Started
Complete = 1 // Complete (seeding)
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsDownloading());
}
}
public class IsStalled_Tests
{
[Fact]
public void ReturnsTrue_WhenDownloadingWithNoSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.True(wrapper.IsStalled());
}
[Fact]
public void ReturnsFalse_WhenDownloadingWithSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 1,
Complete = 0,
DownRate = 100000,
SizeBytes = 1000,
CompletedBytes = 500
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsStalled());
}
[Fact]
public void ReturnsFalse_WhenNotDownloading()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
State = 0, // Stopped
Complete = 0,
DownRate = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.False(wrapper.IsStalled());
}
}
public class SeedingTime_Tests
{
[Fact]
public void ReturnsZero_WhenNotComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 0,
TimestampFinished = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.SeedingTimeSeconds);
}
[Fact]
public void ReturnsZero_WhenNoFinishTimestamp()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 1,
TimestampFinished = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.SeedingTimeSeconds);
}
[Fact]
public void CalculatesSeedingTime_WhenComplete()
{
// Arrange
var finishedTime = DateTimeOffset.UtcNow.AddHours(-2).ToUnixTimeSeconds();
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Complete = 1,
TimestampFinished = finishedTime
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert - should be approximately 2 hours (7200 seconds)
Assert.True(wrapper.SeedingTimeSeconds >= 7190 && wrapper.SeedingTimeSeconds <= 7210);
}
}
public class Eta_Tests
{
[Fact]
public void ReturnsZero_WhenNoDownloadSpeed()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500,
DownRate = 0
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Eta);
}
[Fact]
public void CalculatesEta_WhenDownloading()
{
// Arrange - 500 bytes remaining at 100 bytes/sec = 5 seconds ETA
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 500,
DownRate = 100
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(5, wrapper.Eta);
}
[Fact]
public void ReturnsZero_WhenComplete()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
SizeBytes = 1000,
CompletedBytes = 1000,
DownRate = 100
};
// Act
var wrapper = new RTorrentItemWrapper(torrent);
// Assert
Assert.Equal(0, wrapper.Eta);
}
}
public class IsIgnored_Tests
{
[Fact]
public void ReturnsFalse_WhenEmptyIgnoreList()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string>());
// Assert
Assert.False(result);
}
[Fact]
public void ReturnsTrue_WhenHashMatches()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "ABC123" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenHashMatchesCaseInsensitive()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "ABC123", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "abc123" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenCategoryMatches()
{
// Arrange
var torrent = new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies" };
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "movies" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsTrue_WhenTrackerDomainMatches()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Label = "movies",
Trackers = new List<string> { "https://tracker.example.com/announce" }
};
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "example.com" });
// Assert
Assert.True(result);
}
[Fact]
public void ReturnsFalse_WhenNoMatch()
{
// Arrange
var torrent = new RTorrentTorrent
{
Hash = "HASH1",
Name = "Test",
Label = "movies",
Trackers = new List<string> { "https://tracker.example.com/announce" }
};
var wrapper = new RTorrentItemWrapper(torrent);
// Act
var result = wrapper.IsIgnored(new List<string> { "other.com", "tv", "HASH2" });
// Assert
Assert.False(result);
}
}
}

View File

@@ -0,0 +1,689 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceDCTests : IClassFixture<RTorrentServiceFixture>
{
private readonly RTorrentServiceFixture _fixture;
public RTorrentServiceDCTests(RTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class GetSeedingDownloads_Tests : RTorrentServiceDCTests
{
public GetSeedingDownloads_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task FiltersSeedingState()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<RTorrentTorrent>
{
new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", State = 1, Complete = 0, IsPrivate = 0, Label = "" }, // Downloading, not seeding
new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH4", Name = "Torrent 4", State = 0, Complete = 1, IsPrivate = 0, Label = "" } // Stopped, not seeding
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert - only torrents with State=1 AND Complete=1 should be returned
Assert.Equal(2, result.Count);
Assert.All(result, item => Assert.NotNull(item.Hash));
}
[Fact]
public async Task ReturnsEmptyList_WhenNoTorrents()
{
// Arrange
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(new List<RTorrentTorrent>());
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Empty(result);
}
[Fact]
public async Task SkipsTorrentsWithEmptyHash()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<RTorrentTorrent>
{
new RTorrentTorrent { Hash = "", Name = "No Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" },
new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", State = 1, Complete = 1, IsPrivate = 0, Label = "" }
};
_fixture.ClientWrapper
.Setup(x => x.GetAllTorrentsAsync())
.ReturnsAsync(downloads);
// Act
var result = await sut.GetSeedingDownloads();
// Assert
Assert.Single(result);
Assert.Equal("HASH1", result[0].Hash);
}
}
public class FilterDownloadsToBeCleanedAsync_Tests : RTorrentServiceDCTests
{
public FilterDownloadsToBeCleanedAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true },
new SeedingRule { Name = "tv", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
Assert.Contains(result, x => x.Category == "movies");
Assert.Contains(result, x => x.Category == "tv");
}
[Fact]
public void IsCaseInsensitive()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "Movies" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
}
[Fact]
public void ReturnsEmptyList_WhenNoMatches()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "music" })
};
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public void ReturnsNull_WhenDownloadsNull()
{
// Arrange
var sut = _fixture.CreateSut();
var categories = new List<SeedingRule>
{
new SeedingRule { Name = "movies", MaxRatio = -1, MinSeedTime = 0, MaxSeedTime = -1, DeleteSourceFiles = true }
};
// Act
var result = sut.FilterDownloadsToBeCleanedAsync(null, categories);
// Assert
Assert.Null(result);
}
}
public class FilterDownloadsToChangeCategoryAsync_Tests : RTorrentServiceDCTests
{
public FilterDownloadsToChangeCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public void MatchesCategories()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Torrent 1", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH2", Name = "Torrent 2", Label = "tv" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH3", Name = "Torrent 3", Label = "music" })
};
var categories = new List<string> { "movies", "tv" };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Equal(2, result.Count);
}
[Fact]
public void SkipsEmptyHashes()
{
// Arrange
var sut = _fixture.CreateSut();
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "No Hash", Label = "movies" }),
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Valid Hash", Label = "movies" })
};
var categories = new List<string> { "movies" };
// Act
var result = sut.FilterDownloadsToChangeCategoryAsync(downloads, categories);
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.Equal("HASH1", result[0].Hash);
}
}
public class DeleteDownload_Tests : RTorrentServiceDCTests
{
public DeleteDownload_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NormalizesHashToUppercase()
{
// Arrange
var sut = _fixture.CreateSut();
var hash = "lowercase";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
mockTorrent.Setup(x => x.SavePath).Returns("/test/path");
_fixture.ClientWrapper
.Setup(x => x.DeleteTorrentAsync("LOWERCASE"))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(mockTorrent.Object, deleteSourceFiles: false);
// Assert
_fixture.ClientWrapper.Verify(
x => x.DeleteTorrentAsync("LOWERCASE"),
Times.Once);
}
}
public class CreateCategoryAsync_Tests : RTorrentServiceDCTests
{
public CreateCategoryAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task IsNoOp_BecauseRTorrentDoesNotSupportCategories()
{
// Arrange
var sut = _fixture.CreateSut();
// Act
await sut.CreateCategoryAsync("test-category");
// Assert - no client calls should be made
_fixture.ClientWrapper.VerifyNoOtherCalls();
}
}
public class ChangeCategoryForNoHardLinksAsync_Tests : RTorrentServiceDCTests
{
public ChangeCategoryForNoHardLinksAsync_Tests(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task NullDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(null);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task EmptyDownloads_DoesNothing()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(new List<ITorrentItemWrapper>());
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingHash_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingName_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "", Label = "movies", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task MissingCategory_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "", BasePath = "/downloads" })
};
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task GetFilesThrows_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ThrowsAsync(new Exception("XML-RPC error"));
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task SkippedFiles_IgnoredInCheck()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 }, // Skipped
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // Active
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - only called for file2.mkv (the active file)
_fixture.HardLinkFileService.Verify(
x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()),
Times.Once);
}
[Fact]
public async Task NoHardlinks_ChangesLabel()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert - rTorrent uses SetLabelAsync (not SetTorrentCategoryAsync)
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync("HASH1", "unlinked"),
Times.Once);
}
[Fact]
public async Task HasHardlinks_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(2); // Has hardlinks
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task FileNotFound_SkipsTorrent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(-1); // Error / file not found
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.ClientWrapper.Verify(
x => x.SetLabelAsync(It.IsAny<string>(), It.IsAny<string>()),
Times.Never);
}
[Fact]
public async Task PublishesCategoryChangedEvent()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var downloads = new List<ITorrentItemWrapper>
{
new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" })
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
_fixture.EventPublisher.Verify(
x => x.PublishCategoryChanged("movies", "unlinked", false),
Times.Once);
}
[Fact]
public async Task UpdatesCategoryOnWrapper()
{
// Arrange
var sut = _fixture.CreateSut();
var config = new DownloadCleanerConfig
{
Id = Guid.NewGuid(),
UnlinkedTargetCategory = "unlinked"
};
ContextProvider.Set(nameof(DownloadCleanerConfig), config);
var wrapper = new RTorrentItemWrapper(new RTorrentTorrent { Hash = "HASH1", Name = "Test", Label = "movies", BasePath = "/downloads" });
var downloads = new List<ITorrentItemWrapper> { wrapper };
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync("HASH1"))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 1 }
});
_fixture.HardLinkFileService
.Setup(x => x.GetHardLinkCount(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(0);
// Act
await sut.ChangeCategoryForNoHardLinksAsync(downloads);
// Assert
Assert.Equal("unlinked", wrapper.Category);
}
}
}

View File

@@ -0,0 +1,112 @@
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceFixture : IDisposable
{
public Mock<ILogger<RTorrentService>> Logger { get; }
public Mock<IFilenameEvaluator> FilenameEvaluator { get; }
public Mock<IStriker> Striker { get; }
public Mock<IDryRunInterceptor> DryRunInterceptor { get; }
public Mock<IHardLinkFileService> HardLinkFileService { get; }
public Mock<IDynamicHttpClientProvider> HttpClientProvider { get; }
public Mock<IEventPublisher> EventPublisher { get; }
public Mock<IBlocklistProvider> BlocklistProvider { get; }
public Mock<IRuleEvaluator> RuleEvaluator { get; }
public Mock<IRuleManager> RuleManager { get; }
public Mock<IRTorrentClientWrapper> ClientWrapper { get; }
public RTorrentServiceFixture()
{
Logger = new Mock<ILogger<RTorrentService>>();
FilenameEvaluator = new Mock<IFilenameEvaluator>();
Striker = new Mock<IStriker>();
DryRunInterceptor = new Mock<IDryRunInterceptor>();
HardLinkFileService = new Mock<IHardLinkFileService>();
HttpClientProvider = new Mock<IDynamicHttpClientProvider>();
EventPublisher = new Mock<IEventPublisher>();
BlocklistProvider = new Mock<IBlocklistProvider>();
RuleEvaluator = new Mock<IRuleEvaluator>();
RuleManager = new Mock<IRuleManager>();
ClientWrapper = new Mock<IRTorrentClientWrapper>();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public RTorrentService CreateSut(DownloadClientConfig? config = null)
{
config ??= new DownloadClientConfig
{
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 = ""
};
var httpClient = new HttpClient();
HttpClientProvider
.Setup(x => x.CreateClient(It.IsAny<DownloadClientConfig>()))
.Returns(httpClient);
return new RTorrentService(
Logger.Object,
FilenameEvaluator.Object,
Striker.Object,
DryRunInterceptor.Object,
HardLinkFileService.Object,
HttpClientProvider.Object,
EventPublisher.Object,
BlocklistProvider.Object,
config,
RuleEvaluator.Object,
RuleManager.Object,
ClientWrapper.Object
);
}
public void ResetMocks()
{
Logger.Reset();
FilenameEvaluator.Reset();
Striker.Reset();
DryRunInterceptor.Reset();
HardLinkFileService.Reset();
HttpClientProvider.Reset();
EventPublisher.Reset();
RuleEvaluator.Reset();
RuleManager.Reset();
ClientWrapper.Reset();
DryRunInterceptor
.Setup(x => x.InterceptAsync(It.IsAny<Delegate>(), It.IsAny<object[]>()))
.Returns((Delegate action, object[] parameters) =>
{
return (Task)(action.DynamicInvoke(parameters) ?? Task.CompletedTask);
});
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,725 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
using Moq;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient;
public class RTorrentServiceTests : IClassFixture<RTorrentServiceFixture>
{
private readonly RTorrentServiceFixture _fixture;
public RTorrentServiceTests(RTorrentServiceFixture fixture)
{
_fixture = fixture;
_fixture.ResetMocks();
}
public class ShouldRemoveFromArrQueueAsync_BasicScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_BasicScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task TorrentNotFound_ReturnsEmptyResult()
{
// Arrange
const string hash = "nonexistent";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync((RTorrentTorrent?)null);
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
[Fact]
public async Task TorrentWithEmptyHash_ReturnsEmptyResult()
{
// Arrange
const string hash = "test-hash";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash.ToUpperInvariant()))
.ReturnsAsync(new RTorrentTorrent { Hash = "", Name = "Test" });
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentIsIgnored_ReturnsEmptyResult_WithFound()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
Label = "ignored-category",
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, new[] { "ignored-category" });
// Assert
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPrivate()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 1,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.True(result.IsPrivate);
}
[Fact]
public async Task TorrentFound_SetsIsPrivateCorrectly_WhenPublic()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.False(result.IsPrivate);
}
[Fact]
public async Task NormalizesHashToUppercase()
{
// Arrange
const string hash = "lowercase-hash";
var sut = _fixture.CreateSut();
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync("LOWERCASE-HASH"))
.ReturnsAsync((RTorrentTorrent?)null);
// Act
await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
_fixture.ClientWrapper.Verify(
x => x.GetTorrentAsync("LOWERCASE-HASH"),
Times.Once);
}
}
public class ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_AllFilesSkippedScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task AllFilesSkipped_DeletesFromClient()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 0 }
});
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.AllFilesSkipped, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
[Fact]
public async Task SomeFilesWanted_DoesNotRemove()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file1.mkv", Priority = 0 },
new RTorrentFile { Index = 1, Path = "file2.mkv", Priority = 1 } // At least one wanted
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
}
}
public class ShouldRemoveFromArrQueueAsync_FileErrorScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_FileErrorScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task GetTorrentFilesThrows_ReturnsEmptyResult()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ThrowsAsync(new Exception("XML-RPC error"));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.Found);
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
}
public class ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_SlowDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowDownload_NotInDownloadingState_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=1 means seeding (not downloading)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 1,
DownRate = 100
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task SlowDownload_ZeroSpeed_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0 means downloading; DownRate=0 means zero speed
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task SlowDownload_MatchesRule_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0 means downloading; DownRate > 0 means some speed
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 1000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.SlowSpeed, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.SlowSpeed, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
public class ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_StalledDownloadScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task StalledDownload_NotInStalledState_SkipsCheck()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate > 0 = downloading with speed (not stalled)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 5000,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never);
}
[Fact]
public async Task StalledDownload_MatchesRule_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate=0 = stalled (downloading with no speed)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
Assert.True(result.DeleteFromClient);
}
}
public class ShouldRemoveFromArrQueueAsync_IntegrationScenarios : RTorrentServiceTests
{
public ShouldRemoveFromArrQueueAsync_IntegrationScenarios(RTorrentServiceFixture fixture) : base(fixture)
{
}
[Fact]
public async Task SlowCheckPasses_ButStalledCheckFails_RemovesFromQueue()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
// State=1, Complete=0, DownRate=0 = stalled (not downloading, so slow check skipped)
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 0,
SizeBytes = 1000,
CompletedBytes = 500
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
// Slow check is skipped because speed is 0
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((true, DeleteReason.Stalled, true));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.True(result.ShouldRemove);
Assert.Equal(DeleteReason.Stalled, result.DeleteReason);
_fixture.RuleEvaluator.Verify(
x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Never); // Skipped
_fixture.RuleEvaluator.Verify(
x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()),
Times.Once);
}
[Fact]
public async Task BothChecksPass_DoesNotRemove()
{
// Arrange
const string hash = "TEST-HASH";
var sut = _fixture.CreateSut();
var download = new RTorrentTorrent
{
Hash = hash,
Name = "Test Torrent",
IsPrivate = 0,
State = 1,
Complete = 0,
DownRate = 5000000, // Good speed
SizeBytes = 10000000,
CompletedBytes = 5000000
};
_fixture.ClientWrapper
.Setup(x => x.GetTorrentAsync(hash))
.ReturnsAsync(download);
_fixture.ClientWrapper
.Setup(x => x.GetTrackersAsync(hash))
.ReturnsAsync(new List<string>());
_fixture.ClientWrapper
.Setup(x => x.GetTorrentFilesAsync(hash))
.ReturnsAsync(new List<RTorrentFile>
{
new RTorrentFile { Index = 0, Path = "file.mkv", Priority = 1 }
});
_fixture.RuleEvaluator
.Setup(x => x.EvaluateSlowRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
_fixture.RuleEvaluator
.Setup(x => x.EvaluateStallRulesAsync(It.IsAny<RTorrentItemWrapper>()))
.ReturnsAsync((false, DeleteReason.None, false));
// Act
var result = await sut.ShouldRemoveFromArrQueueAsync(hash, Array.Empty<string>());
// Assert
Assert.False(result.ShouldRemove);
Assert.Equal(DeleteReason.None, result.DeleteReason);
}
}
}

View File

@@ -303,44 +303,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(123)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -354,37 +325,20 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "nonexistent-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrentInfo = new TorrentInfo { Id = 456, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync((TransmissionTorrents?)null);
.Setup(x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert - no exception thrown
// Assert
_fixture.ClientWrapper.Verify(
x => x.TorrentRemoveAsync(It.IsAny<long[]>(), It.IsAny<bool>()),
Times.Never);
x => x.TorrentRemoveAsync(It.Is<long[]>(ids => ids.Contains(456)), true),
Times.Once);
}
[Fact]
@@ -393,40 +347,15 @@ public class TransmissionServiceDCTests : IClassFixture<TransmissionServiceFixtu
// Arrange
var sut = _fixture.CreateSut();
const string hash = "test-hash";
var fields = new[]
{
TorrentFields.FILES,
TorrentFields.FILE_STATS,
TorrentFields.HASH_STRING,
TorrentFields.ID,
TorrentFields.ETA,
TorrentFields.NAME,
TorrentFields.STATUS,
TorrentFields.IS_PRIVATE,
TorrentFields.DOWNLOADED_EVER,
TorrentFields.DOWNLOAD_DIR,
TorrentFields.SECONDS_SEEDING,
TorrentFields.UPLOAD_RATIO,
TorrentFields.TRACKERS,
TorrentFields.RATE_DOWNLOAD,
TorrentFields.TOTAL_SIZE
};
var torrents = new TransmissionTorrents
{
Torrents = new[]
{
new TorrentInfo { Id = 123, HashString = hash }
}
};
var torrentInfo = new TorrentInfo { Id = 123, HashString = hash };
var torrentWrapper = new TransmissionItemWrapper(torrentInfo);
_fixture.ClientWrapper
.Setup(x => x.TorrentGetAsync(fields, hash))
.ReturnsAsync(torrents);
.Setup(x => x.TorrentRemoveAsync(It.IsAny<long[]>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(torrentWrapper, true);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -1,3 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.UTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
@@ -290,13 +291,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -310,13 +313,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "UPPERCASE-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.IsAny<List<string>>(), true))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, true);
await sut.DeleteDownload(mockTorrent.Object, true);
// Assert
_fixture.ClientWrapper.Verify(
@@ -330,13 +335,15 @@ public class UTorrentServiceDCTests : IClassFixture<UTorrentServiceFixture>
// Arrange
var sut = _fixture.CreateSut();
const string hash = "TEST-HASH";
var mockTorrent = new Mock<ITorrentItemWrapper>();
mockTorrent.Setup(x => x.Hash).Returns(hash);
_fixture.ClientWrapper
.Setup(x => x.RemoveTorrentsAsync(It.Is<List<string>>(h => h.Contains("test-hash")), false))
.Returns(Task.CompletedTask);
// Act
await sut.DeleteDownload(hash, false);
await sut.DeleteDownload(mockTorrent.Object, false);
// Assert
_fixture.ClientWrapper.Verify(

View File

@@ -44,6 +44,8 @@ public sealed class DelugeItemWrapper : ITorrentItemWrapper
set => Info.Label = value;
}
public string SavePath => Info.DownloadLocation ?? string.Empty;
public bool IsDownloading() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true;
public bool IsStalled() => Info.State?.Equals("Downloading", StringComparison.InvariantCultureIgnoreCase) == true && Info is { DownloadSpeed: <= 0, Eta: <= 0 };

View File

@@ -37,9 +37,11 @@ public partial class DelugeService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
string hash = torrent.Hash.ToLowerInvariant();
await _client.DeleteTorrents([hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -139,14 +141,6 @@ public partial class DelugeService
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.DeleteTorrents([hash], deleteSourceFiles);
}
protected async Task CreateLabel(string name)
{
await _client.CreateLabel(name);

View File

@@ -66,9 +66,6 @@ public abstract class DownloadService : IDownloadService
public abstract Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads);
/// <inheritdoc/>
public abstract Task DeleteDownload(string hash, bool deleteSourceFiles);
/// <inheritdoc/>
public abstract Task<List<ITorrentItemWrapper>> GetSeedingDownloads();
@@ -123,7 +120,7 @@ public abstract class DownloadService : IDownloadService
continue;
}
await _dryRunInterceptor.InterceptAsync(() => DeleteDownloadInternal(torrent, category.DeleteSourceFiles));
await _dryRunInterceptor.InterceptAsync(() => DeleteDownload(torrent, category.DeleteSourceFiles));
_logger.LogInformation(
"download cleaned | {reason} reached | delete files: {deleteFiles} | {name}",
@@ -153,7 +150,7 @@ public abstract class DownloadService : IDownloadService
/// </summary>
/// <param name="torrent">The torrent to delete</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent</param>
protected abstract Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles);
public abstract Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles);
protected SeedingCheckResult ShouldCleanDownload(double ratio, TimeSpan seedingTime, SeedingRule category)
{
@@ -245,4 +242,56 @@ public abstract class DownloadService : IDownloadService
// max seed time is 0 or reached
return true;
}
protected bool TryDeleteFiles(string path, bool failOnNotFound)
{
if (string.IsNullOrEmpty(path))
{
_logger.LogTrace("File path is null or empty");
if (failOnNotFound)
{
return false;
}
return true;
}
if (Directory.Exists(path))
{
try
{
Directory.Delete(path, true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete directory: {path}", path);
return false;
}
}
if (File.Exists(path))
{
try
{
File.Delete(path);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete file: {path}", path);
return false;
}
}
_logger.LogTrace("File path to delete not found: {path}", path);
if (failOnNotFound)
{
return false;
}
return true;
}
}

View File

@@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService;
using QBitService = Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent.QBitService;
using RTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent.RTorrentService;
using TransmissionService = Cleanuparr.Infrastructure.Features.DownloadClient.Transmission.TransmissionService;
using UTorrentService = Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent.UTorrentService;
@@ -54,6 +55,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig),
DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig),
DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig),
DownloadClientTypeName.rTorrent => CreateRTorrentService(downloadClientConfig),
_ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported")
};
}
@@ -151,4 +153,27 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory
return service;
}
private RTorrentService CreateRTorrentService(DownloadClientConfig downloadClientConfig)
{
var logger = _serviceProvider.GetRequiredService<ILogger<RTorrentService>>();
var filenameEvaluator = _serviceProvider.GetRequiredService<IFilenameEvaluator>();
var striker = _serviceProvider.GetRequiredService<IStriker>();
var dryRunInterceptor = _serviceProvider.GetRequiredService<IDryRunInterceptor>();
var hardLinkFileService = _serviceProvider.GetRequiredService<IHardLinkFileService>();
var httpClientProvider = _serviceProvider.GetRequiredService<IDynamicHttpClientProvider>();
var eventPublisher = _serviceProvider.GetRequiredService<IEventPublisher>();
var blocklistProvider = _serviceProvider.GetRequiredService<IBlocklistProvider>();
var ruleEvaluator = _serviceProvider.GetRequiredService<IRuleEvaluator>();
var ruleManager = _serviceProvider.GetRequiredService<IRuleManager>();
// Create the RTorrentService instance
RTorrentService service = new(
logger, filenameEvaluator, striker, dryRunInterceptor,
hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
);
return service;
}
}

View File

@@ -62,9 +62,9 @@ public interface IDownloadService : IDisposable
/// <summary>
/// Deletes a download item.
/// </summary>
/// <param name="hash">The torrent hash.</param>
/// <param name="item">The torrent item.</param>
/// <param name="deleteSourceFiles">Whether to delete the source files along with the torrent. Defaults to true.</param>
public Task DeleteDownload(string hash, bool deleteSourceFiles);
public Task DeleteDownload(ITorrentItemWrapper item, bool deleteSourceFiles);
/// <summary>
/// Creates a category.

View File

@@ -47,6 +47,8 @@ public sealed class QBitItemWrapper : ITorrentItemWrapper
set => Info.Category = value;
}
public string SavePath => Info.SavePath ?? string.Empty;
public IReadOnlyList<string> Tags => Info.Tags?.ToList().AsReadOnly() ?? (IReadOnlyList<string>)Array.Empty<string>();
public bool IsDownloading() => Info.State is TorrentState.Downloading or TorrentState.ForcedDownload;

View File

@@ -61,9 +61,9 @@ public partial class QBitService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
await _client.DeleteAsync([torrent.Hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -172,12 +172,6 @@ public partial class QBitService
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
await _client.DeleteAsync([hash], deleteDownloadedData: deleteSourceFiles);
}
protected async Task CreateCategory(string name)
{
await _client.AddCategoryAsync(name);

View File

@@ -0,0 +1,16 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public interface IRTorrentClientWrapper
{
Task<string> GetVersionAsync();
Task<List<RTorrentTorrent>> GetAllTorrentsAsync();
Task<RTorrentTorrent?> GetTorrentAsync(string hash);
Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash);
Task<List<string>> GetTrackersAsync(string hash);
Task DeleteTorrentAsync(string hash);
Task SetFilePriorityAsync(string hash, int fileIndex, int priority);
Task<string?> GetLabelAsync(string hash);
Task SetLabelAsync(string hash, string label);
}

View File

@@ -0,0 +1,5 @@
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public interface IRTorrentService : IDownloadService
{
}

View File

@@ -0,0 +1,399 @@
using System.Net.Http.Headers;
using System.Text;
using System.Xml.Linq;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Persistence.Models.Configuration;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
/// <summary>
/// Low-level XML-RPC client for communicating with rTorrent
/// </summary>
public sealed class RTorrentClient
{
private readonly DownloadClientConfig _config;
private readonly HttpClient _httpClient;
// Fields to request when fetching torrent data via d.multicall2
private static readonly string[] TorrentFields =
[
"d.hash=",
"d.name=",
"d.is_private=",
"d.size_bytes=",
"d.completed_bytes=",
"d.down.rate=",
"d.ratio=",
"d.state=",
"d.complete=",
"d.timestamp.finished=",
"d.custom1=",
"d.base_path="
];
// Fields to request when fetching file data via f.multicall
private static readonly string[] FileFields =
[
"f.path=",
"f.size_bytes=",
"f.priority=",
"f.completed_chunks=",
"f.size_chunks="
];
public RTorrentClient(DownloadClientConfig config, HttpClient httpClient)
{
_config = config;
_httpClient = httpClient;
}
/// <summary>
/// Gets the rTorrent client version for health check
/// </summary>
public async Task<string> GetVersionAsync()
{
var response = await CallAsync("system.client_version");
return ParseStringValue(response);
}
/// <summary>
/// Gets all torrents with their status information
/// </summary>
public async Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
{
var args = new object[] { "", "main" }.Concat(TorrentFields.Cast<object>()).ToArray();
var response = await CallAsync("d.multicall2", args);
return ParseTorrentList(response);
}
/// <summary>
/// Gets a single torrent by hash
/// </summary>
public async Task<RTorrentTorrent?> GetTorrentAsync(string hash)
{
try
{
var fields = TorrentFields.Select(f => f.TrimEnd('=')).ToArray();
var tasks = fields.Select(field => CallAsync(field, hash)).ToArray();
var responses = await Task.WhenAll(tasks);
var values = responses.Select(ParseSingleValue).ToArray();
return CreateTorrentFromValues(values);
}
catch (RTorrentClientException)
{
return null;
}
catch (HttpRequestException)
{
return null;
}
}
/// <summary>
/// Gets all files for a torrent
/// </summary>
public async Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
{
var args = new object[] { hash, "" }.Concat(FileFields.Cast<object>()).ToArray();
var response = await CallAsync("f.multicall", args);
return ParseFileList(response);
}
/// <summary>
/// Gets tracker URLs for a torrent
/// </summary>
public async Task<List<string>> GetTrackersAsync(string hash)
{
var response = await CallAsync("t.multicall", hash, "", "t.url=");
return ParseTrackerList(response);
}
/// <summary>
/// Deletes a torrent from rTorrent
/// </summary>
/// <param name="hash">Torrent hash</param>
public async Task DeleteTorrentAsync(string hash)
{
await CallAsync("d.erase", hash);
}
/// <summary>
/// Sets the priority for a file within a torrent
/// </summary>
/// <param name="hash">Torrent hash</param>
/// <param name="fileIndex">File index (0-based)</param>
/// <param name="priority">Priority: 0=skip, 1=normal, 2=high</param>
public async Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
{
// rTorrent uses hash:f<index> format for file commands
await CallAsync("f.priority.set", $"{hash}:f{fileIndex}", priority);
}
/// <summary>
/// Gets the label (category) for a torrent
/// </summary>
public async Task<string?> GetLabelAsync(string hash)
{
var response = await CallAsync("d.custom1", hash);
var label = ParseStringValue(response);
return string.IsNullOrEmpty(label) ? null : label;
}
/// <summary>
/// Sets the label (category) for a torrent
/// </summary>
public async Task SetLabelAsync(string hash, string label)
{
await CallAsync("d.custom1.set", hash, label);
}
/// <summary>
/// Sends an XML-RPC call to rTorrent
/// </summary>
private async Task<XElement> CallAsync(string method, params object[] parameters)
{
var requestXml = BuildXmlRpcRequest(method, parameters);
var responseXml = await SendRequestAsync(requestXml);
return ParseXmlRpcResponse(responseXml);
}
private string BuildXmlRpcRequest(string method, object[] parameters)
{
var doc = new XDocument(
new XElement("methodCall",
new XElement("methodName", method),
new XElement("params",
parameters.Select(p => new XElement("param", SerializeValue(p)))
)
)
);
return doc.ToString(SaveOptions.DisableFormatting);
}
private XElement SerializeValue(object? value)
{
return value switch
{
null => new XElement("value", new XElement("string", "")),
string s => new XElement("value", new XElement("string", s)),
int i => new XElement("value", new XElement("i4", i)),
long l => new XElement("value", new XElement("i8", l)),
bool b => new XElement("value", new XElement("boolean", b ? "1" : "0")),
double d => new XElement("value", new XElement("double", d)),
string[] arr => new XElement("value",
new XElement("array",
new XElement("data",
arr.Select(item => new XElement("value", new XElement("string", item)))
)
)
),
object[] arr => new XElement("value",
new XElement("array",
new XElement("data",
arr.Select(item => SerializeValue(item))
)
)
),
_ => new XElement("value", new XElement("string", value.ToString()))
};
}
private async Task<string> SendRequestAsync(string requestXml)
{
var content = new StringContent(requestXml, Encoding.UTF8, "text/xml");
content.Headers.ContentType = new MediaTypeHeaderValue("text/xml");
var response = await _httpClient.PostAsync(_config.Url, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
private XElement ParseXmlRpcResponse(string responseXml)
{
var doc = XDocument.Parse(responseXml);
var root = doc.Root;
if (root == null)
{
throw new RTorrentClientException("Invalid XML-RPC response: empty document");
}
// Check for fault response
var fault = root.Element("fault");
if (fault != null)
{
var faultValue = fault.Element("value");
var faultStruct = faultValue?.Element("struct");
var faultString = faultStruct?.Elements("member")
.FirstOrDefault(m => m.Element("name")?.Value == "faultString")
?.Element("value")?.Value ?? "Unknown XML-RPC fault";
throw new RTorrentClientException($"XML-RPC fault: {faultString}");
}
// Get the response value
var paramsElement = root.Element("params");
var param = paramsElement?.Element("param");
var value = param?.Element("value");
if (value == null)
{
throw new RTorrentClientException("Invalid XML-RPC response: missing value");
}
return value;
}
private static string ParseStringValue(XElement value)
{
// Value can be directly text or wrapped in <string>, <i4>, <i8>, etc.
var stringEl = value.Element("string");
if (stringEl != null) return stringEl.Value;
var i4El = value.Element("i4");
if (i4El != null) return i4El.Value;
var i8El = value.Element("i8");
if (i8El != null) return i8El.Value;
// Direct text content
if (!value.HasElements) return value.Value;
return value.Elements().First().Value;
}
private static object? ParseSingleValue(XElement value)
{
var stringEl = value.Element("string");
if (stringEl != null) return stringEl.Value;
var i4El = value.Element("i4");
if (i4El != null) return long.TryParse(i4El.Value, out var i4) ? i4 : 0L;
var i8El = value.Element("i8");
if (i8El != null) return long.TryParse(i8El.Value, out var i8) ? i8 : 0L;
var intEl = value.Element("int");
if (intEl != null) return long.TryParse(intEl.Value, out var intVal) ? intVal : 0L;
var boolEl = value.Element("boolean");
if (boolEl != null) return boolEl.Value == "1";
var doubleEl = value.Element("double");
if (doubleEl != null) return double.TryParse(doubleEl.Value, out var d) ? d : 0.0;
// Direct text content
if (!value.HasElements) return value.Value;
return value.Elements().First().Value;
}
private List<RTorrentTorrent> ParseTorrentList(XElement value)
{
var result = new List<RTorrentTorrent>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
var torrent = CreateTorrentFromValues(values);
if (torrent != null)
{
result.Add(torrent);
}
}
return result;
}
private static RTorrentTorrent? CreateTorrentFromValues(object?[] values)
{
if (values.Length < 12) return null;
return new RTorrentTorrent
{
Hash = values[0]?.ToString() ?? "",
Name = values[1]?.ToString() ?? "",
IsPrivate = Convert.ToInt32(values[2] ?? 0),
SizeBytes = Convert.ToInt64(values[3] ?? 0),
CompletedBytes = Convert.ToInt64(values[4] ?? 0),
DownRate = Convert.ToInt64(values[5] ?? 0),
Ratio = Convert.ToInt64(values[6] ?? 0),
State = Convert.ToInt32(values[7] ?? 0),
Complete = Convert.ToInt32(values[8] ?? 0),
TimestampFinished = Convert.ToInt64(values[9] ?? 0),
Label = values[10]?.ToString(),
BasePath = values[11]?.ToString()
};
}
private List<RTorrentFile> ParseFileList(XElement value)
{
var result = new List<RTorrentFile>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
int index = 0;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var values = innerArray.Elements("value").Select(ParseSingleValue).ToArray();
if (values.Length >= 5)
{
result.Add(new RTorrentFile
{
Index = index,
Path = values[0]?.ToString() ?? "",
SizeBytes = Convert.ToInt64(values[1] ?? 0),
Priority = Convert.ToInt32(values[2] ?? 1),
CompletedChunks = Convert.ToInt64(values[3] ?? 0),
SizeChunks = Convert.ToInt64(values[4] ?? 0)
});
index++;
}
}
return result;
}
private List<string> ParseTrackerList(XElement value)
{
var result = new List<string>();
var array = value.Element("array");
var data = array?.Element("data");
if (data == null) return result;
foreach (var itemValue in data.Elements("value"))
{
var innerArray = itemValue.Element("array")?.Element("data");
if (innerArray == null) continue;
var url = innerArray.Elements("value").FirstOrDefault();
if (url != null)
{
var trackerUrl = ParseStringValue(url);
if (!string.IsNullOrEmpty(trackerUrl))
{
result.Add(trackerUrl);
}
}
}
return result;
}
}

View File

@@ -0,0 +1,40 @@
using Cleanuparr.Domain.Entities.RTorrent.Response;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public sealed class RTorrentClientWrapper : IRTorrentClientWrapper
{
private readonly RTorrentClient _client;
public RTorrentClientWrapper(RTorrentClient client)
{
_client = client;
}
public Task<string> GetVersionAsync()
=> _client.GetVersionAsync();
public Task<List<RTorrentTorrent>> GetAllTorrentsAsync()
=> _client.GetAllTorrentsAsync();
public Task<RTorrentTorrent?> GetTorrentAsync(string hash)
=> _client.GetTorrentAsync(hash);
public Task<List<RTorrentFile>> GetTorrentFilesAsync(string hash)
=> _client.GetTorrentFilesAsync(hash);
public Task<List<string>> GetTrackersAsync(string hash)
=> _client.GetTrackersAsync(hash);
public Task DeleteTorrentAsync(string hash)
=> _client.DeleteTorrentAsync(hash);
public Task SetFilePriorityAsync(string hash, int fileIndex, int priority)
=> _client.SetFilePriorityAsync(hash, fileIndex, priority);
public Task<string?> GetLabelAsync(string hash)
=> _client.GetLabelAsync(hash);
public Task SetLabelAsync(string hash, string label)
=> _client.SetLabelAsync(hash, label);
}

View File

@@ -0,0 +1,121 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Services;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
/// <summary>
/// Wrapper for RTorrentTorrent that implements ITorrentItemWrapper interface
/// </summary>
public sealed class RTorrentItemWrapper : ITorrentItemWrapper
{
public RTorrentTorrent Info { get; }
private readonly IReadOnlyList<string> _trackers;
private string? _category;
public RTorrentItemWrapper(RTorrentTorrent torrent, IReadOnlyList<string>? trackers = null)
{
Info = torrent ?? throw new ArgumentNullException(nameof(torrent));
_trackers = trackers ?? torrent.Trackers ?? [];
_category = torrent.Label;
}
public string Hash => Info.Hash;
public string Name => Info.Name;
public bool IsPrivate => Info.IsPrivate == 1;
public long Size => Info.SizeBytes;
public double CompletionPercentage => Info.SizeBytes > 0
? (Info.CompletedBytes / (double)Info.SizeBytes) * 100.0
: 0.0;
public long DownloadedBytes => Info.CompletedBytes;
public long DownloadSpeed => Info.DownRate;
/// <summary>
/// Ratio from rTorrent (returned as ratio * 1000, so divide by 1000)
/// </summary>
public double Ratio => Info.Ratio / 1000.0;
public long Eta => CalculateEta();
public long SeedingTimeSeconds => CalculateSeedingTime();
public string? Category
{
get => _category;
set => _category = value;
}
public string SavePath => Info.BasePath ?? string.Empty;
/// <summary>
/// Downloading when state is 1 (started) and complete is 0 (not finished)
/// </summary>
public bool IsDownloading() => Info.State == 1 && Info.Complete == 0;
/// <summary>
/// Stalled when downloading but no download speed and no ETA
/// </summary>
public bool IsStalled() => IsDownloading() && Info.DownRate <= 0 && Eta <= 0;
public bool IsIgnored(IReadOnlyList<string> ignoredDownloads)
{
if (ignoredDownloads.Count == 0)
{
return false;
}
foreach (string pattern in ignoredDownloads)
{
if (Hash.Equals(pattern, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (Category?.Equals(pattern, StringComparison.InvariantCultureIgnoreCase) is true)
{
return true;
}
if (_trackers.Any(url => UriService.GetDomain(url)?.EndsWith(pattern, StringComparison.InvariantCultureIgnoreCase) is true))
{
return true;
}
}
return false;
}
/// <summary>
/// Calculate ETA based on remaining bytes and download speed
/// </summary>
private long CalculateEta()
{
if (Info.DownRate <= 0) return 0;
long remaining = Info.SizeBytes - Info.CompletedBytes;
if (remaining <= 0) return 0;
return remaining / Info.DownRate;
}
/// <summary>
/// Calculate seeding time based on the timestamp when the torrent finished downloading.
/// rTorrent doesn't natively track seeding time, so we calculate it from completion timestamp.
/// </summary>
private long CalculateSeedingTime()
{
// If not finished yet, no seeding time
if (Info.Complete != 1 || Info.TimestampFinished <= 0)
{
return 0;
}
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var seedingTime = now - Info.TimestampFinished;
return seedingTime > 0 ? seedingTime : 0;
}
}

View File

@@ -0,0 +1,108 @@
using Cleanuparr.Domain.Entities.HealthCheck;
using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Services.Interfaces;
using Cleanuparr.Persistence.Models.Configuration;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService : DownloadService, IRTorrentService
{
private readonly IRTorrentClientWrapper _client;
public RTorrentService(
ILogger<RTorrentService> logger,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
var rtorrentClient = new RTorrentClient(downloadClientConfig, _httpClient);
_client = new RTorrentClientWrapper(rtorrentClient);
}
// Internal constructor for testing
internal RTorrentService(
ILogger<RTorrentService> logger,
IFilenameEvaluator filenameEvaluator,
IStriker striker,
IDryRunInterceptor dryRunInterceptor,
IHardLinkFileService hardLinkFileService,
IDynamicHttpClientProvider httpClientProvider,
IEventPublisher eventPublisher,
IBlocklistProvider blocklistProvider,
DownloadClientConfig downloadClientConfig,
IRuleEvaluator ruleEvaluator,
IRuleManager ruleManager,
IRTorrentClientWrapper clientWrapper
) : base(
logger, filenameEvaluator, striker, dryRunInterceptor, hardLinkFileService,
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, ruleManager
)
{
_client = clientWrapper;
}
/// <summary>
/// rTorrent doesn't have its own authentication - it relies on HTTP Basic Auth
/// handled by the reverse proxy (nginx/apache). No action needed here.
/// </summary>
public override Task LoginAsync()
{
_logger.LogDebug("rTorrent authentication is handled by HTTP Basic Auth via reverse proxy for client {clientId}", _downloadClientConfig.Id);
return Task.CompletedTask;
}
public override async Task<HealthCheckResult> HealthCheckAsync()
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// Try to get the version - this is a simple health check
var version = await _client.GetVersionAsync();
stopwatch.Stop();
_logger.LogDebug("Health check: rTorrent version {version} for client {clientId}", version, _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = true,
ResponseTime = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogWarning(ex, "Health check failed for rTorrent client {clientId}", _downloadClientConfig.Id);
return new HealthCheckResult
{
IsHealthy = false,
ErrorMessage = $"Connection failed: {ex.Message}",
ResponseTime = stopwatch.Elapsed
};
}
}
public override void Dispose()
{
}
}

View File

@@ -0,0 +1,145 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
/// <inheritdoc/>
public override async Task<BlockFilesResult> BlockUnwantedFilesAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
// rTorrent uses uppercase hashes
hash = hash.ToUpperInvariant();
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
BlockFilesResult result = new();
if (download?.Hash is null)
{
_logger.LogDebug("failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
result.IsPrivate = download.IsPrivate == 1;
result.Found = true;
// Get trackers for ignore check
var trackers = await _client.GetTrackersAsync(hash);
var torrentWrapper = new RTorrentItemWrapper(download, trackers);
if (ignoredDownloads.Count > 0 && torrentWrapper.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", download.Name);
return result;
}
var malwareBlockerConfig = ContextProvider.Get<ContentBlockerConfig>();
if (malwareBlockerConfig.IgnorePrivate && download.IsPrivate == 1)
{
_logger.LogDebug("skip files check | download is private | {name}", download.Name);
return result;
}
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", download.Name);
return result;
}
if (files.Count == 0)
{
return result;
}
bool hasPriorityUpdates = false;
long totalFiles = 0;
long totalUnwantedFiles = 0;
InstanceType instanceType = (InstanceType)ContextProvider.Get<object>(nameof(InstanceType));
BlocklistType blocklistType = _blocklistProvider.GetBlocklistType(instanceType);
ConcurrentBag<string> patterns = _blocklistProvider.GetPatterns(instanceType);
ConcurrentBag<Regex> regexes = _blocklistProvider.GetRegexes(instanceType);
ConcurrentBag<string> malwarePatterns = _blocklistProvider.GetMalwarePatterns();
List<(int Index, int Priority)> priorityUpdates = [];
foreach (var file in files)
{
totalFiles++;
string fileName = Path.GetFileName(file.Path);
if (result.ShouldRemove)
{
continue;
}
if (malwareBlockerConfig.DeleteKnownMalware && _filenameEvaluator.IsKnownMalware(fileName, malwarePatterns))
{
_logger.LogInformation("malware file found | {file} | {title}", file.Path, download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.MalwareFileFound;
}
if (file.Priority == 0)
{
_logger.LogTrace("File is already skipped | {file}", file.Path);
totalUnwantedFiles++;
continue;
}
if (!_filenameEvaluator.IsValid(fileName, blocklistType, patterns, regexes))
{
totalUnwantedFiles++;
hasPriorityUpdates = true;
priorityUpdates.Add((file.Index, 0));
_logger.LogInformation("unwanted file found | {file}", file.Path);
continue;
}
_logger.LogTrace("File is valid | {file}", file.Path);
}
if (result.ShouldRemove)
{
return result;
}
if (!hasPriorityUpdates)
{
return result;
}
if (totalUnwantedFiles == totalFiles)
{
_logger.LogDebug("All files are blocked for {name}", download.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesBlocked;
}
_logger.LogDebug("Marking {count} unwanted files as skipped for {name}", priorityUpdates.Count, download.Name);
foreach (var (index, priority) in priorityUpdates)
{
await _dryRunInterceptor.InterceptAsync(SetFilePriority, hash, index, priority);
}
return result;
}
protected virtual async Task SetFilePriority(string hash, int index, int priority)
{
await _client.SetFilePriorityAsync(hash, index, priority);
}
}

View File

@@ -0,0 +1,147 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
public override async Task<List<ITorrentItemWrapper>> GetSeedingDownloads()
{
var downloads = await _client.GetAllTorrentsAsync();
return 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))
.ToList();
}
public override List<ITorrentItemWrapper>? FilterDownloadsToBeCleanedAsync(List<ITorrentItemWrapper>? downloads, List<SeedingRule> seedingRules) =>
downloads
?.Where(x => seedingRules.Any(cat => cat.Name.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
public override List<ITorrentItemWrapper>? FilterDownloadsToChangeCategoryAsync(List<ITorrentItemWrapper>? downloads, List<string> categories) =>
downloads
?.Where(x => !string.IsNullOrEmpty(x.Hash))
.Where(x => categories.Any(cat => cat.Equals(x.Category, StringComparison.InvariantCultureIgnoreCase)))
.ToList();
/// <inheritdoc/>
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
string hash = torrent.Hash.ToUpperInvariant();
await _client.DeleteTorrentAsync(hash);
if (deleteSourceFiles)
{
if (!TryDeleteFiles(torrent.SavePath, true))
{
_logger.LogWarning("Failed to delete files | {name}", torrent.Name);
}
}
}
/// <summary>
/// rTorrent doesn't have native category management. Labels are stored in d.custom1
/// and are created implicitly when set. This is a no-op.
/// </summary>
public override Task CreateCategoryAsync(string name)
{
return Task.CompletedTask;
}
public override async Task ChangeCategoryForNoHardLinksAsync(List<ITorrentItemWrapper>? downloads)
{
if (downloads?.Count is null or 0)
{
return;
}
var downloadCleanerConfig = ContextProvider.Get<DownloadCleanerConfig>(nameof(DownloadCleanerConfig));
foreach (RTorrentItemWrapper torrent in downloads.Cast<RTorrentItemWrapper>())
{
if (string.IsNullOrEmpty(torrent.Hash) || string.IsNullOrEmpty(torrent.Name) || string.IsNullOrEmpty(torrent.Category))
{
continue;
}
ContextProvider.Set(ContextProvider.Keys.ItemName, torrent.Name);
ContextProvider.Set(ContextProvider.Keys.Hash, torrent.Hash);
ContextProvider.Set(ContextProvider.Keys.DownloadClientUrl, _downloadClientConfig.ExternalOrInternalUrl);
ContextProvider.Set(ContextProvider.Keys.DownloadClientType, _downloadClientConfig.TypeName);
ContextProvider.Set(ContextProvider.Keys.DownloadClientName, _downloadClientConfig.Name);
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(torrent.Hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find torrent files for {name}", torrent.Name);
continue;
}
bool hasHardlinks = false;
bool hasErrors = false;
foreach (var file in files)
{
string filePath = string.Join(Path.DirectorySeparatorChar,
Path.Combine(torrent.Info.BasePath ?? "", file.Path).Split(['\\', '/']));
if (file.Priority <= 0)
{
_logger.LogDebug("skip | file is not downloaded | {file}", filePath);
continue;
}
long hardlinkCount = _hardLinkFileService
.GetHardLinkCount(filePath, downloadCleanerConfig.UnlinkedIgnoredRootDirs.Count > 0);
if (hardlinkCount < 0)
{
_logger.LogError("skip | file does not exist or insufficient permissions | {file}", filePath);
hasErrors = true;
continue;
}
if (hardlinkCount > 0)
{
hasHardlinks = true;
break;
}
}
if (hasErrors)
{
continue;
}
if (hasHardlinks)
{
_logger.LogDebug("skip | download has hardlinks | {name}", torrent.Name);
continue;
}
await _dryRunInterceptor.InterceptAsync(ChangeLabel, torrent.Hash, downloadCleanerConfig.UnlinkedTargetCategory);
_logger.LogInformation("category changed for {name}", torrent.Name);
await _eventPublisher.PublishCategoryChanged(torrent.Category, downloadCleanerConfig.UnlinkedTargetCategory);
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{
await _client.SetLabelAsync(hash, newLabel);
}
}

View File

@@ -0,0 +1,108 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Entities.RTorrent.Response;
using Cleanuparr.Domain.Enums;
using Microsoft.Extensions.Logging;
namespace Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent;
public partial class RTorrentService
{
/// <inheritdoc/>
public override async Task<DownloadCheckResult> ShouldRemoveFromArrQueueAsync(string hash, IReadOnlyList<string> ignoredDownloads)
{
// rTorrent uses uppercase hashes
hash = hash.ToUpperInvariant();
DownloadCheckResult result = new();
RTorrentTorrent? download = await _client.GetTorrentAsync(hash);
if (string.IsNullOrEmpty(download?.Hash))
{
_logger.LogDebug("Failed to find torrent {hash} in the {name} download client", hash, _downloadClientConfig.Name);
return result;
}
result.IsPrivate = download.IsPrivate == 1;
result.Found = true;
// Get trackers for ignore check
var trackers = await _client.GetTrackersAsync(hash);
RTorrentItemWrapper torrent = new(download, trackers);
if (torrent.IsIgnored(ignoredDownloads))
{
_logger.LogInformation("skip | download is ignored | {name}", torrent.Name);
return result;
}
List<RTorrentFile> files;
try
{
files = await _client.GetTorrentFilesAsync(hash);
}
catch (Exception exception)
{
_logger.LogDebug(exception, "failed to find files in the download client | {name}", torrent.Name);
return result;
}
// Check if all files are skipped (priority = 0)
bool hasActiveFiles = files.Any(f => f.Priority > 0);
if (files.Count > 0 && !hasActiveFiles)
{
// remove if all files are unwanted
_logger.LogTrace("all files are unwanted | removing download | {name}", torrent.Name);
result.ShouldRemove = true;
result.DeleteReason = DeleteReason.AllFilesSkipped;
result.DeleteFromClient = true;
return result;
}
// remove if download is stuck
(result.ShouldRemove, result.DeleteReason, result.DeleteFromClient) = await EvaluateDownloadRemoval(torrent);
return result;
}
private async Task<(bool, DeleteReason, bool)> EvaluateDownloadRemoval(ITorrentItemWrapper wrapper)
{
(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient) result = await CheckIfSlow(wrapper);
if (result.ShouldRemove)
{
return result;
}
return await CheckIfStuck(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfSlow(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsDownloading())
{
_logger.LogTrace("skip slow check | download is not in downloading state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
if (wrapper.DownloadSpeed <= 0)
{
_logger.LogTrace("skip slow check | download speed is 0 | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateSlowRulesAsync(wrapper);
}
private async Task<(bool ShouldRemove, DeleteReason Reason, bool DeleteFromClient)> CheckIfStuck(ITorrentItemWrapper wrapper)
{
if (!wrapper.IsStalled())
{
_logger.LogTrace("skip stalled check | download is not in stalled state | {name}", wrapper.Name);
return (false, DeleteReason.None, false);
}
return await _ruleEvaluator.EvaluateStallRulesAsync(wrapper);
}
}

View File

@@ -46,6 +46,8 @@ public sealed class TransmissionItemWrapper : ITorrentItemWrapper
get => Info.GetCategory();
set => Info.AppendCategory(value);
}
public string SavePath => Info.DownloadDir ?? string.Empty;
// Transmission status: 0=stopped, 1=check pending, 2=checking, 3=download pending, 4=downloading, 5=seed pending, 6=seeding
public bool IsDownloading() => Info.Status == 4;

View File

@@ -1,5 +1,4 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Extensions;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
@@ -39,10 +38,10 @@ public partial class TransmissionService
}
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
var transmissionTorrent = (TransmissionItemWrapper)torrent;
await RemoveDownloadAsync(transmissionTorrent.Info.Id, deleteSourceFiles);
await _client.TorrentRemoveAsync([transmissionTorrent.Info.Id], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -137,21 +136,4 @@ public partial class TransmissionService
{
await _client.TorrentSetLocationAsync([downloadId], newLocation, true);
}
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
TorrentInfo? torrent = await GetTorrentAsync(hash);
if (torrent is null)
{
return;
}
await _client.TorrentRemoveAsync([torrent.Id], deleteSourceFiles);
}
protected virtual async Task RemoveDownloadAsync(long downloadId, bool deleteSourceFiles)
{
await _client.TorrentRemoveAsync([downloadId], deleteSourceFiles);
}
}

View File

@@ -45,6 +45,8 @@ public sealed class UTorrentItemWrapper : ITorrentItemWrapper
set => Info.Label = value ?? throw new ArgumentNullException(nameof(value));
}
public string SavePath => Info.SavePath ?? string.Empty;
public bool IsDownloading() =>
(Info.Status & UTorrentStatus.Started) != 0 &&
(Info.Status & UTorrentStatus.Checked) != 0 &&

View File

@@ -36,9 +36,10 @@ public partial class UTorrentService
.ToList();
/// <inheritdoc/>
protected override async Task DeleteDownloadInternal(ITorrentItemWrapper torrent, bool deleteSourceFiles)
public override async Task DeleteDownload(ITorrentItemWrapper torrent, bool deleteSourceFiles)
{
await DeleteDownload(torrent.Hash, deleteSourceFiles);
string hash = torrent.Hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
}
public override async Task CreateCategoryAsync(string name)
@@ -120,14 +121,6 @@ public partial class UTorrentService
torrent.Category = downloadCleanerConfig.UnlinkedTargetCategory;
}
}
/// <inheritdoc/>
public override async Task DeleteDownload(string hash, bool deleteSourceFiles)
{
hash = hash.ToLowerInvariant();
await _client.RemoveTorrentsAsync([hash], deleteSourceFiles);
}
protected virtual async Task ChangeLabel(string hash, string newLabel)
{

View File

@@ -76,11 +76,13 @@
hint="Username for authentication"
helpKey="download-client:username" />
}
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalPassword"
hint="Password for authentication"
helpKey="download-client:password" />
@if (showPasswordField()) {
<app-input label="Password" placeholder="Enter password" type="password" [(value)]="modalPassword"
hint="Password for authentication"
helpKey="download-client:password" />
}
<app-input label="URL Base" placeholder="/api/v2" [(value)]="modalUrlBase"
hint="Optional URL base path, leave blank for default"
[hint]="urlBaseHint()"
helpKey="download-client:urlBase" />
<app-input label="External URL" placeholder="https://qbit.example.com" type="url" [(value)]="modalExternalUrl"
hint="Optional URL used in notifications for clickable links (e.g., when internal Docker URLs are not reachable externally)"

View File

@@ -20,6 +20,7 @@ const TYPE_OPTIONS: SelectOption[] = [
{ label: 'Deluge', value: DownloadClientTypeName.Deluge },
{ label: 'Transmission', value: DownloadClientTypeName.Transmission },
{ label: 'uTorrent', value: DownloadClientTypeName.uTorrent },
{ label: 'rTorrent', value: DownloadClientTypeName.rTorrent },
];
@Component({
@@ -72,7 +73,19 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
));
readonly showUsernameField = computed(() => {
return this.modalTypeName() !== DownloadClientTypeName.Deluge;
const type = this.modalTypeName();
return type !== DownloadClientTypeName.Deluge && type !== DownloadClientTypeName.rTorrent;
});
readonly showPasswordField = computed(() => {
return this.modalTypeName() !== DownloadClientTypeName.rTorrent;
});
readonly urlBaseHint = computed(() => {
if (this.modalTypeName() === DownloadClientTypeName.rTorrent) {
return 'Path to the XMLRPC endpoint. Usually RPC2 for rTorrent or plugins/httprpc/action.php for ruTorrent.';
}
return 'Optional URL base path, leave blank for default';
});
onClientTypeChange(value: unknown): void {
@@ -83,6 +96,11 @@ export class DownloadClientsComponent implements OnInit, HasPendingChanges {
if (value === DownloadClientTypeName.Transmission) {
this.modalUrlBase.set('transmission');
}
if (value === DownloadClientTypeName.rTorrent) {
this.modalUrlBase.set('plugins/httprpc/action.php');
this.modalUsername.set('');
this.modalPassword.set('');
}
}
ngOnInit(): void {

View File

@@ -8,6 +8,7 @@ export enum DownloadClientTypeName {
Deluge = 'Deluge',
Transmission = 'Transmission',
uTorrent = 'uTorrent',
rTorrent = 'rTorrent',
}
export enum NotificationProviderType {

View File

@@ -11,7 +11,7 @@ import {
# Download Client
Configure download client connections for torrents. Cleanuparr supports qBittorrent, Deluge, Transmission and µTorrent download clients.
Configure download client connections for torrents. Cleanuparr supports qBittorrent, Deluge, Transmission, µTorrent and rTorrent download clients.
<ElementNavigator />
@@ -76,6 +76,7 @@ The complete URL to access your download client's web interface.
- `https://seedbox.example.com:8080` (remote qBittorrent with SSL)
- `http://192.168.1.100:8112` (local network Deluge)
- `http://transmission.lan:9091` (local Transmission)
- `http://localhost:8000/RPC2` (rTorrent via XMLRPC)
</ConfigSection>