From 0a7e85c3e245e49b9f4031066a3dd0eeeddd1267 Mon Sep 17 00:00:00 2001 From: omgbeez <251408589+omgbeez@users.noreply.github.com> Date: Sat, 17 Jan 2026 12:22:10 -0500 Subject: [PATCH] Implement new rate-limiting framework for torrent clients --- .../src/app/models/rate-limit-status.model.ts | 4 + .../torrent-table.component.html | 7 ++ .../torrent-table/torrent-table.component.ts | 16 +++- client/src/app/torrent.service.ts | 10 +++ .../Models/Internal/RateLimitException.cs | 6 ++ .../Models/Internal/RateLimitStatus.cs | 7 ++ .../Helpers/RateLimitHandlerTest.cs | 53 +++++++++++++ .../RdtClient.Service.Test.csproj | 1 + .../BackgroundServices/ProviderUpdater.cs | 7 ++ server/RdtClient.Service/DiConfig.cs | 70 +++++++++++++++-- .../Helpers/RateLimitHandler.cs | 34 +++++++++ .../RdtClient.Service.csproj | 4 +- .../Services/RemoteService.cs | 6 ++ .../TorrentClients/AllDebridTorrentClient.cs | 49 ++++++++---- .../TorrentClients/DebridLinkTorrentClient.cs | 25 ++++++- .../TorrentClients/PremiumizeTorrentClient.cs | 49 ++++++++---- .../TorrentClients/RealDebridTorrentClient.cs | 29 +++++-- .../TorrentClients/TorBoxTorrentClient.cs | 60 +++++++++------ .../Services/TorrentRunner.cs | 75 ++++++++++++++----- .../Controllers/TorrentsController.cs | 23 ++++++ 20 files changed, 442 insertions(+), 93 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..720619b 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'; @@ -21,8 +22,8 @@ export class TorrentTableComponent implements OnInit { public torrents: Torrent[] = []; public selectedTorrents: string[] = []; public error: string; - public sortProperty = 'rdName'; - public sortDirection: 'asc' | 'desc' = 'asc'; + public sortProperty = 'added'; + public sortDirection: 'asc' | 'desc' = 'desc'; public isDeleteModalActive: boolean; public deleteError: string; @@ -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 8ecd2f0..334e812 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/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..3cc95a5 --- /dev/null +++ b/server/RdtClient.Service.Test/Helpers/RateLimitHandlerTest.cs @@ -0,0 +1,53 @@ +using System.Net; +using RdtClient.Data.Models.Internal; +using RdtClient.Service.Helpers; +using Xunit; + +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 3313d85..1a41e5b 100644 --- a/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj +++ b/server/RdtClient.Service.Test/RdtClient.Service.Test.csproj @@ -20,6 +20,7 @@ + all diff --git a/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs b/server/RdtClient.Service/BackgroundServices/ProviderUpdater.cs index a9a5eba..35b8ab4 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,6 +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."); @@ -56,6 +58,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/DiConfig.cs b/server/RdtClient.Service/DiConfig.cs index c93f5e5..c9d2f27 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.TorrentClients; @@ -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) @@ -58,11 +62,6 @@ public static class DiConfig public static void RegisterHttpClients(this IServiceCollection services) { - var retryPolicy = HttpPolicyExtensions - .HandleTransientHttpError() - .OrResult(r => r.StatusCode == HttpStatusCode.TooManyRequests) - .WaitAndRetryAsync(retryCount: 5, sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))); - services.AddHttpClient(); services.ConfigureHttpClientDefaults(builder => { @@ -72,7 +71,64 @@ public static class DiConfig }); }); + services.AddTransient(); + services.AddHttpClient(RD_CLIENT) - .AddPolicyHandler(retryPolicy); + .AddHttpMessageHandler() + .AddResilienceHandler("rd_client_handler", ConfigureResiliencePipeline); + + services.AddHttpClient(TORBOX_CLIENT) + .AddHttpMessageHandler() + .AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline); + } + + private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder builder) + { + 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(), + { Result.StatusCode: >= HttpStatusCode.InternalServerError } => 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); + } + }); + + builder.AddTimeout(new TimeoutStrategyOptions + { + TimeoutGenerator = _ => new ValueTask(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout)) + }); } } diff --git a/server/RdtClient.Service/Helpers/RateLimitHandler.cs b/server/RdtClient.Service/Helpers/RateLimitHandler.cs new file mode 100644 index 0000000..14a7742 --- /dev/null +++ b/server/RdtClient.Service/Helpers/RateLimitHandler.cs @@ -0,0 +1,34 @@ +using System.Net; +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 (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + 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 3d042d8..6822a09 100644 --- a/server/RdtClient.Service/RdtClient.Service.csproj +++ b/server/RdtClient.Service/RdtClient.Service.csproj @@ -18,6 +18,8 @@ + + @@ -25,7 +27,7 @@ - + diff --git a/server/RdtClient.Service/Services/RemoteService.cs b/server/RdtClient.Service/Services/RemoteService.cs index 8eea62c..a930f09 100644 --- a/server/RdtClient.Service/Services/RemoteService.cs +++ b/server/RdtClient.Service/Services/RemoteService.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.SignalR; +using RdtClient.Data.Models.Internal; namespace RdtClient.Service.Services; @@ -24,4 +25,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]); + } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs index 31eacee..51905ea 100644 --- a/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/AllDebridTorrentClient.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using RdtClient.Data.Enums; using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using RdtClient.Data.Models.Data; using File = AllDebridNET.File; @@ -124,30 +125,46 @@ public class AllDebridTorrentClient(ILogger logger, IAll public async Task AddMagnet(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 AddFile(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> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs index 567b777..14b1574 100644 --- a/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/DebridLinkTorrentClient.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using DebridLinkFrNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using RdtClient.Data.Models.Data; using Download = RdtClient.Data.Models.Data.Download; @@ -120,16 +121,32 @@ public class DebridLinkClient(ILogger logger, IHttpClientFacto public async Task AddMagnet(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 AddFile(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> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs index 1c7014d..effae5e 100644 --- a/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/PremiumizeTorrentClient.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using PremiumizeNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.TorrentClient; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; using RdtClient.Data.Models.Data; using Torrent = RdtClient.Data.Models.Data.Torrent; @@ -91,30 +92,46 @@ public class PremiumizeTorrentClient(ILogger logger, IH public async Task AddMagnet(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 AddFile(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> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs index f5b2289..1b5e101 100644 --- a/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/RealDebridTorrentClient.cs @@ -5,6 +5,7 @@ using RDNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; using RdtClient.Data.Models.TorrentClient; +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 RealDebridTorrentClient(ILogger logger, IH public async Task AddMagnet(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 AddFile(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> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs index 4994e72..7a2a012 100644 --- a/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs +++ b/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs @@ -5,6 +5,7 @@ using TorBoxNET; using RdtClient.Data.Enums; using RdtClient.Data.Models.TorrentClient; using RdtClient.Data.Models.Data; +using RdtClient.Data.Models.Internal; using RdtClient.Service.Helpers; namespace RdtClient.Service.Services.TorrentClients; @@ -23,10 +24,10 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien throw new("TorBox API Key not set in the settings"); } - var httpClient = httpClientFactory.CreateClient(); + var httpClient = httpClientFactory.CreateClient(DiConfig.TORBOX_CLIENT); httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); - var torBoxNetClient = new TorBoxNetClient(null, httpClient, 5); + var torBoxNetClient = new TorBoxNetClient(null, httpClient, 1); torBoxNetClient.UseApiAuthentication(apiKey); // Get the server time to fix up the timezones on results @@ -120,35 +121,48 @@ public class TorBoxTorrentClient(ILogger logger, IHttpClien }; } + private async Task AddTorrentRetry(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 AddMagnet(String magnetLink) { - var user = await GetClient().User.GetAsync(true); - - var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, false); - - if (result.Error == "ACTIVE_LIMIT") + return await AddTorrentRetry(async asQueued => { - var magnetLinkInfo = MonoTorrent.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 AddFile(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 AddTorrentRetry(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> GetAvailableFiles(String hash) diff --git a/server/RdtClient.Service/Services/TorrentRunner.cs b/server/RdtClient.Service/Services/TorrentRunner.cs index 50e87ed..d5765b8 100644 --- a/server/RdtClient.Service/Services/TorrentRunner.cs +++ b/server/RdtClient.Service/Services/TorrentRunner.cs @@ -11,13 +11,15 @@ using System.Text.Json; namespace RdtClient.Service.Services; -public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads) +public class TorrentRunner(ILogger logger, Torrents torrents, Downloads downloads, RemoteService remoteService) { public static readonly ConcurrentDictionary ActiveDownloadClients = new(); public static readonly ConcurrentDictionary ActiveUnpackClients = new(); public static Boolean IsPausedForLowDiskSpace { get; set; } + public static DateTimeOffset NextDequeueTime { get; private set; } = DateTimeOffset.MinValue; + private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(10) @@ -323,27 +325,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); + } } } } @@ -698,6 +724,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/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index fd3a0dc..005d9ee 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MonoTorrent; +using RdtClient.Data.Models.Internal; using RdtClient.Data.Models.TorrentClient; using RdtClient.Service.Helpers; using RdtClient.Service.Services; @@ -53,6 +54,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. ///