diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html
index d441002..7c170d8 100644
--- a/client/src/app/torrent-table/torrent-table.component.html
+++ b/client/src/app/torrent-table/torrent-table.component.html
@@ -17,7 +17,10 @@
Debrid provider rate limit reached
- New torrents will not be added until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }}
+ Processing paused until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }}
+ @if (rateLimitStatus.secondsRemaining > 0) {
+ ({{ rateLimitStatus.secondsRemaining | number: '1.0-0' }} seconds remaining)
+ }
}
diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs
new file mode 100644
index 0000000..8798ec5
--- /dev/null
+++ b/server/RdtClient.Service.Test/Helpers/RateLimitCoordinatorTest.cs
@@ -0,0 +1,68 @@
+using RdtClient.Service.Helpers;
+
+namespace RdtClient.Service.Test.Helpers;
+
+public class RateLimitCoordinatorTest
+{
+ [Fact]
+ public void UpdateCooldown_SetsCooldown()
+ {
+ // Arrange
+ var coordinator = new RateLimitCoordinator();
+ var key = "test.host";
+ var delay = TimeSpan.FromMinutes(5);
+
+ // Act
+ coordinator.UpdateCooldown(key, delay);
+ var remaining = coordinator.GetRemainingCooldown(key);
+
+ // Assert
+ Assert.True(remaining > TimeSpan.FromMinutes(4) && remaining <= TimeSpan.FromMinutes(5));
+ }
+
+ [Fact]
+ public void GetRemainingCooldown_ReturnsZero_WhenNoCooldown()
+ {
+ // Arrange
+ var coordinator = new RateLimitCoordinator();
+ var key = "test.host";
+
+ // Act
+ var remaining = coordinator.GetRemainingCooldown(key);
+
+ // Assert
+ Assert.Equal(TimeSpan.Zero, remaining);
+ }
+
+ [Fact]
+ public void GetMaxNextAllowedAt_ReturnsMax()
+ {
+ // Arrange
+ var coordinator = new RateLimitCoordinator();
+ var now = DateTimeOffset.UtcNow;
+ coordinator.UpdateCooldown("host1", TimeSpan.FromMinutes(10));
+ coordinator.UpdateCooldown("host2", TimeSpan.FromMinutes(20));
+
+ // Act
+ var maxNext = coordinator.GetMaxNextAllowedAt();
+
+ // Assert
+ Assert.NotNull(maxNext);
+ Assert.True(maxNext > now.AddMinutes(19));
+ Assert.True(maxNext < now.AddMinutes(21));
+ }
+
+ [Fact]
+ public void GetMaxNextAllowedAt_ReturnsNull_WhenAllExpired()
+ {
+ // Arrange
+ var coordinator = new RateLimitCoordinator();
+ coordinator.UpdateCooldown("host1", TimeSpan.FromSeconds(-10));
+
+ // Act
+ var maxNext = coordinator.GetMaxNextAllowedAt();
+
+ // Assert
+ Assert.Null(maxNext);
+ }
+}
diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs
index f628fcc..cc0ac2d 100644
--- a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs
+++ b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs
@@ -1,4 +1,5 @@
using System.Net;
+using Moq;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
@@ -6,11 +7,13 @@ namespace RdtClient.Service.Test.Helpers;
public class RateLimitHandlerTest
{
+ private readonly Mock
_coordinatorMock = new();
+
[Fact]
public async Task SendAsync_ThrowsRateLimitException_On429WithRetryAfter()
{
// Arrange
- var handler = new RateLimitHandler
+ var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, 3600)
};
@@ -20,14 +23,16 @@ public class RateLimitHandlerTest
// Act & Assert
var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com"));
Assert.Equal(TimeSpan.FromSeconds(3600), ex.RetryAfter);
- Assert.Equal("TorBox rate limit exceeded", ex.Message);
+ Assert.Contains("rate limit exceeded", ex.Message);
+
+ _coordinatorMock.Verify(m => m.UpdateCooldown("example.com", TimeSpan.FromSeconds(3600)), Times.Once);
}
[Fact]
public async Task SendAsync_ThrowsRateLimitException_On429WithoutRetryAfter()
{
// Arrange
- var handler = new RateLimitHandler
+ var handler = new RateLimitHandler(_coordinatorMock.Object)
{
InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, null)
};
@@ -39,6 +44,37 @@ public class RateLimitHandlerTest
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
+ [Fact]
+ public async Task SendAsync_ThrowsRateLimitException_WhenCooldownActive()
+ {
+ // Arrange
+ _coordinatorMock.Setup(m => m.GetRemainingCooldown("example.com")).Returns(TimeSpan.FromMinutes(5));
+ var handler = new RateLimitHandler(_coordinatorMock.Object)
+ {
+ InnerHandler = new MockHttpMessageHandler(HttpStatusCode.OK, null)
+ };
+ var client = new HttpClient(handler);
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com"));
+ Assert.Equal(TimeSpan.FromMinutes(5), ex.RetryAfter);
+ Assert.Contains("cooldown active", ex.Message);
+ }
+
+ [Fact]
+ public async Task SendAsync_DoesNotCatchTimeoutException()
+ {
+ // Arrange
+ var handler = new RateLimitHandler(_coordinatorMock.Object)
+ {
+ InnerHandler = new MockExceptionHandler(new TimeoutException())
+ };
+ var client = new HttpClient(handler);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => client.GetAsync("http://example.com"));
+ }
+
private class MockHttpMessageHandler(HttpStatusCode statusCode, Int32? retryAfterSeconds) : HttpMessageHandler
{
protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
@@ -53,4 +89,12 @@ public class RateLimitHandlerTest
return Task.FromResult(response);
}
}
+
+ private class MockExceptionHandler(Exception ex) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ throw ex;
+ }
+ }
}
diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs
index 71d1574..96035a7 100644
--- a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs
+++ b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs
@@ -6,6 +6,7 @@ using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
+using RdtClient.Service.Helpers;
using RdtClient.Service.Services;
using RdtClient.Service.Services.DebridClients;
using TorBoxNET;
@@ -17,15 +18,17 @@ namespace RdtClient.Service.Test.Services.TorrentClients;
public class TorBoxDebridClientTest
{
private readonly Mock _fileFilterMock;
- private readonly Mock _httpClientFactoryMock;
private readonly Mock> _loggerMock;
+ private readonly Mock _httpClientFactoryMock;
+ private readonly Mock _coordinatorMock;
public TorBoxDebridClientTest()
{
_loggerMock = new();
_httpClientFactoryMock = new();
_fileFilterMock = new();
-
+ _coordinatorMock = new();
+
var httpClient = new HttpClient();
_httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny())).Returns(httpClient);
@@ -61,7 +64,7 @@ public class TorBoxDebridClientTest
}
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
clientMock.Protected().Setup?>>("GetCurrentTorrents").ReturnsAsync(torrents);
clientMock.Protected().Setup?>>("GetQueuedTorrents").ReturnsAsync(new List());
clientMock.Protected().Setup?>>("GetCurrentUsenet").ReturnsAsync(nzbs);
@@ -111,7 +114,7 @@ public class TorBoxDebridClientTest
}
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(availability);
// Act
@@ -154,7 +157,7 @@ public class TorBoxDebridClientTest
}
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability);
@@ -183,7 +186,7 @@ public class TorBoxDebridClientTest
Data = new()
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
clientMock.Protected().Setup>>>("GetTorrentAvailability", hash).ReturnsAsync(torrentAvailability);
clientMock.Protected().Setup>>>("GetUsenetAvailability", hash).ReturnsAsync(usenetAvailability);
@@ -205,11 +208,11 @@ public class TorBoxDebridClientTest
};
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
// Act
await clientMock.Object.Delete(torrent);
@@ -229,11 +232,11 @@ public class TorBoxDebridClientTest
};
var usenetApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
// Act
await clientMock.Object.Delete(torrent);
@@ -255,12 +258,12 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/d/123/456";
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny()))
.ReturnsAsync(new Response
{
@@ -288,12 +291,12 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/d/123/456";
var usenetApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny()))
.ReturnsAsync(new Response
{
@@ -319,25 +322,15 @@ public class TorBoxDebridClientTest
var name = "test-nzb";
var usenetApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), It.IsAny(), It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Hash = "new-hash"
- }
- });
+ .ReturnsAsync(new Response { Data = new UsenetAddResult { Hash = "new-hash" } });
// Act
var result = await clientMock.Object.AddNzbFile(bytes, name);
@@ -374,12 +367,12 @@ public class TorBoxDebridClientTest
};
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny()))
.ReturnsAsync(new TorrentInfoResult
{
@@ -425,12 +418,12 @@ public class TorBoxDebridClientTest
Settings.Get.Provider.PreferZippedDownloads = true;
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny()))
.ReturnsAsync(new TorrentInfoResult
{
@@ -461,12 +454,12 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/12345/6789";
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny()))
.ReturnsAsync(new Response
{
@@ -493,12 +486,12 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/12345/zip";
var torrentsApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny()))
.ReturnsAsync(new Response
{
@@ -535,12 +528,12 @@ public class TorBoxDebridClientTest
};
var usenetApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny()))
.ReturnsAsync(new List
{
@@ -576,12 +569,12 @@ public class TorBoxDebridClientTest
var link = "https://torbox.app/fakedl/98765/4321";
var usenetApiMock = new Mock();
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
var torBoxClientMock = new Mock();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny()))
.ReturnsAsync(new Response
{
@@ -603,33 +596,20 @@ public class TorBoxDebridClientTest
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock();
var userApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
userApiMock.Setup(m => m.GetAsync(true, It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Settings = new()
- {
- SeedTorrents = 5
- }
- }
- });
+ .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
-
+
// Act & Assert
await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink));
torrentsApiMock.Verify(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()), Times.Once);
@@ -643,32 +623,19 @@ public class TorBoxDebridClientTest
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock();
var userApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
userApiMock.Setup(m => m.GetAsync(true, It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Settings = new()
- {
- SeedTorrents = 5
- }
- }
- });
+ .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()))
- .ThrowsAsync(new("slow_down"));
+ .ThrowsAsync(new Exception("slow_down"));
// Act & Assert
await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink));
@@ -681,29 +648,16 @@ public class TorBoxDebridClientTest
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock();
var userApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
userApiMock.Setup(m => m.GetAsync(true, It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Settings = new()
- {
- SeedTorrents = 5
- }
- }
- });
+ .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()))
.ThrowsAsync(new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60)));
@@ -720,32 +674,19 @@ public class TorBoxDebridClientTest
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock();
var userApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
userApiMock.Setup(m => m.GetAsync(true, It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Settings = new()
- {
- SeedTorrents = 5
- }
- }
- });
+ .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny()))
- .ThrowsAsync(new("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60))));
+ .ThrowsAsync(new Exception("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60))));
// Act & Assert
var ex = await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink));
@@ -756,36 +697,18 @@ public class TorBoxDebridClientTest
public async Task AddTorrentFile_ThrowsRateLimitException_OnActiveLimit()
{
// Arrange
- var bytes = new Byte[]
- {
- 1, 2, 3
- };
-
+ var bytes = new Byte[] { 1, 2, 3 };
var torrentsApiMock = new Mock();
var userApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
userApiMock.Setup(m => m.GetAsync(true, It.IsAny()))
- .ReturnsAsync(new Response
- {
- Data = new()
- {
- Settings = new()
- {
- SeedTorrents = 5
- }
- }
- });
+ .ReturnsAsync(new Response { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddFileAsync(bytes, 5, It.IsAny(), It.IsAny(), false, It.IsAny()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
@@ -802,17 +725,12 @@ public class TorBoxDebridClientTest
// Arrange
var nzbLink = "https://example.com/test.nzb";
var usenetApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.AddLinkAsync(nzbLink, It.IsAny(), It.IsAny(), It.IsAny(), false, It.IsAny()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
@@ -826,24 +744,15 @@ public class TorBoxDebridClientTest
public async Task AddNzbFile_ThrowsRateLimitException_OnActiveLimit()
{
// Arrange
- var bytes = new Byte[]
- {
- 1, 2, 3
- };
-
+ var bytes = new Byte[] { 1, 2, 3 };
var name = "test.nzb";
var usenetApiMock = new Mock();
-
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object)
- {
- CallBase = true
- };
-
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object) { CallBase = true };
var torBoxClientMock = new Mock();
-
+
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
- clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object);
-
+ clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object);
+
usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), false, It.IsAny()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
@@ -862,14 +771,13 @@ public class TorBoxDebridClientTest
RdId = "test-rd-id",
RdStatus = TorrentStatus.Downloading
};
-
var torrentClientTorrent = new DebridClientTorrent
{
Status = "failed (Aborted, cannot be completed - https://sabnzbd.org/not-complete)",
Filename = "test-file"
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
// Act
var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
@@ -895,7 +803,7 @@ public class TorBoxDebridClientTest
Filename = "test-torrent"
};
- var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
+ var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.Object);
// Act
await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs
index 29d5a55..b23fcae 100644
--- a/server/RdtClient.Service/DiConfig.cs
+++ b/server/RdtClient.Service/DiConfig.cs
@@ -6,7 +6,6 @@ using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Timeout;
using RateLimitHeaders.Polly;
-using RdtClient.Data.Models.Internal;
using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Helpers;
using RdtClient.Service.Middleware;
@@ -20,6 +19,7 @@ public static class DiConfig
{
public const String RD_CLIENT = "RdClient";
public const String TORBOX_CLIENT = "TorBoxClient";
+ public const String TORBOX_CLIENT_SLOW = "TorBoxClientSlow";
public static readonly String UserAgent = $"rdt-client {Assembly.GetEntryAssembly()?.GetName().Version}";
public static void RegisterRdtServices(this IServiceCollection services)
@@ -29,6 +29,7 @@ public static class DiConfig
services.AddSingleton();
services.AddScoped();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -65,7 +66,6 @@ public static class DiConfig
public static void RegisterHttpClients(this IServiceCollection services)
{
services.AddHttpClient();
-
services.ConfigureHttpClientDefaults(builder =>
{
builder.ConfigureHttpClient(httpClient =>
@@ -75,7 +75,7 @@ public static class DiConfig
});
services.AddTransient();
-
+
services.AddHttpClient(RD_CLIENT)
.AddHttpMessageHandler()
.AddResilienceHandler("rd_client_handler", ConfigureResiliencePipeline);
@@ -83,17 +83,15 @@ public static class DiConfig
// This likely works for most providers, but should be verified and then the providers changed
// to this HTTP client for added resilience.
services.AddHttpClient(TORBOX_CLIENT)
- .AddHttpMessageHandler()
.AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline);
+
+ services.AddHttpClient(TORBOX_CLIENT_SLOW)
+ .AddHttpMessageHandler()
+ .AddResilienceHandler("torbox_client_handler_slow", ConfigureResiliencePipeline);
}
private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder)
{
- builder.AddTimeout(new TimeoutStrategyOptions
- {
- TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
- });
-
builder.AddRateLimitHeaders(options =>
{
options.EnableProactiveThrottling = true;
@@ -116,17 +114,12 @@ public static class DiConfig
{
if (args.Outcome.Result is { StatusCode: HttpStatusCode.TooManyRequests } response)
{
- var retryAfter = response.Headers.RetryAfter;
- var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
+ var delay = RateLimitHandler.GetRetryAfterDelay(response);
+ var timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout);
- if (delay < TimeSpan.Zero)
+ if (delay >= timeout)
{
- delay = TimeSpan.FromMinutes(2);
- }
-
- if (delay >= TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
- {
- throw new RateLimitException("Provider rate limit exceeded", delay);
+ return new ValueTask((TimeSpan?)null);
}
return new(delay);
@@ -135,5 +128,10 @@ public static class DiConfig
return new((TimeSpan?)null);
}
});
+
+ builder.AddTimeout(new TimeoutStrategyOptions
+ {
+ TimeoutGenerator = _ => new(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout))
+ });
}
}
diff --git a/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs b/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs
new file mode 100644
index 0000000..f122da7
--- /dev/null
+++ b/server/RdtClient.Service/Helpers/IRateLimitCoordinator.cs
@@ -0,0 +1,10 @@
+namespace RdtClient.Service.Helpers;
+
+public interface IRateLimitCoordinator
+{
+ TimeSpan GetRemainingCooldown(String key);
+ TimeSpan GetMaxRemainingCooldown();
+ DateTimeOffset? GetMaxNextAllowedAt();
+ void UpdateCooldown(String key, TimeSpan retryAfter);
+ DateTimeOffset? GetNextAllowedAt(String key);
+}
diff --git a/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs
new file mode 100644
index 0000000..7ea002a
--- /dev/null
+++ b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs
@@ -0,0 +1,84 @@
+using System.Collections.Concurrent;
+
+namespace RdtClient.Service.Helpers;
+
+public class RateLimitCoordinator : IRateLimitCoordinator
+{
+ private readonly ConcurrentDictionary _cooldowns = new();
+
+ public TimeSpan GetRemainingCooldown(String key)
+ {
+ if (_cooldowns.TryGetValue(key, out var nextAllowedAt))
+ {
+ var remaining = nextAllowedAt - DateTimeOffset.UtcNow;
+ if (remaining > TimeSpan.Zero)
+ {
+ return remaining;
+ }
+ _cooldowns.TryRemove(key, out _);
+ }
+ return TimeSpan.Zero;
+ }
+
+ public TimeSpan GetMaxRemainingCooldown()
+ {
+ var now = DateTimeOffset.UtcNow;
+ var max = TimeSpan.Zero;
+ foreach (var (key, nextAllowedAt) in _cooldowns)
+ {
+ var remaining = nextAllowedAt - now;
+ if (remaining > TimeSpan.Zero)
+ {
+ if (remaining > max)
+ {
+ max = remaining;
+ }
+ }
+ else
+ {
+ _cooldowns.TryRemove(key, out _);
+ }
+ }
+ return max;
+ }
+
+ public DateTimeOffset? GetMaxNextAllowedAt()
+ {
+ var now = DateTimeOffset.UtcNow;
+ DateTimeOffset? max = null;
+ foreach (var (key, nextAllowedAt) in _cooldowns)
+ {
+ if (nextAllowedAt > now)
+ {
+ if (max == null || nextAllowedAt > max)
+ {
+ max = nextAllowedAt;
+ }
+ }
+ else
+ {
+ _cooldowns.TryRemove(key, out _);
+ }
+ }
+ return max;
+ }
+
+ public void UpdateCooldown(String key, TimeSpan retryAfter)
+ {
+ var nextAllowedAt = DateTimeOffset.UtcNow.Add(retryAfter);
+ _cooldowns.AddOrUpdate(key, nextAllowedAt, (_, existing) => nextAllowedAt > existing ? nextAllowedAt : existing);
+ }
+
+ public DateTimeOffset? GetNextAllowedAt(String key)
+ {
+ if (_cooldowns.TryGetValue(key, out var nextAllowedAt))
+ {
+ if (nextAllowedAt > DateTimeOffset.UtcNow)
+ {
+ return nextAllowedAt;
+ }
+ _cooldowns.TryRemove(key, out _);
+ }
+ return null;
+ }
+}
diff --git a/server/RdtClient.Service/Helpers/RateLimitHandler.cs b/server/RdtClient.Service/Helpers/RateLimitHandler.cs
index 4fd243e..ce3511f 100644
--- a/server/RdtClient.Service/Helpers/RateLimitHandler.cs
+++ b/server/RdtClient.Service/Helpers/RateLimitHandler.cs
@@ -1,37 +1,58 @@
using System.Net;
-using Polly.Timeout;
+using Polly;
using RdtClient.Data.Models.Internal;
namespace RdtClient.Service.Helpers;
-public class RateLimitHandler : DelegatingHandler
+public class RateLimitHandler(IRateLimitCoordinator coordinator) : DelegatingHandler
{
+ public static readonly ResiliencePropertyKey StartTimeKey = new("StartTime");
+
+ public static TimeSpan GetRetryAfterDelay(HttpResponseMessage response)
+ {
+ var retryAfter = response.Headers.RetryAfter;
+ var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
+
+ if (delay < TimeSpan.Zero)
+ {
+ delay = TimeSpan.FromMinutes(2);
+ }
+
+ return delay;
+ }
+
protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
- try
+ var host = request.RequestUri?.Host ?? "unknown";
+
+ var cooldown = coordinator.GetRemainingCooldown(host);
+ if (cooldown > TimeSpan.Zero)
{
- var response = await base.SendAsync(request, cancellationToken);
-
- if (response.StatusCode == HttpStatusCode.TooManyRequests)
- {
- var retryAfter = response.Headers.RetryAfter;
- var delay = retryAfter?.Delta ?? (retryAfter?.Date.HasValue == true ? retryAfter.Date.Value - DateTimeOffset.UtcNow : TimeSpan.FromMinutes(2));
-
- if (delay < TimeSpan.Zero)
- {
- delay = TimeSpan.FromMinutes(2);
- }
-
- response.Dispose();
-
- throw new RateLimitException("TorBox rate limit exceeded", delay);
- }
-
- return response;
+ throw new RateLimitException($"Rate limit cooldown active for {host}", cooldown);
}
- catch (Exception ex) when (ex is TimeoutRejectedException or TaskCanceledException)
+
+ var context = request.GetResilienceContext();
+ if (context == null)
{
- throw new RateLimitException("Provider rate limit exceeded (timeout)", TimeSpan.FromMinutes(2));
+ context = ResilienceContextPool.Shared.Get(cancellationToken);
+ request.SetResilienceContext(context);
}
+
+ if (!context.Properties.TryGetValue(StartTimeKey, out _))
+ {
+ context.Properties.Set(StartTimeKey, DateTimeOffset.UtcNow);
+ }
+
+ var response = await base.SendAsync(request, cancellationToken);
+
+ if (response.StatusCode == HttpStatusCode.TooManyRequests)
+ {
+ var delay = GetRetryAfterDelay(response);
+ coordinator.UpdateCooldown(host, delay);
+ response.Dispose();
+ throw new RateLimitException($"Provider {host} rate limit exceeded", delay);
+ }
+
+ return response;
}
}
diff --git a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs
index 295c29a..f97d804 100644
--- a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs
+++ b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs
@@ -2,8 +2,8 @@ using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using RdtClient.Data.Enums;
-using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient;
+using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers;
using TorBoxNET;
@@ -11,10 +11,149 @@ using Torrent = RdtClient.Data.Models.Data.Torrent;
namespace RdtClient.Service.Services.DebridClients;
-public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
+public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, IRateLimitCoordinator coordinator) : IDebridClient
{
+ private const String TorBoxApiHost = "api.torbox.app";
+
private TimeSpan? _offset;
+ protected virtual ITorBoxNetClient GetClient(String clientId = DiConfig.TORBOX_CLIENT)
+ {
+ try
+ {
+ var apiKey = Settings.Get.Provider.ApiKey;
+
+ if (String.IsNullOrWhiteSpace(apiKey))
+ {
+ throw new("TorBox API Key not set in the settings");
+ }
+
+ var httpClient = httpClientFactory.CreateClient(clientId);
+ var torBoxNetClient = new TorBoxNetClient(null, httpClient, 1);
+ torBoxNetClient.UseApiAuthentication(apiKey);
+
+ // Get the server time to fix up the timezones on results
+ if (_offset == null)
+ {
+ var serverTime = DateTimeOffset.UtcNow;
+ _offset = serverTime.Offset;
+ }
+
+ return torBoxNetClient;
+ }
+ catch (AggregateException ae)
+ {
+ foreach (var inner in ae.InnerExceptions)
+ {
+ logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}");
+ }
+
+ throw;
+ }
+ catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
+ {
+ logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}");
+
+ throw;
+ }
+ catch (TaskCanceledException ex)
+ {
+ logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}");
+
+ throw;
+ }
+ }
+
+ protected virtual async Task?> GetCurrentTorrents()
+ {
+ return await HandleErrors(() => GetClient().Torrents.GetCurrentAsync(true));
+ }
+
+ protected virtual async Task?> GetQueuedTorrents()
+ {
+ return await HandleErrors(() => GetClient().Torrents.GetQueuedAsync(true));
+ }
+
+ protected virtual async Task?> GetCurrentUsenet()
+ {
+ return await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true));
+ }
+
+ protected virtual async Task?> GetQueuedUsenet()
+ {
+ return await HandleErrors(() => GetClient().Usenet.GetQueuedAsync(true));
+ }
+
+ protected virtual async Task>> GetTorrentAvailability(String hash)
+ {
+ return await HandleErrors(() => GetClient().Torrents.GetAvailabilityAsync(hash, listFiles: true));
+ }
+
+ protected virtual async Task>> GetUsenetAvailability(String hash)
+ {
+ return await HandleErrors(() => GetClient().Usenet.GetAvailabilityAsync(hash, listFiles: true));
+ }
+
+ private DebridClientTorrent Map(TorrentInfoResult torrent)
+ {
+ return new()
+ {
+ Id = torrent.Hash,
+ Filename = torrent.Name,
+ OriginalFilename = torrent.Name,
+ Hash = torrent.Hash,
+ Bytes = torrent.Size,
+ OriginalBytes = torrent.Size,
+ Host = torrent.DownloadPresent.ToString(),
+ Split = 0,
+ Progress = (Int64)(torrent.Progress * 100.0),
+ Status = torrent.DownloadState,
+ Type = DownloadType.Torrent,
+ Added = ChangeTimeZone(torrent.CreatedAt)!.Value,
+ Files = (torrent.Files ?? []).Select(m => new DebridClientFile
+ {
+ Path = String.Join("/", m.Name.Split('/').Skip(1)),
+ Bytes = m.Size,
+ Id = m.Id,
+ Selected = true
+ }).ToList(),
+ Links = [],
+ Ended = ChangeTimeZone(torrent.UpdatedAt),
+ Speed = torrent.DownloadSpeed,
+ Seeders = torrent.Seeds,
+ };
+ }
+
+ private DebridClientTorrent Map(UsenetInfoResult usenet)
+ {
+ return new()
+ {
+ Id = usenet.Hash,
+ Filename = usenet.Name,
+ OriginalFilename = usenet.Name,
+ Hash = usenet.Hash,
+ Bytes = usenet.Size,
+ OriginalBytes = usenet.Size,
+ Host = usenet.DownloadPresent.ToString(),
+ Split = 0,
+ Progress = (Int64)(usenet.Progress * 100.0),
+ Status = usenet.DownloadState,
+ Type = DownloadType.Nzb,
+ Added = ChangeTimeZone(usenet.CreatedAt)!.Value,
+ Files = (usenet.Files ?? []).Select(m => new DebridClientFile
+ {
+ Path = String.Join("/", m.Name.Split('/').Skip(1)),
+ Bytes = m.Size,
+ Id = m.Id,
+ Selected = true
+ }).ToList(),
+ Links = [],
+ Ended = ChangeTimeZone(usenet.UpdatedAt),
+ Speed = usenet.DownloadSpeed,
+ Seeders = 0,
+ };
+ }
+
public async Task> GetDownloads()
{
var results = new List();
@@ -52,7 +191,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF
public async Task