From a023d33d908d0bb6d0bb456a37ecf9bd6cad8f61 Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:47:13 -0500 Subject: [PATCH] Add resilience handler, predictive rate limit handling - Applied to TorBox, can be extended easily to other providers - Reads response headers commonplace for pre-emptive rate limit throttling and retry-after - When a rate limit is reached, displays a warning in the UI - Fix socket leak from direct allocation of HttpClient, replaced with factory which handles pooling and re-use of sockets. While HttpClient is Disposable, it doesn't gaurantee (and does not) directly release underlying sockets for queries at the time the client is disposed. These sockets will go into a TCP WAIT state often for a very long time. The expected pattern in C# is to always use the HttClientFactory which will correctly handle re-use of the OS sockets in suqsequent queries reducing resource and memory leaks. --- .../src/app/models/rate-limit-status.model.ts | 4 + .../torrent-table.component.html | 7 + .../torrent-table/torrent-table.component.ts | 12 ++ client/src/app/torrent.service.ts | 10 + server/RdtClient.Data/Data/ITorrentData.cs | 1 + .../Models/Internal/RateLimitException.cs | 6 + .../Models/Internal/RateLimitStatus.cs | 7 + .../Helpers/RateLimitHandlerTest.cs | 52 +++++ .../RdtClient.Service.Test.csproj | 2 +- .../TorrentClients/TorBoxDebridClientTest.cs | 200 +++++++++++++++++- .../Services/TorrentsTest.cs | 2 +- .../BackgroundServices/ProviderUpdater.cs | 14 +- .../BackgroundServices/UpdateChecker.cs | 14 +- server/RdtClient.Service/DiConfig.cs | 71 ++++++- .../Helpers/RateLimitHandler.cs | 35 +++ .../RdtClient.Service.csproj | 3 +- .../DebridClients/AllDebridDebridClient.cs | 49 +++-- .../DebridClients/DebridLinkTorrentClient.cs | 25 ++- .../DebridClients/PremiumizeDebridClient.cs | 49 +++-- .../DebridClients/RealDebridDebridClient.cs | 29 ++- .../DebridClients/TorBoxDebridClient.cs | 106 +++++++--- .../Services/RemoteService.cs | 5 + .../Services/TorrentRunner.cs | 91 +++++--- .../Controllers/QBittorrentController.cs | 4 +- .../Controllers/TorrentsController.cs | 22 ++ 25 files changed, 688 insertions(+), 132 deletions(-) create mode 100644 client/src/app/models/rate-limit-status.model.ts create mode 100644 server/RdtClient.Data/Models/Internal/RateLimitException.cs create mode 100644 server/RdtClient.Data/Models/Internal/RateLimitStatus.cs create mode 100644 server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs create mode 100644 server/RdtClient.Service/Helpers/RateLimitHandler.cs diff --git a/client/src/app/models/rate-limit-status.model.ts b/client/src/app/models/rate-limit-status.model.ts new file mode 100644 index 0000000..c6009dc --- /dev/null +++ b/client/src/app/models/rate-limit-status.model.ts @@ -0,0 +1,4 @@ +export interface RateLimitStatus { + nextDequeueTime: Date | null; + secondsRemaining: number; +} diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html index b4c0e79..d441002 100644 --- a/client/src/app/torrent-table/torrent-table.component.html +++ b/client/src/app/torrent-table/torrent-table.component.html @@ -13,6 +13,13 @@ Last check: {{ diskSpaceStatus.lastCheckTime | date: 'short' }} } +@if (rateLimitStatus?.nextDequeueTime) { +
+ Debrid provider rate limit reached +
+ New torrents will not be added until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }} +
+}
diff --git a/client/src/app/torrent-table/torrent-table.component.ts b/client/src/app/torrent-table/torrent-table.component.ts index da7478f..5e011c1 100644 --- a/client/src/app/torrent-table/torrent-table.component.ts +++ b/client/src/app/torrent-table/torrent-table.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Torrent } from '../models/torrent.model'; import { DiskSpaceStatus } from '../models/disk-space-status.model'; +import { RateLimitStatus } from '../models/rate-limit-status.model'; import { TorrentService } from '../torrent.service'; import { forkJoin, Observable } from 'rxjs'; import { FormsModule } from '@angular/forms'; @@ -50,6 +51,7 @@ export class TorrentTableComponent implements OnInit { public updateSettingsTorrentLifetime: number; public diskSpaceStatus: DiskSpaceStatus | null = null; + public rateLimitStatus: RateLimitStatus | null = null; constructor( private router: Router, @@ -67,6 +69,16 @@ export class TorrentTableComponent implements OnInit { this.diskSpaceStatus = status; }); + this.torrentService.getRateLimitStatus().subscribe({ + next: (status) => { + this.rateLimitStatus = status; + }, + }); + + this.torrentService.rateLimitStatus$.subscribe((status) => { + this.rateLimitStatus = status; + }); + this.torrentService.update$.subscribe((result) => { this.torrents = result; }); diff --git a/client/src/app/torrent.service.ts b/client/src/app/torrent.service.ts index 061d724..d3f3398 100644 --- a/client/src/app/torrent.service.ts +++ b/client/src/app/torrent.service.ts @@ -4,6 +4,7 @@ import * as signalR from '@microsoft/signalr'; import { Observable, Subject } from 'rxjs'; import { Torrent, TorrentFileAvailability } from './models/torrent.model'; import { DiskSpaceStatus } from './models/disk-space-status.model'; +import { RateLimitStatus } from './models/rate-limit-status.model'; import { APP_BASE_HREF } from '@angular/common'; @Injectable({ @@ -12,6 +13,7 @@ import { APP_BASE_HREF } from '@angular/common'; export class TorrentService { public update$: Subject = new Subject(); public diskSpaceStatus$: Subject = new Subject(); + public rateLimitStatus$: Subject = new Subject(); private connection: signalR.HubConnection; @@ -40,6 +42,10 @@ export class TorrentService { this.diskSpaceStatus$.next(status); }); + this.connection.on('rateLimitStatus', (status: any) => { + this.rateLimitStatus$.next(status); + }); + this.connection.onreconnected(() => { this.getDiskSpaceStatus().subscribe({ next: (status) => { @@ -65,6 +71,10 @@ export class TorrentService { return this.http.get(`${this.baseHref}Api/Torrents/DiskSpaceStatus`); } + public getRateLimitStatus(): Observable { + return this.http.get(`${this.baseHref}Api/Torrents/RateLimitStatus`); + } + public uploadMagnet(magnetLink: string, torrent: Torrent): Observable { return this.http.post(`${this.baseHref}Api/Torrents/UploadMagnet`, { magnetLink, diff --git a/server/RdtClient.Data/Data/ITorrentData.cs b/server/RdtClient.Data/Data/ITorrentData.cs index 7b04b88..3ff6071 100644 --- a/server/RdtClient.Data/Data/ITorrentData.cs +++ b/server/RdtClient.Data/Data/ITorrentData.cs @@ -19,6 +19,7 @@ public interface ITorrentData Task UpdateRdData(Torrent torrent); Task UpdateRdId(Torrent torrent, String rdId); + Task UpdateHash(Torrent torrent, String hash); Task Update(Torrent torrent); Task UpdateCategory(Guid torrentId, String? category); Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry); diff --git a/server/RdtClient.Data/Models/Internal/RateLimitException.cs b/server/RdtClient.Data/Models/Internal/RateLimitException.cs new file mode 100644 index 0000000..3564975 --- /dev/null +++ b/server/RdtClient.Data/Models/Internal/RateLimitException.cs @@ -0,0 +1,6 @@ +namespace RdtClient.Data.Models.Internal; + +public class RateLimitException(String message, TimeSpan retryAfter) : Exception(message) +{ + public TimeSpan RetryAfter { get; } = retryAfter; +} diff --git a/server/RdtClient.Data/Models/Internal/RateLimitStatus.cs b/server/RdtClient.Data/Models/Internal/RateLimitStatus.cs new file mode 100644 index 0000000..dd4d7f1 --- /dev/null +++ b/server/RdtClient.Data/Models/Internal/RateLimitStatus.cs @@ -0,0 +1,7 @@ +namespace RdtClient.Data.Models.Internal; + +public class RateLimitStatus +{ + public DateTimeOffset? NextDequeueTime { get; set; } + public Double SecondsRemaining { get; set; } +} diff --git a/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs new file mode 100644 index 0000000..7ed3bda --- /dev/null +++ b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs @@ -0,0 +1,52 @@ +using System.Net; +using RdtClient.Data.Models.Internal; +using RdtClient.Service.Helpers; + +namespace RdtClient.Service.Test.Helpers; + +public class RateLimitHandlerTest +{ + [Fact] + public async Task SendAsync_ThrowsRateLimitException_On429WithRetryAfter() + { + // Arrange + var handler = new RateLimitHandler + { + InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, 3600) + }; + var client = new HttpClient(handler); + + // 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); + } + + [Fact] + public async Task SendAsync_ThrowsRateLimitException_On429WithoutRetryAfter() + { + // Arrange + var handler = new RateLimitHandler + { + InnerHandler = new MockHttpMessageHandler(HttpStatusCode.TooManyRequests, null) + }; + var client = new HttpClient(handler); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => client.GetAsync("http://example.com")); + Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter); + } + + private class MockHttpMessageHandler(HttpStatusCode statusCode, Int32? retryAfterSeconds) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(statusCode); + if (retryAfterSeconds.HasValue) + { + response.Headers.RetryAfter = new System.Net.Http.Headers.RetryConditionHeaderValue(TimeSpan.FromSeconds(retryAfterSeconds.Value)); + } + return Task.FromResult(response); + } + } +} diff --git a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj index a105272..181fff8 100644 --- a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj +++ b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj @@ -20,7 +20,7 @@ - + all diff --git a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs index f303bb5..f8fcb19 100644 --- a/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentClients/TorBoxDebridClientTest.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Services; using RdtClient.Service.Services.DebridClients; using TorBoxNET; @@ -269,16 +270,15 @@ public class TorBoxDebridClientTest torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); clientMock.Protected().Setup("GetClient").Returns(torBoxClientMock.Object); - usenetApiMock.Setup(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny())) - .ReturnsAsync(new Response { Data = new() - { Hash = "new-hash" } }); + usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Response { Data = new UsenetAddResult { Hash = "new-hash" } }); // Act var result = await clientMock.Object.AddNzbFile(bytes, name); // Assert Assert.Equal("new-hash", result); - usenetApiMock.Verify(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny()), Times.Once); + usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } [Fact] public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForIndividualFiles() @@ -479,6 +479,198 @@ public class TorBoxDebridClientTest Assert.Equal("https://real-usenet-link", result); usenetApiMock.Verify(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny()), Times.Once); } + [Fact] + public async Task AddTorrentMagnet_ThrowsRateLimitException_OnActiveLimit() + { + // Arrange + 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 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); + + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) + .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); + torrentsApiMock.Verify(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny(), It.IsAny(), true, It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTorrentMagnet_ThrowsRateLimitException_OnSlowDown() + { + // Arrange + 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 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); + + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) + .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 Exception("slow_down")); + + // Act & Assert + await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); + } + + [Fact] + public async Task AddTorrentMagnet_ThrowsRateLimitException_OnRateLimitException() + { + // Arrange + 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 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); + + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) + .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))); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); + Assert.Equal(TimeSpan.FromMinutes(60), ex.RetryAfter); + } + + [Fact] + public async Task AddTorrentMagnet_ThrowsRateLimitException_OnWrappedRateLimitException() + { + // Arrange + 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 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); + + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) + .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 Exception("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60)))); + + // Act & Assert + var ex = await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentMagnet(magnetLink)); + Assert.Equal(TimeSpan.FromMinutes(60), ex.RetryAfter); + } + + [Fact] + public async Task AddTorrentFile_ThrowsRateLimitException_OnActiveLimit() + { + // Arrange + 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 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); + + userApiMock.Setup(m => m.GetAsync(true, It.IsAny())) + .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")); + + // Act & Assert + await Assert.ThrowsAsync(() => clientMock.Object.AddTorrentFile(bytes)); + torrentsApiMock.Verify(m => m.AddFileAsync(bytes, 5, It.IsAny(), It.IsAny(), false, It.IsAny()), Times.Once); + torrentsApiMock.Verify(m => m.AddFileAsync(bytes, 5, It.IsAny(), It.IsAny(), true, It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddNzbLink_ThrowsRateLimitException_OnActiveLimit() + { + // 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 torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").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")); + + // Act & Assert + await Assert.ThrowsAsync(() => clientMock.Object.AddNzbLink(nzbLink)); + usenetApiMock.Verify(m => m.AddLinkAsync(nzbLink, It.IsAny(), It.IsAny(), It.IsAny(), false, It.IsAny()), Times.Once); + usenetApiMock.Verify(m => m.AddLinkAsync(nzbLink, It.IsAny(), It.IsAny(), It.IsAny(), true, It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddNzbFile_ThrowsRateLimitException_OnActiveLimit() + { + // Arrange + 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 torBoxClientMock = new Mock(); + + torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); + clientMock.Protected().Setup("GetClient").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")); + + // Act & Assert + await Assert.ThrowsAsync(() => clientMock.Object.AddNzbFile(bytes, name)); + usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), false, It.IsAny()), Times.Once); + usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny(), name, It.IsAny(), true, It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateData_SetsErrorStatus_WhenTorBoxStatusStartsWithFailed() + { + // Arrange + var torrent = new Torrent + { + 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); + + // Act + var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent); + + // Assert + Assert.Equal(TorrentStatus.Error, result.RdStatus); + } [Fact] public async Task UpdateData_LogsWarning_WhenTorBoxStatusIsUnmapped() diff --git a/server/RdtClient.Service.Test/Services/TorrentsTest.cs b/server/RdtClient.Service.Test/Services/TorrentsTest.cs index 1b2b5ab..d856866 100644 --- a/server/RdtClient.Service.Test/Services/TorrentsTest.cs +++ b/server/RdtClient.Service.Test/Services/TorrentsTest.cs @@ -14,7 +14,7 @@ using TorrentsService = RdtClient.Service.Services.Torrents; namespace RdtClient.Service.Test.Services; -internal class Mocks +class Mocks { public readonly Mock DownloadsMock; public readonly Mock EnricherMock; diff --git a/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs b/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs index f32b8cc..8ab529d 100644 --- a/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs +++ b/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using RdtClient.Data.Enums; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Services; namespace RdtClient.Service.BackgroundServices; @@ -19,7 +20,7 @@ public class ProviderUpdater(ILogger logger, IServiceProvider s using var scope = serviceProvider.CreateScope(); var torrentService = scope.ServiceProvider.GetRequiredService(); - + var torrentRunner = scope.ServiceProvider.GetRequiredService(); logger.LogInformation("ProviderUpdater started."); while (!stoppingToken.IsCancellationRequested) @@ -28,7 +29,7 @@ public class ProviderUpdater(ILogger logger, IServiceProvider s { var torrents = await torrentService.Get(); - if (_nextUpdate < DateTime.UtcNow && (Settings.Get.Provider.AutoImport || torrents.Any(t => t.RdStatus != TorrentStatus.Finished))) + if (_nextUpdate < DateTime.UtcNow && (Settings.Get.Provider.AutoImport || torrents.Any(t => t.RdStatus != TorrentStatus.Finished) || RdtHub.HasConnections)) { logger.LogDebug($"Updating torrent info from debrid provider"); @@ -43,9 +44,9 @@ public class ProviderUpdater(ILogger logger, IServiceProvider s { updateTime = Settings.Get.Provider.CheckInterval; - if (updateTime < 5) + if (updateTime < 1) { - updateTime = 5; + updateTime = 1; } } @@ -56,6 +57,11 @@ public class ProviderUpdater(ILogger logger, IServiceProvider s logger.LogDebug("Finished updating torrent info from debrid provider, next update in {updateTime} seconds", updateTime); } } + catch (RateLimitException ex) + { + await torrentRunner.SetRateLimit(ex.RetryAfter, ex.Message); + _nextUpdate = DateTime.UtcNow.Add(ex.RetryAfter); + } catch (Exception ex) { logger.LogError(ex, "Unexpected error occurred in ProviderUpdater: {ex.Message}", ex.Message); diff --git a/server/RdtClient.Service/BackgroundServices/UpdateChecker.cs b/server/RdtClient.Service/BackgroundServices/UpdateChecker.cs index 3ae9bfb..f76f9c7 100644 --- a/server/RdtClient.Service/BackgroundServices/UpdateChecker.cs +++ b/server/RdtClient.Service/BackgroundServices/UpdateChecker.cs @@ -5,7 +5,7 @@ using Newtonsoft.Json; namespace RdtClient.Service.BackgroundServices; -public class UpdateChecker(ILogger logger) : BackgroundService +public class UpdateChecker(ILogger logger, IHttpClientFactory httpClientFactory) : BackgroundService { private static readonly List KnownGhsaIds = []; public static String? CurrentVersion { get; private set; } @@ -79,13 +79,13 @@ public class UpdateChecker(ILogger logger) : BackgroundService logger.LogInformation("UpdateChecker stopped."); } - private static async Task GitHubRequest(String endpoint, CancellationToken cancellationToken) + private async Task GitHubRequest(String endpoint, CancellationToken cancellationToken) { - var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion)); - var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken); - - return JsonConvert.DeserializeObject(response); + var httpClient = httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion)); + var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken); + + return JsonConvert.DeserializeObject(response); } } diff --git a/server/RdtClient.Service/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index a22a266..549d99d 100644 --- a/server/RdtClient.Service/DiConfig.cs +++ b/server/RdtClient.Service/DiConfig.cs @@ -4,8 +4,11 @@ using System.Reflection; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.DependencyInjection; using Polly; -using Polly.Extensions.Http; +using Polly.Timeout; +using RateLimitHeaders.Polly; +using RdtClient.Data.Models.Internal; using RdtClient.Service.BackgroundServices; +using RdtClient.Service.Helpers; using RdtClient.Service.Middleware; using RdtClient.Service.Services; using RdtClient.Service.Services.DebridClients; @@ -16,6 +19,7 @@ namespace RdtClient.Service; public static class DiConfig { public const String RD_CLIENT = "RdClient"; + public const String TORBOX_CLIENT = "TorBoxClient"; public static readonly String UserAgent = $"rdt-client {Assembly.GetEntryAssembly()?.GetName().Version}"; public static void RegisterRdtServices(this IServiceCollection services) @@ -60,13 +64,7 @@ public static class DiConfig public static void RegisterHttpClients(this IServiceCollection services) { - var retryPolicy = HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests) - .WaitAndRetryAsync(5, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))); - services.AddHttpClient(); - services.ConfigureHttpClientDefaults(builder => { builder.ConfigureHttpClient(httpClient => @@ -75,7 +73,64 @@ public static class DiConfig }); }); + services.AddTransient(); + services.AddHttpClient(RD_CLIENT) - .AddPolicyHandler(retryPolicy); + .AddHttpMessageHandler() + .AddResilienceHandler("rd_client_handler", ConfigureResiliencePipeline); + + // 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); + } + + private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder) + { + builder.AddTimeout(new TimeoutStrategyOptions + { + TimeoutGenerator = _ => new ValueTask(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) + }); + builder.AddRateLimitHeaders(options => + { + options.EnableProactiveThrottling = true; + }); + builder.AddRetry(new() + { + ShouldHandle = args => args.Outcome switch + { + { Exception: HttpRequestException } => PredicateResult.True(), + { Result.StatusCode: HttpStatusCode.RequestTimeout } => PredicateResult.True(), + { Result.StatusCode: HttpStatusCode.TooManyRequests } => PredicateResult.True(), + _ => PredicateResult.False() + }, + MaxRetryAttempts = 2, + BackoffType = DelayBackoffType.Exponential, + Delay = TimeSpan.FromSeconds(2), + UseJitter = true, + DelayGenerator = args => + { + 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)); + + if (delay < TimeSpan.Zero) + { + delay = TimeSpan.FromMinutes(2); + } + + if (delay >= TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) + { + throw new RateLimitException("TorBox rate limit exceeded", delay); + } + + return new ValueTask(delay); + } + + return new ValueTask((TimeSpan?)null); + } + }); } } diff --git a/server/RdtClient.Service/Helpers/RateLimitHandler.cs b/server/RdtClient.Service/Helpers/RateLimitHandler.cs new file mode 100644 index 0000000..6ddf292 --- /dev/null +++ b/server/RdtClient.Service/Helpers/RateLimitHandler.cs @@ -0,0 +1,35 @@ +using System.Net; +using Polly.Timeout; +using RdtClient.Data.Models.Internal; + +namespace RdtClient.Service.Helpers; + +public class RateLimitHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + try + { + 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); + } + + throw new RateLimitException("TorBox rate limit exceeded", delay); + } + + return response; + } + catch (Exception ex) when (ex is TimeoutRejectedException or TaskCanceledException) + { + throw new RateLimitException("TorBox rate limit exceeded (timeout)", TimeSpan.FromMinutes(2)); + } + } +} diff --git a/server/RdtClient.Service/RdtClient.Service.csproj b/server/RdtClient.Service/RdtClient.Service.csproj index 00172d1..701730b 100644 --- a/server/RdtClient.Service/RdtClient.Service.csproj +++ b/server/RdtClient.Service/RdtClient.Service.csproj @@ -18,6 +18,7 @@ + @@ -25,7 +26,7 @@ - + diff --git a/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs index bb6dedf..f8d5102 100644 --- a/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/AllDebridDebridClient.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using File = AllDebridNET.File; using Torrent = RdtClient.Data.Models.Data.Torrent; @@ -126,30 +127,46 @@ public class AllDebridDebridClient(ILogger logger, IAllDe public async Task AddTorrentMagnet(String magnetLink) { - var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink); - - if (result?.Id == null) + try { - throw new("Unable to add magnet link"); + var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink); + + if (result?.Id == null) + { + throw new("Unable to add magnet link"); + } + + var resultId = result.Id.ToString() ?? throw new($"Invalid responseID {result.Id}"); + + return resultId; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } - - var resultId = result.Id.ToString() ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; } public async Task AddTorrentFile(Byte[] bytes) { - var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes); - - if (result?.Id == null) + try { - throw new("Unable to add torrent file"); + var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes); + + if (result?.Id == null) + { + throw new("Unable to add torrent file"); + } + + var resultId = result.Id.ToString() ?? throw new($"Invalid responseID {result.Id}"); + + return resultId; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } - - var resultId = result.Id.ToString() ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; } public Task AddNzbLink(String nzbLink) diff --git a/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs b/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs index a18ee6a..e75eb12 100644 --- a/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/DebridLinkTorrentClient.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using Download = RdtClient.Data.Models.Data.Download; using Torrent = DebridLinkFrNET.Models.Torrent; @@ -120,16 +121,32 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto public async Task AddTorrentMagnet(String magnetLink) { - var result = await GetClient().Seedbox.AddTorrentAsync(magnetLink); + try + { + var result = await GetClient().Seedbox.AddTorrentAsync(magnetLink); - return result.Id ?? ""; + return result.Id ?? ""; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } } public async Task AddTorrentFile(Byte[] bytes) { - var result = await GetClient().Seedbox.AddTorrentByFileAsync(bytes); + try + { + var result = await GetClient().Seedbox.AddTorrentByFileAsync(bytes); - return result.Id ?? ""; + return result.Id ?? ""; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } } public Task AddNzbLink(String nzbLink) diff --git a/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs index a95297d..a52d251 100644 --- a/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.cs @@ -5,6 +5,7 @@ using PremiumizeNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using Torrent = RdtClient.Data.Models.Data.Torrent; @@ -92,30 +93,46 @@ public class PremiumizeDebridClient(ILogger logger, IHtt public async Task AddTorrentMagnet(String magnetLink) { - var result = await GetClient().Transfers.CreateAsync(magnetLink, ""); - - if (result?.Id == null) + try { - throw new("Unable to add magnet link"); + var result = await GetClient().Transfers.CreateAsync(magnetLink, ""); + + if (result?.Id == null) + { + throw new("Unable to add magnet link"); + } + + var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); + + return resultId; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } - - var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; } public async Task AddTorrentFile(Byte[] bytes) { - var result = await GetClient().Transfers.CreateAsync(bytes, ""); - - if (result?.Id == null) + try { - throw new("Unable to add torrent file"); + var result = await GetClient().Transfers.CreateAsync(bytes, ""); + + if (result?.Id == null) + { + throw new("Unable to add torrent file"); + } + + var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); + + return resultId; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } - - var resultId = result.Id ?? throw new($"Invalid responseID {result.Id}"); - - return resultId; } public Task AddNzbLink(String nzbLink) diff --git a/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs index 5c24607..184f64d 100644 --- a/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs @@ -5,6 +5,7 @@ using RDNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.DebridClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using Download = RdtClient.Data.Models.Data.Download; using Torrent = RDNET.Torrent; @@ -128,20 +129,36 @@ public class RealDebridDebridClient(ILogger logger, IHtt public async Task AddTorrentMagnet(String magnetLink) { - var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); + try + { + var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); - var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, timeoutCancellationToken.Token); + var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, timeoutCancellationToken.Token); - return result.Id; + return result.Id; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } } public async Task AddTorrentFile(Byte[] bytes) { - var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); + try + { + var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)); - var result = await GetClient().Torrents.AddFileAsync(bytes, timeoutCancellationToken.Token); + var result = await GetClient().Torrents.AddFileAsync(bytes, timeoutCancellationToken.Token); - return result.Id; + return result.Id; + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("rate limit exceeded", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } } public Task AddNzbLink(String nzbLink) diff --git a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs index 53ccb7b..3515278 100644 --- a/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs +++ b/server/RdtClient.Service/Services/DebridClients/TorBoxDebridClient.cs @@ -1,10 +1,10 @@ using System.Diagnostics; using Microsoft.Extensions.Logging; -using MonoTorrent; using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using TorBoxNET; using Torrent = RdtClient.Data.Models.Data.Torrent; @@ -25,10 +25,8 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF throw new("TorBox API Key not set in the settings"); } - var httpClient = httpClientFactory.CreateClient(); - httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - - var torBoxNetClient = new TorBoxNetClient(null, httpClient, 5); + var httpClient = httpClientFactory.CreateClient(DiConfig.TORBOX_CLIENT); + var torBoxNetClient = new TorBoxNetClient(null, httpClient, 1); torBoxNetClient.UseApiAuthentication(apiKey); // Get the server time to fix up the timezones on results @@ -195,52 +193,90 @@ public class TorBoxDebridClient(ILogger logger, IHttpClientF }; } + private async Task HandleAddTorrentErrors(Func> action) + { + try + { + return await action(false); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + + private async Task HandleAddUsenetErrors(Func> action) + { + try + { + return await action(false); + } + catch (RateLimitException) + { + throw; + } + catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) + { + throw rateLimitException; + } + catch (TorBoxException ex) when (ex.Error.Equals("active_limit", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) + { + throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); + } + } + public async Task AddTorrentMagnet(String magnetLink) { - var user = await GetClient().User.GetAsync(true); - - var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3); - - if (result.Error == "ACTIVE_LIMIT") + return await HandleAddTorrentErrors(async asQueued => { - var magnetLinkInfo = MagnetLink.Parse(magnetLink); - - return magnetLinkInfo.InfoHashes.V1!.ToHex().ToLowerInvariant(); - } - - return result.Data!.Hash!; + var user = await GetClient().User.GetAsync(true); + var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); + return result.Data!.Hash!; + }); } public async Task AddTorrentFile(Byte[] bytes) { - var user = await GetClient().User.GetAsync(true); - - var result = await GetClient().Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3); - - if (result.Error == "ACTIVE_LIMIT") + return await HandleAddTorrentErrors(async asQueued => { - using var stream = new MemoryStream(bytes); - - var torrent = await MonoTorrent.Torrent.LoadAsync(stream); - - return torrent.InfoHashes.V1!.ToHex().ToLowerInvariant(); - } - - return result.Data!.Hash!; + var user = await GetClient().User.GetAsync(true); + var result = await GetClient().Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued); + return result.Data!.Hash!; + }); } public async Task AddNzbLink(String nzbLink) { - var result = await GetClient().Usenet.AddLinkAsync(nzbLink); - - return result.Data!.Hash!; + return await HandleAddUsenetErrors(async asQueued => + { + var result = await GetClient().Usenet.AddLinkAsync(nzbLink, as_queued: asQueued); + return result.Data!.Hash!; + }); } public virtual async Task AddNzbFile(Byte[] bytes, String? name) { - var result = await GetClient().Usenet.AddFileAsync(bytes, name: name); - - return result.Data!.Hash!; + return await HandleAddUsenetErrors(async asQueued => + { + var result = await GetClient().Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued); + return result.Data!.Hash!; + }); } public async Task> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/RemoteService.cs b/server/RdtClient.Service/Services/RemoteService.cs index a985c9b..460b81b 100644 --- a/server/RdtClient.Service/Services/RemoteService.cs +++ b/server/RdtClient.Service/Services/RemoteService.cs @@ -79,4 +79,9 @@ public class RemoteService(IHubContext hub, Torrents torrents) { await hub.Clients.All.SendCoreAsync("diskSpaceStatus", [status]); } + + public async Task UpdateRateLimitStatus(RateLimitStatus status) + { + await hub.Clients.All.SendCoreAsync("rateLimitStatus", [status]); + } } diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 4c40837..ab14a4b 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -11,18 +11,11 @@ using RdtClient.Service.Services.Downloaders; namespace RdtClient.Service.Services; -public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads) +public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory) { public static readonly ConcurrentDictionary ActiveDownloadClients = new(); public static readonly ConcurrentDictionary ActiveUnpackClients = new(); - private readonly HttpClient _httpClient = new() - { - Timeout = TimeSpan.FromSeconds(10) - }; - - public static Boolean IsPausedForLowDiskSpace { get; set; } - public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId) { if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient)) @@ -37,7 +30,11 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow return (0, 0, 0); } - + + public static Boolean IsPausedForLowDiskSpace { get; set; } + + public static DateTimeOffset NextDequeueTime { get; private set; } = DateTimeOffset.MinValue; + public async Task Initialize() { Log("Initializing TorrentRunner"); @@ -127,7 +124,10 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow { Log("Updating Aria2 status"); - var aria2NetClient = new Aria2NetClient(Settings.Get.DownloadClient.Aria2cUrl, Settings.Get.DownloadClient.Aria2cSecret, _httpClient, 1); + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(10); + + var aria2NetClient = new Aria2NetClient(Settings.Get.DownloadClient.Aria2cUrl, Settings.Get.DownloadClient.Aria2cSecret, httpClient, 1); var allDownloads = await aria2NetClient.TellAllAsync(); @@ -349,27 +349,51 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow if (torrentsToAddToProvider.Count != 0) { - var downloadingTorrentsCount = allTorrents.Count(m => m.RdStatus is not (TorrentStatus.Queued or TorrentStatus.Finished or TorrentStatus.Error)); - - var maxParallelDownloads = Settings.Get.Provider.MaxParallelDownloads; - - logger.LogDebug("Currently downloading {downloadingTorrentCount}/{maxParallelDownloads} torrents, {queuedCount} queued.", - downloadingTorrentsCount, - maxParallelDownloads, - torrentsToAddToProvider.Count); - - var dequeueCount = maxParallelDownloads == 0 ? torrentsToAddToProvider.Count : maxParallelDownloads - downloadingTorrentsCount; - - foreach (var torrent in torrentsToAddToProvider.Take(dequeueCount)) + if (DateTimeOffset.Now < NextDequeueTime) { - try + logger.LogDebug($"Dequeuing torrents is paused until {NextDequeueTime}, {NextDequeueTime - DateTimeOffset.Now} remaining"); + } + else + { + if (NextDequeueTime != DateTimeOffset.MinValue) { - await torrents.DequeueFromDebridQueue(torrent); + NextDequeueTime = DateTimeOffset.MinValue; + + await remoteService.UpdateRateLimitStatus(new RateLimitStatus + { + NextDequeueTime = null, + SecondsRemaining = 0 + }); } - catch (Exception ex) + + var downloadingTorrentsCount = allTorrents.Count(m => m.RdStatus is not (TorrentStatus.Queued or TorrentStatus.Finished or TorrentStatus.Error)); + + var maxParallelDownloads = Settings.Get.Provider.MaxParallelDownloads; + + logger.LogDebug("Currently downloading {downloadingTorrentCount}/{maxParallelDownloads} torrents, {queuedCount} queued.", + downloadingTorrentsCount, + maxParallelDownloads, + torrentsToAddToProvider.Count); + + var dequeueCount = maxParallelDownloads == 0 ? torrentsToAddToProvider.Count : maxParallelDownloads - downloadingTorrentsCount; + + foreach (var torrent in torrentsToAddToProvider.Take(dequeueCount)) { - await torrents.UpdateComplete(torrent.TorrentId, $"Could not add to provider: {ex.Message}", DateTimeOffset.Now, true); - logger.LogWarning(ex, "Could not dequeue torrent {torrentId}", torrent.TorrentId); + try + { + await torrents.DequeueFromDebridQueue(torrent); + } + catch (RateLimitException ex) + { + await SetRateLimit(ex.RetryAfter, ex.Message); + + break; + } + catch (Exception ex) + { + await torrents.UpdateComplete(torrent.TorrentId, $"Could not add to provider: {ex.Message}", DateTimeOffset.Now, true); + logger.LogWarning(ex, "Could not dequeue torrent {torrentId}", torrent.TorrentId); + } } } } @@ -724,6 +748,19 @@ public class TorrentRunner(ILogger logger, Torrents torrents, Dow } } + public async Task SetRateLimit(TimeSpan retryAfter, String message) + { + NextDequeueTime = DateTimeOffset.Now.Add(retryAfter); + + Log($"Rate-limit reached, pausing dequeuing for {retryAfter.TotalMinutes} minutes (until {NextDequeueTime}): {message}"); + + await remoteService.UpdateRateLimitStatus(new RateLimitStatus + { + NextDequeueTime = NextDequeueTime, + SecondsRemaining = retryAfter.TotalSeconds + }); + } + private void Log(String message, Download? download, Torrent? torrent) { if (download != null) diff --git a/server/RdtClient.Web/Controllers/QBittorrentController.cs b/server/RdtClient.Web/Controllers/QBittorrentController.cs index b491eb2..1692978 100644 --- a/server/RdtClient.Web/Controllers/QBittorrentController.cs +++ b/server/RdtClient.Web/Controllers/QBittorrentController.cs @@ -14,7 +14,7 @@ namespace RdtClient.Web.Controllers; [ApiController] [Route("api/v2")] [Route("qbittorrent/api/v2")] -public class QBittorrentController(ILogger logger, QBittorrent qBittorrent) : Controller +public class QBittorrentController(ILogger logger, QBittorrent qBittorrent, IHttpClientFactory httpClientFactory) : Controller { [AllowAnonymous] [Route("auth/login")] @@ -338,7 +338,7 @@ public class QBittorrentController(ILogger logger, QBitto } else if (url.StartsWith("http")) { - var httpClient = new HttpClient(); + var httpClient = httpClientFactory.CreateClient(); var result = await httpClient.GetByteArrayAsync(url); await qBittorrent.TorrentsAddFile(result, request.Category, null); } diff --git a/server/RdtClient.Web/Controllers/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index 37a815a..d1dce19 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -173,6 +173,28 @@ public class TorrentsController(ILogger logger, Torrents tor return Ok(status); } + [HttpGet] + [Route("RateLimitStatus")] + public ActionResult GetRateLimitStatus() + { + var nextDequeueTime = TorrentRunner.NextDequeueTime; + + if (nextDequeueTime < DateTimeOffset.Now) + { + return Ok(new RateLimitStatus + { + NextDequeueTime = null, + SecondsRemaining = 0 + }); + } + + return Ok(new RateLimitStatus + { + NextDequeueTime = nextDequeueTime, + SecondsRemaining = (nextDequeueTime - DateTimeOffset.Now).TotalSeconds + }); + } + /// /// Used for debugging only. Force a tick. ///