From 66bd4560a84101e1fe7740898298720b799be66c Mon Sep 17 00:00:00 2001 From: Roger Far Date: Sat, 14 Mar 2026 13:41:30 -0600 Subject: [PATCH] Run formatters. --- .../Models/QBittorrent/Tracker.cs | 4 +- .../Helpers/RateLimitHandlerTest.cs | 5 +- .../TorrentClients/TorBoxDebridClientTest.cs | 204 ++++++--- server/RdtClient.Service/DiConfig.cs | 5 +- .../Helpers/RateLimitCoordinator.cs | 10 + .../Helpers/RateLimitHandler.cs | 3 + .../DebridClients/TorBoxDebridClient.cs | 419 +++++++++--------- .../Services/TorrentRunner.cs | 17 +- .../Controllers/TorrentsControllerNzbTest.cs | 6 +- .../Controllers/QBittorrentController.cs | 7 +- 10 files changed, 399 insertions(+), 281 deletions(-) diff --git a/server/RdtClient.Data/Models/QBittorrent/Tracker.cs b/server/RdtClient.Data/Models/QBittorrent/Tracker.cs index 6317864..984c837 100644 --- a/server/RdtClient.Data/Models/QBittorrent/Tracker.cs +++ b/server/RdtClient.Data/Models/QBittorrent/Tracker.cs @@ -12,7 +12,7 @@ public class Tracker [JsonPropertyName("num_peers")] public required Int64 NumPeers { get; set; } - + [JsonPropertyName("msg")] public required String Msg { get; set; } -} \ No newline at end of file +} diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs index cc0ac2d..fa636e4 100644 --- a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs +++ b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs @@ -24,7 +24,7 @@ public class RateLimitHandlerTest var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com")); Assert.Equal(TimeSpan.FromSeconds(3600), ex.RetryAfter); Assert.Contains("rate limit exceeded", ex.Message); - + _coordinatorMock.Verify(m => m.UpdateCooldown("example.com", TimeSpan.FromSeconds(3600)), Times.Once); } @@ -49,10 +49,12 @@ public class RateLimitHandlerTest { // 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 @@ -69,6 +71,7 @@ public class RateLimitHandlerTest { InnerHandler = new MockExceptionHandler(new TimeoutException()) }; + var client = new HttpClient(handler); // Act & Assert diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs index 7c302ef..dc0a832 100644 --- a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs @@ -17,10 +17,10 @@ namespace RdtClient.Service.Test.Services.TorrentClients; public class TorBoxDebridClientTest { - private readonly Mock _fileFilterMock; - private readonly Mock> _loggerMock; - private readonly Mock _httpClientFactoryMock; private readonly Mock _coordinatorMock; + private readonly Mock _fileFilterMock; + private readonly Mock _httpClientFactoryMock; + private readonly Mock> _loggerMock; public TorBoxDebridClientTest() { @@ -28,7 +28,7 @@ public class TorBoxDebridClientTest _httpClientFactoryMock = new(); _fileFilterMock = new(); _coordinatorMock = new(); - + var httpClient = new HttpClient(); _httpClientFactoryMock.Setup(m => m.CreateClient(It.IsAny())).Returns(httpClient); @@ -263,7 +263,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + torrentsApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) .ReturnsAsync(new Response { @@ -296,7 +296,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + usenetApiMock.Setup(m => m.RequestDownloadAsync(123, 456, false, It.IsAny())) .ReturnsAsync(new Response { @@ -322,16 +322,25 @@ public class TorBoxDebridClientTest var name = "test-nzb"; var usenetApiMock = new Mock(); - - var clientMock = new Mock(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object, _coordinatorMock.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", 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() + { + Hash = "new-hash" + } + }); // Act var result = await clientMock.Object.AddNzbFile(bytes, name); @@ -373,7 +382,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) .ReturnsAsync(new TorrentInfoResult { @@ -424,7 +433,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + torrentsApiMock.Setup(m => m.GetHashInfoAsync("test-hash", true, It.IsAny())) .ReturnsAsync(new TorrentInfoResult { @@ -460,7 +469,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 6789, false, It.IsAny())) .ReturnsAsync(new Response { @@ -492,7 +501,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + torrentsApiMock.Setup(m => m.RequestDownloadAsync(12345, 0, true, It.IsAny())) .ReturnsAsync(new Response { @@ -534,7 +543,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + usenetApiMock.Setup(m => m.GetCurrentAsync(true, It.IsAny())) .ReturnsAsync(new List { @@ -575,7 +584,7 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); clientMock.Protected().Setup("GetClient", ItExpr.IsAny()).Returns(torBoxClientMock.Object); - + usenetApiMock.Setup(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny())) .ReturnsAsync(new Response { @@ -597,22 +606,33 @@ 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, _coordinatorMock.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", 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() + { + Settings = new() + { + 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); @@ -626,18 +646,29 @@ 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, _coordinatorMock.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", 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() + { + Settings = new() + { + SeedTorrents = 5 + } + } + }); torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new("slow_down")); @@ -653,18 +684,29 @@ 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, _coordinatorMock.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", 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() + { + Settings = new() + { + 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))); @@ -681,18 +723,29 @@ 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, _coordinatorMock.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", 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() + { + Settings = new() + { + 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)))); @@ -706,20 +759,36 @@ 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, _coordinatorMock.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", 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() + { + Settings = new() + { + SeedTorrents = 5 + } + } + }); torrentsApiMock.Setup(m => m.AddFileAsync(bytes, 5, It.IsAny(), It.IsAny(), false, It.IsAny())) .ThrowsAsync(new TorBoxException("active_limit", "Active limit reached")); @@ -736,12 +805,17 @@ 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, _coordinatorMock.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", 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")); @@ -755,15 +829,24 @@ 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, _coordinatorMock.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", 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")); @@ -782,6 +865,7 @@ 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)", diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index cfbaccd..6c1f0fe 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -66,6 +66,7 @@ public static class DiConfig public static void RegisterHttpClients(this IServiceCollection services) { services.AddHttpClient(); + services.ConfigureHttpClientDefaults(builder => { builder.ConfigureHttpClient(httpClient => @@ -75,7 +76,7 @@ public static class DiConfig }); services.AddTransient(); - + services.AddHttpClient(RD_CLIENT) .AddHttpMessageHandler() .AddResilienceHandler("rd_client_handler", ConfigureResiliencePipeline); @@ -84,7 +85,7 @@ public static class DiConfig // to this HTTP client for added resilience. services.AddHttpClient(TORBOX_CLIENT) .AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline); - + services.AddHttpClient(TORBOX_CLIENT_SLOW) .AddHttpMessageHandler() .AddResilienceHandler("torbox_client_handler_slow", ConfigureResiliencePipeline); diff --git a/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs index 7ea002a..c0911d5 100644 --- a/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs +++ b/server/RdtClient.Service/Helpers/RateLimitCoordinator.cs @@ -11,12 +11,15 @@ public class RateLimitCoordinator : IRateLimitCoordinator 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; } @@ -24,9 +27,11 @@ public class RateLimitCoordinator : IRateLimitCoordinator { 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) @@ -39,6 +44,7 @@ public class RateLimitCoordinator : IRateLimitCoordinator _cooldowns.TryRemove(key, out _); } } + return max; } @@ -46,6 +52,7 @@ public class RateLimitCoordinator : IRateLimitCoordinator { var now = DateTimeOffset.UtcNow; DateTimeOffset? max = null; + foreach (var (key, nextAllowedAt) in _cooldowns) { if (nextAllowedAt > now) @@ -60,6 +67,7 @@ public class RateLimitCoordinator : IRateLimitCoordinator _cooldowns.TryRemove(key, out _); } } + return max; } @@ -77,8 +85,10 @@ public class RateLimitCoordinator : IRateLimitCoordinator { 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 ce3511f..3f9294a 100644 --- a/server/RdtClient.Service/Helpers/RateLimitHandler.cs +++ b/server/RdtClient.Service/Helpers/RateLimitHandler.cs @@ -26,12 +26,14 @@ public class RateLimitHandler(IRateLimitCoordinator coordinator) : DelegatingHan var host = request.RequestUri?.Host ?? "unknown"; var cooldown = coordinator.GetRemainingCooldown(host); + if (cooldown > TimeSpan.Zero) { throw new RateLimitException($"Rate limit cooldown active for {host}", cooldown); } var context = request.GetResilienceContext(); + if (context == null) { context = ResilienceContextPool.Shared.Get(cancellationToken); @@ -50,6 +52,7 @@ public class RateLimitHandler(IRateLimitCoordinator coordinator) : DelegatingHan var delay = GetRetryAfterDelay(response); coordinator.UpdateCooldown(host, delay); response.Dispose(); + throw new RateLimitException($"Provider {host} rate limit exceeded", delay); } diff --git a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs index f97d804..7a096a2 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.DebridClient; using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using TorBoxNET; @@ -11,149 +11,13 @@ using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Service.Services.DebridClients; -public class TorBoxDebridClient(ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, IRateLimitCoordinator coordinator) : 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(); @@ -200,74 +64,13 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF }; } - private async Task HandleErrors(Func> action) - { - try - { - return await action(); - } - catch (RateLimitException) - { - throw; - } - catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) - { - throw rateLimitException; - } - catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) - { - coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) - { - coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - } - - private async Task HandleErrors(Func action) - { - try - { - await action(); - } - catch (RateLimitException) - { - throw; - } - catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) - { - throw rateLimitException; - } - catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) - { - coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) - { - coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); - throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); - } - } - - private async Task HandleAddTorrentErrors(Func> action) - { - return await HandleErrors(() => action(false)); - } - - private async Task HandleAddUsenetErrors(Func> action) - { - return await HandleErrors(() => action(false)); - } - public async Task AddTorrentMagnet(String magnetLink) { return await HandleAddTorrentErrors(async asQueued => { var user = await GetClient().User.GetAsync(true); var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); + return result.Data!.Hash!; }); } @@ -278,6 +81,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF { var user = await GetClient().User.GetAsync(true); var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); + return result.Data!.Hash!; }); } @@ -287,6 +91,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return await HandleAddUsenetErrors(async asQueued => { var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddLinkAsync(nzbLink, as_queued: asQueued); + return result.Data!.Hash!; }); } @@ -296,6 +101,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return await HandleAddUsenetErrors(async asQueued => { var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued); + return result.Data!.Hash!; }); } @@ -510,7 +316,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF } else { - var torrentId = await HandleErrors(() => GetClient().Torrents.GetHashInfoAsync(torrent.Hash, skipCache: true)); + var torrentId = await HandleErrors(() => GetClient().Torrents.GetHashInfoAsync(torrent.Hash, true)); id = torrentId?.Id; } @@ -554,6 +360,210 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF return Task.FromResult(download.FileName); } + 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); + 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, true)); + } + + protected virtual async Task>> GetUsenetAvailability(String hash) + { + return await HandleErrors(() => GetClient().Usenet.GetAvailabilityAsync(hash, 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 + }; + } + + private async Task HandleErrors(Func> action) + { + try + { + return await action(); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task HandleErrors(Func action) + { + try + { + await action(); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when ("active_limit".Equals(ex.Error, StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); + + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task HandleAddTorrentErrors(Func> action) + { + return await HandleErrors(() => action(false)); + } + + private async Task HandleAddUsenetErrors(Func> action) + { + return await HandleErrors(() => action(false)); + } private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) { @@ -571,7 +581,8 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF { if (type == DownloadType.Nzb) { - var usenet = await GetClient().Usenet.GetHashInfoAsync(id, skipCache: true); + var usenet = await GetClient().Usenet.GetHashInfoAsync(id, true); + if (usenet != null) { return Map(usenet); @@ -579,7 +590,7 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF } else { - var result = await GetClient().Torrents.GetHashInfoAsync(id, skipCache: true); + var result = await GetClient().Torrents.GetHashInfoAsync(id, true); if (result != null) { diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 8be94f2..e3ede44 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -11,12 +11,19 @@ using RdtClient.Service.Services.Downloaders; namespace RdtClient.Service.Services; -public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory, IRateLimitCoordinator coordinator) +public class TorrentRunner( + ILogger logger, + Torrents torrents, + Downloads downloads, + RemoteService remoteService, + IHttpClientFactory httpClientFactory, + IRateLimitCoordinator coordinator) { - private DateTimeOffset? _lastNextAllowedAt; - public static readonly ConcurrentDictionary ActiveDownloadClients = new(); public static readonly ConcurrentDictionary ActiveUnpackClients = new(); + private DateTimeOffset? _lastNextAllowedAt; + + public static Boolean IsPausedForLowDiskSpace { get; set; } public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId) { @@ -33,8 +40,6 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow return (0, 0, 0); } - public static Boolean IsPausedForLowDiskSpace { get; set; } - public async Task Initialize() { Log("Initializing TorrentRunner"); @@ -116,6 +121,7 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow sw.Start(); var currentNextAllowedAt = coordinator.GetMaxNextAllowedAt(); + if (currentNextAllowedAt != _lastNextAllowedAt) { if (currentNextAllowedAt == null || currentNextAllowedAt <= DateTimeOffset.UtcNow) @@ -370,6 +376,7 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow if (torrentsToAddToProvider.Count != 0) { var nextAllowedAt = coordinator.GetMaxNextAllowedAt(); + if (nextAllowedAt > DateTimeOffset.UtcNow) { logger.LogDebug($"Dequeuing torrents is paused until {nextAllowedAt}, {nextAllowedAt - DateTimeOffset.Now} remaining"); diff --git a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs index 8c059a2..17adbdf 100644 --- a/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs +++ b/server/RdtClient.Web.Test/Controllers/TorrentsControllerNzbTest.cs @@ -10,10 +10,10 @@ namespace RdtClient.Web.Test.Controllers; public class TorrentsControllerNzbTest { - private readonly Mock _torrentsMock; - private readonly Mock> _loggerMock; - private readonly Mock _coordinatorMock; private readonly TorrentsController _controller; + private readonly Mock _coordinatorMock; + private readonly Mock> _loggerMock; + private readonly Mock _torrentsMock; public TorrentsControllerNzbTest() { diff --git a/server/RdtClient.Web/Controllers/QBittorrentController.cs b/server/RdtClient.Web/Controllers/QBittorrentController.cs index efb3810..ba1edff 100644 --- a/server/RdtClient.Web/Controllers/QBittorrentController.cs +++ b/server/RdtClient.Web/Controllers/QBittorrentController.cs @@ -166,10 +166,9 @@ public class QBittorrentController(ILogger logger, QBitto if (!String.IsNullOrWhiteSpace(request.Hashes)) { - var hashSet = new HashSet( - request.Hashes.Split('|', StringSplitOptions.RemoveEmptyEntries), - StringComparer.OrdinalIgnoreCase - ); + var hashSet = new HashSet(request.Hashes.Split('|', StringSplitOptions.RemoveEmptyEntries), + StringComparer.OrdinalIgnoreCase); + results = results.Where(m => hashSet.Contains(m.Hash)).ToList(); }