Merge pull request #923 from omgbeez/upstream/resilience

Add resilience handler, predictive rate limit handling
This commit is contained in:
Roger Far 2026-02-23 08:36:35 -07:00 committed by GitHub
commit 81dd3dd9f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 687 additions and 130 deletions

View file

@ -0,0 +1,4 @@
export interface RateLimitStatus {
nextDequeueTime: Date | null;
secondsRemaining: number;
}

View file

@ -13,6 +13,13 @@
<small>Last check: {{ diskSpaceStatus.lastCheckTime | date: 'short' }}</small> <small>Last check: {{ diskSpaceStatus.lastCheckTime | date: 'short' }}</small>
</div> </div>
} }
@if (rateLimitStatus?.nextDequeueTime) {
<div class="notification is-warning">
<strong>Debrid provider rate limit reached</strong>
<br />
New torrents will not be added until {{ rateLimitStatus.nextDequeueTime | date: 'medium' }}
</div>
}
<div class="table-container"> <div class="table-container">
<table class="table is-fullwidth is-hoverable"> <table class="table is-fullwidth is-hoverable">
<thead> <thead>

View file

@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Torrent } from '../models/torrent.model'; import { Torrent } from '../models/torrent.model';
import { DiskSpaceStatus } from '../models/disk-space-status.model'; import { DiskSpaceStatus } from '../models/disk-space-status.model';
import { RateLimitStatus } from '../models/rate-limit-status.model';
import { TorrentService } from '../torrent.service'; import { TorrentService } from '../torrent.service';
import { forkJoin, Observable } from 'rxjs'; import { forkJoin, Observable } from 'rxjs';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@ -50,6 +51,7 @@ export class TorrentTableComponent implements OnInit {
public updateSettingsTorrentLifetime: number; public updateSettingsTorrentLifetime: number;
public diskSpaceStatus: DiskSpaceStatus | null = null; public diskSpaceStatus: DiskSpaceStatus | null = null;
public rateLimitStatus: RateLimitStatus | null = null;
constructor( constructor(
private router: Router, private router: Router,
@ -81,6 +83,16 @@ export class TorrentTableComponent implements OnInit {
this.diskSpaceStatus = status; 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.torrentService.update$.subscribe((result) => {
this.torrents = result; this.torrents = result;
}); });

View file

@ -4,6 +4,7 @@ import * as signalR from '@microsoft/signalr';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { Torrent, TorrentFileAvailability } from './models/torrent.model'; import { Torrent, TorrentFileAvailability } from './models/torrent.model';
import { DiskSpaceStatus } from './models/disk-space-status.model'; import { DiskSpaceStatus } from './models/disk-space-status.model';
import { RateLimitStatus } from './models/rate-limit-status.model';
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
@Injectable({ @Injectable({
@ -12,6 +13,7 @@ import { APP_BASE_HREF } from '@angular/common';
export class TorrentService { export class TorrentService {
public update$: Subject<Torrent[]> = new Subject(); public update$: Subject<Torrent[]> = new Subject();
public diskSpaceStatus$: Subject<DiskSpaceStatus> = new Subject(); public diskSpaceStatus$: Subject<DiskSpaceStatus> = new Subject();
public rateLimitStatus$: Subject<RateLimitStatus> = new Subject();
private connection: signalR.HubConnection; private connection: signalR.HubConnection;
@ -40,6 +42,10 @@ export class TorrentService {
this.diskSpaceStatus$.next(status); this.diskSpaceStatus$.next(status);
}); });
this.connection.on('rateLimitStatus', (status: any) => {
this.rateLimitStatus$.next(status);
});
this.connection.onreconnected(() => { this.connection.onreconnected(() => {
this.getDiskSpaceStatus().subscribe({ this.getDiskSpaceStatus().subscribe({
next: (status) => { next: (status) => {
@ -65,6 +71,10 @@ export class TorrentService {
return this.http.get<DiskSpaceStatus | null>(`${this.baseHref}Api/Torrents/DiskSpaceStatus`); return this.http.get<DiskSpaceStatus | null>(`${this.baseHref}Api/Torrents/DiskSpaceStatus`);
} }
public getRateLimitStatus(): Observable<RateLimitStatus | null> {
return this.http.get<RateLimitStatus | null>(`${this.baseHref}Api/Torrents/RateLimitStatus`);
}
public uploadMagnet(magnetLink: string, torrent: Torrent): Observable<void> { public uploadMagnet(magnetLink: string, torrent: Torrent): Observable<void> {
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, { return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, {
magnetLink, magnetLink,

View file

@ -19,6 +19,7 @@ public interface ITorrentData
Task UpdateRdData(Torrent torrent); Task UpdateRdData(Torrent torrent);
Task UpdateRdId(Torrent torrent, String rdId); Task UpdateRdId(Torrent torrent, String rdId);
Task UpdateHash(Torrent torrent, String hash);
Task Update(Torrent torrent); Task Update(Torrent torrent);
Task UpdateCategory(Guid torrentId, String? category); Task UpdateCategory(Guid torrentId, String? category);
Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry); Task UpdateComplete(Guid torrentId, String? error, DateTimeOffset? datetime, Boolean retry);

View file

@ -0,0 +1,6 @@
namespace RdtClient.Data.Models.Internal;
public class RateLimitException(String message, TimeSpan retryAfter) : Exception(message)
{
public TimeSpan RetryAfter { get; } = retryAfter;
}

View file

@ -0,0 +1,7 @@
namespace RdtClient.Data.Models.Internal;
public class RateLimitStatus
{
public DateTimeOffset? NextDequeueTime { get; set; }
public Double SecondsRemaining { get; set; }
}

View file

@ -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<RateLimitException>(() => 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<RateLimitException>(() => client.GetAsync("http://example.com"));
Assert.Equal(TimeSpan.FromMinutes(2), ex.RetryAfter);
}
private class MockHttpMessageHandler(HttpStatusCode statusCode, Int32? retryAfterSeconds) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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);
}
}
}

View file

@ -21,7 +21,7 @@
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" /> <PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.0" /> <PackageReference Include="TestableIO.System.IO.Abstractions.TestingHelpers" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.6.0" /> <PackageReference Include="TorBox.NET" Version="1.6.2" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View file

@ -5,6 +5,7 @@ using Newtonsoft.Json;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services; using RdtClient.Service.Services;
using RdtClient.Service.Services.DebridClients; using RdtClient.Service.Services.DebridClients;
using TorBoxNET; using TorBoxNET;
@ -269,16 +270,15 @@ public class TorBoxDebridClientTest
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object); torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object); clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny<CancellationToken>())) usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), It.IsAny<Boolean>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<UsenetAddResult> { Data = new() .ReturnsAsync(new Response<UsenetAddResult> { Data = new UsenetAddResult { Hash = "new-hash" } });
{ Hash = "new-hash" } });
// Act // Act
var result = await clientMock.Object.AddNzbFile(bytes, name); var result = await clientMock.Object.AddNzbFile(bytes, name);
// Assert // Assert
Assert.Equal("new-hash", result); Assert.Equal("new-hash", result);
usenetApiMock.Verify(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny<CancellationToken>()), Times.Once); usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), It.IsAny<Boolean>(), It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact] [Fact]
public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForIndividualFiles() public async Task GetDownloadInfos_GeneratesCorrectFakedlLinks_ForIndividualFiles()
@ -479,6 +479,198 @@ public class TorBoxDebridClientTest
Assert.Equal("https://real-usenet-link", result); Assert.Equal("https://real-usenet-link", result);
usenetApiMock.Verify(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny<CancellationToken>()), Times.Once); usenetApiMock.Verify(m => m.RequestDownloadAsync(98765, 4321, false, It.IsAny<CancellationToken>()), Times.Once);
} }
[Fact]
public async Task AddTorrentMagnet_ThrowsRateLimitException_OnActiveLimit()
{
// Arrange
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User> { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
// Act & Assert
await Assert.ThrowsAsync<RateLimitException>(() => clientMock.Object.AddTorrentMagnet(magnetLink));
torrentsApiMock.Verify(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()), Times.Once);
torrentsApiMock.Verify(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), true, It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTorrentMagnet_ThrowsRateLimitException_OnSlowDown()
{
// Arrange
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User> { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("slow_down"));
// Act & Assert
await Assert.ThrowsAsync<RateLimitException>(() => clientMock.Object.AddTorrentMagnet(magnetLink));
}
[Fact]
public async Task AddTorrentMagnet_ThrowsRateLimitException_OnRateLimitException()
{
// Arrange
var magnetLink = "magnet:?xt=urn:btih:test";
var torrentsApiMock = new Mock<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User> { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60)));
// Act & Assert
var ex = await Assert.ThrowsAsync<RateLimitException>(() => 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<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User> { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddMagnetAsync(magnetLink, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Wrapped", new RateLimitException("TorBox rate limit exceeded", TimeSpan.FromMinutes(60))));
// Act & Assert
var ex = await Assert.ThrowsAsync<RateLimitException>(() => 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<ITorrentsApi>();
var userApiMock = new Mock<IUserApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Torrents).Returns(torrentsApiMock.Object);
torBoxClientMock.Setup(m => m.User).Returns(userApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
userApiMock.Setup(m => m.GetAsync(true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new Response<User> { Data = new User { Settings = new UserSettings { SeedTorrents = 5 } } });
torrentsApiMock.Setup(m => m.AddFileAsync(bytes, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
// Act & Assert
await Assert.ThrowsAsync<RateLimitException>(() => clientMock.Object.AddTorrentFile(bytes));
torrentsApiMock.Verify(m => m.AddFileAsync(bytes, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()), Times.Once);
torrentsApiMock.Verify(m => m.AddFileAsync(bytes, 5, It.IsAny<Boolean>(), It.IsAny<String?>(), true, It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddNzbLink_ThrowsRateLimitException_OnActiveLimit()
{
// Arrange
var nzbLink = "https://example.com/test.nzb";
var usenetApiMock = new Mock<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddLinkAsync(nzbLink, It.IsAny<Int32>(), It.IsAny<String?>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
// Act & Assert
await Assert.ThrowsAsync<RateLimitException>(() => clientMock.Object.AddNzbLink(nzbLink));
usenetApiMock.Verify(m => m.AddLinkAsync(nzbLink, It.IsAny<Int32>(), It.IsAny<String?>(), It.IsAny<String?>(), false, It.IsAny<CancellationToken>()), Times.Once);
usenetApiMock.Verify(m => m.AddLinkAsync(nzbLink, It.IsAny<Int32>(), It.IsAny<String?>(), It.IsAny<String?>(), true, It.IsAny<CancellationToken>()), 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<IUsenetApi>();
var clientMock = new Mock<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object) { CallBase = true };
var torBoxClientMock = new Mock<ITorBoxNetClient>();
torBoxClientMock.Setup(m => m.Usenet).Returns(usenetApiMock.Object);
clientMock.Protected().Setup<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
usenetApiMock.Setup(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), false, It.IsAny<CancellationToken>()))
.ThrowsAsync(new TorBoxException("active_limit", "Active limit reached"));
// Act & Assert
await Assert.ThrowsAsync<RateLimitException>(() => clientMock.Object.AddNzbFile(bytes, name));
usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), false, It.IsAny<CancellationToken>()), Times.Once);
usenetApiMock.Verify(m => m.AddFileAsync(bytes, It.IsAny<Int32>(), name, It.IsAny<String?>(), true, It.IsAny<CancellationToken>()), 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<TorBoxDebridClient>(_loggerMock.Object, _httpClientFactoryMock.Object, _fileFilterMock.Object);
// Act
var result = await clientMock.Object.UpdateData(torrent, torrentClientTorrent);
// Assert
Assert.Equal(TorrentStatus.Error, result.RdStatus);
}
[Fact] [Fact]
public async Task UpdateData_LogsWarning_WhenTorBoxStatusIsUnmapped() public async Task UpdateData_LogsWarning_WhenTorBoxStatusIsUnmapped()

View file

@ -15,7 +15,7 @@ using TorrentsService = RdtClient.Service.Services.Torrents;
namespace RdtClient.Service.Test.Services; namespace RdtClient.Service.Test.Services;
internal class Mocks class Mocks
{ {
public readonly Mock<IDownloads> DownloadsMock; public readonly Mock<IDownloads> DownloadsMock;
public readonly Mock<IEnricher> EnricherMock; public readonly Mock<IEnricher> EnricherMock;

View file

@ -2,6 +2,7 @@
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Services; using RdtClient.Service.Services;
namespace RdtClient.Service.BackgroundServices; namespace RdtClient.Service.BackgroundServices;
@ -19,7 +20,7 @@ public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider s
using var scope = serviceProvider.CreateScope(); using var scope = serviceProvider.CreateScope();
var torrentService = scope.ServiceProvider.GetRequiredService<Torrents>(); var torrentService = scope.ServiceProvider.GetRequiredService<Torrents>();
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
logger.LogInformation("ProviderUpdater started."); logger.LogInformation("ProviderUpdater started.");
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
@ -28,7 +29,7 @@ public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider s
{ {
var torrents = await torrentService.Get(); 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"); logger.LogDebug($"Updating torrent info from debrid provider");
@ -56,6 +57,11 @@ public class ProviderUpdater(ILogger<ProviderUpdater> logger, IServiceProvider s
logger.LogDebug("Finished updating torrent info from debrid provider, next update in {updateTime} seconds", updateTime); 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) catch (Exception ex)
{ {
logger.LogError(ex, "Unexpected error occurred in ProviderUpdater: {ex.Message}", ex.Message); logger.LogError(ex, "Unexpected error occurred in ProviderUpdater: {ex.Message}", ex.Message);

View file

@ -5,7 +5,7 @@ using Newtonsoft.Json;
namespace RdtClient.Service.BackgroundServices; namespace RdtClient.Service.BackgroundServices;
public class UpdateChecker(ILogger<UpdateChecker> logger) : BackgroundService public class UpdateChecker(ILogger<UpdateChecker> logger, IHttpClientFactory httpClientFactory) : BackgroundService
{ {
private static readonly List<String> KnownGhsaIds = []; private static readonly List<String> KnownGhsaIds = [];
public static String? CurrentVersion { get; private set; } public static String? CurrentVersion { get; private set; }
@ -79,13 +79,13 @@ public class UpdateChecker(ILogger<UpdateChecker> logger) : BackgroundService
logger.LogInformation("UpdateChecker stopped."); logger.LogInformation("UpdateChecker stopped.");
} }
private static async Task<T?> GitHubRequest<T>(String endpoint, CancellationToken cancellationToken) private async Task<T?> GitHubRequest<T>(String endpoint, CancellationToken cancellationToken)
{ {
var httpClient = new HttpClient(); var httpClient = httpClientFactory.CreateClient();
httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion)); httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion));
var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken); var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken);
return JsonConvert.DeserializeObject<T>(response); return JsonConvert.DeserializeObject<T>(response);
} }
} }

View file

@ -4,8 +4,11 @@ using System.Reflection;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Polly; using Polly;
using Polly.Extensions.Http; using Polly.Timeout;
using RateLimitHeaders.Polly;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.BackgroundServices; using RdtClient.Service.BackgroundServices;
using RdtClient.Service.Helpers;
using RdtClient.Service.Middleware; using RdtClient.Service.Middleware;
using RdtClient.Service.Services; using RdtClient.Service.Services;
using RdtClient.Service.Services.DebridClients; using RdtClient.Service.Services.DebridClients;
@ -16,6 +19,7 @@ namespace RdtClient.Service;
public static class DiConfig public static class DiConfig
{ {
public const String RD_CLIENT = "RdClient"; 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 readonly String UserAgent = $"rdt-client {Assembly.GetEntryAssembly()?.GetName().Version}";
public static void RegisterRdtServices(this IServiceCollection services) public static void RegisterRdtServices(this IServiceCollection services)
@ -60,13 +64,7 @@ public static class DiConfig
public static void RegisterHttpClients(this IServiceCollection services) 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.AddHttpClient();
services.ConfigureHttpClientDefaults(builder => services.ConfigureHttpClientDefaults(builder =>
{ {
builder.ConfigureHttpClient(httpClient => builder.ConfigureHttpClient(httpClient =>
@ -75,7 +73,64 @@ public static class DiConfig
}); });
}); });
services.AddTransient<RateLimitHandler>();
services.AddHttpClient(RD_CLIENT) services.AddHttpClient(RD_CLIENT)
.AddPolicyHandler(retryPolicy); .AddHttpMessageHandler<RateLimitHandler>()
.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<RateLimitHandler>()
.AddResilienceHandler("torbox_client_handler", ConfigureResiliencePipeline);
}
private static void ConfigureResiliencePipeline(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
builder.AddTimeout(new TimeoutStrategyOptions
{
TimeoutGenerator = _ => new ValueTask<TimeSpan>(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("Provider rate limit exceeded", delay);
}
return new ValueTask<TimeSpan?>(delay);
}
return new ValueTask<TimeSpan?>((TimeSpan?)null);
}
});
} }
} }

View file

@ -0,0 +1,36 @@
using System.Net;
using Polly.Timeout;
using RdtClient.Data.Models.Internal;
namespace RdtClient.Service.Helpers;
public class RateLimitHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> 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);
}
response.Dispose();
throw new RateLimitException("TorBox rate limit exceeded", delay);
}
return response;
}
catch (Exception ex) when (ex is TimeoutRejectedException or TaskCanceledException)
{
throw new RateLimitException("Provider rate limit exceeded (timeout)", TimeSpan.FromMinutes(2));
}
}
}

View file

@ -18,6 +18,7 @@
<PackageReference Include="MonoTorrent" Version="3.0.2" /> <PackageReference Include="MonoTorrent" Version="3.0.2" />
<PackageReference Include="Polly" Version="8.6.5" /> <PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="Premiumize.NET" Version="1.0.10" /> <PackageReference Include="Premiumize.NET" Version="1.0.10" />
<PackageReference Include="RateLimitHeaders.Polly" Version="1.0.0" />
<PackageReference Include="RD.NET" Version="2.1.11" /> <PackageReference Include="RD.NET" Version="2.1.11" />
<PackageReference Include="Serilog" Version="4.3.1" /> <PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
@ -25,7 +26,7 @@
<PackageReference Include="Synology.Api.Client" Version="0.3.93" /> <PackageReference Include="Synology.Api.Client" Version="0.3.93" />
<PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" /> <PackageReference Include="TestableIO.System.IO.Abstractions" Version="22.1.0" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
<PackageReference Include="TorBox.NET" Version="1.6.0" /> <PackageReference Include="TorBox.NET" Version="1.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View file

@ -5,6 +5,7 @@ using Newtonsoft.Json;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
using File = AllDebridNET.File; using File = AllDebridNET.File;
using Torrent = RdtClient.Data.Models.Data.Torrent; using Torrent = RdtClient.Data.Models.Data.Torrent;
@ -126,30 +127,46 @@ public class AllDebridDebridClient(ILogger<AllDebridDebridClient> logger, IAllDe
public async Task<String> AddTorrentMagnet(String magnetLink) public async Task<String> AddTorrentMagnet(String magnetLink)
{ {
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadMagnetAsync(magnetLink); try
if (result?.Id == null)
{ {
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<String> AddTorrentFile(Byte[] bytes) public async Task<String> AddTorrentFile(Byte[] bytes)
{ {
var result = await allDebridNetClientFactory.GetClient().Magnet.UploadFileAsync(bytes); try
if (result?.Id == null)
{ {
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<String> AddNzbLink(String nzbLink) public Task<String> AddNzbLink(String nzbLink)

View file

@ -5,6 +5,7 @@ using Newtonsoft.Json;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
using Download = RdtClient.Data.Models.Data.Download; using Download = RdtClient.Data.Models.Data.Download;
using Torrent = DebridLinkFrNET.Models.Torrent; using Torrent = DebridLinkFrNET.Models.Torrent;
@ -120,16 +121,32 @@ public class DebridLinkClient(ILogger<DebridLinkClient> logger, IHttpClientFacto
public async Task<String> AddTorrentMagnet(String magnetLink) public async Task<String> 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<String> AddTorrentFile(Byte[] bytes) public async Task<String> 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<String> AddNzbLink(String nzbLink) public Task<String> AddNzbLink(String nzbLink)

View file

@ -5,6 +5,7 @@ using PremiumizeNET;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
using Torrent = RdtClient.Data.Models.Data.Torrent; using Torrent = RdtClient.Data.Models.Data.Torrent;
@ -92,30 +93,46 @@ public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHtt
public async Task<String> AddTorrentMagnet(String magnetLink) public async Task<String> AddTorrentMagnet(String magnetLink)
{ {
var result = await GetClient().Transfers.CreateAsync(magnetLink, ""); try
if (result?.Id == null)
{ {
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<String> AddTorrentFile(Byte[] bytes) public async Task<String> AddTorrentFile(Byte[] bytes)
{ {
var result = await GetClient().Transfers.CreateAsync(bytes, ""); try
if (result?.Id == null)
{ {
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<String> AddNzbLink(String nzbLink) public Task<String> AddNzbLink(String nzbLink)

View file

@ -5,6 +5,7 @@ using RDNET;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
using Download = RdtClient.Data.Models.Data.Download; using Download = RdtClient.Data.Models.Data.Download;
using Torrent = RDNET.Torrent; using Torrent = RDNET.Torrent;
@ -128,20 +129,36 @@ public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHtt
public async Task<String> AddTorrentMagnet(String magnetLink) public async Task<String> 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<String> AddTorrentFile(Byte[] bytes) public async Task<String> 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<String> AddNzbLink(String nzbLink) public Task<String> AddNzbLink(String nzbLink)

View file

@ -1,10 +1,10 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MonoTorrent;
using Newtonsoft.Json; using Newtonsoft.Json;
using RdtClient.Data.Enums; using RdtClient.Data.Enums;
using RdtClient.Data.Models.DebridClient; using RdtClient.Data.Models.DebridClient;
using RdtClient.Data.Models.Data; using RdtClient.Data.Models.Data;
using RdtClient.Data.Models.Internal;
using RdtClient.Service.Helpers; using RdtClient.Service.Helpers;
using TorBoxNET; using TorBoxNET;
using Torrent = RdtClient.Data.Models.Data.Torrent; using Torrent = RdtClient.Data.Models.Data.Torrent;
@ -25,10 +25,8 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
throw new("TorBox API Key not set in the settings"); 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, 1);
var torBoxNetClient = new TorBoxNetClient(null, httpClient, 5);
torBoxNetClient.UseApiAuthentication(apiKey); torBoxNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results // Get the server time to fix up the timezones on results
@ -195,52 +193,90 @@ public class TorBoxDebridClient(ILogger<TorBoxDebridClient> logger, IHttpClientF
}; };
} }
private async Task<String> HandleAddTorrentErrors(Func<Boolean, Task<String>> 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<String> HandleAddUsenetErrors(Func<Boolean, Task<String>> 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<String> AddTorrentMagnet(String magnetLink) public async Task<String> AddTorrentMagnet(String magnetLink)
{ {
var user = await GetClient().User.GetAsync(true); return await HandleAddTorrentErrors(async asQueued =>
var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3);
if (result.Error == "ACTIVE_LIMIT")
{ {
var magnetLinkInfo = MagnetLink.Parse(magnetLink); var user = await GetClient().User.GetAsync(true);
var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued);
return magnetLinkInfo.InfoHashes.V1!.ToHex().ToLowerInvariant(); return result.Data!.Hash!;
} });
return result.Data!.Hash!;
} }
public async Task<String> AddTorrentFile(Byte[] bytes) public async Task<String> AddTorrentFile(Byte[] bytes)
{ {
var user = await GetClient().User.GetAsync(true); return await HandleAddTorrentErrors(async asQueued =>
var result = await GetClient().Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3);
if (result.Error == "ACTIVE_LIMIT")
{ {
using var stream = new MemoryStream(bytes); var user = await GetClient().User.GetAsync(true);
var result = await GetClient().Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, as_queued: asQueued);
var torrent = await MonoTorrent.Torrent.LoadAsync(stream); return result.Data!.Hash!;
});
return torrent.InfoHashes.V1!.ToHex().ToLowerInvariant();
}
return result.Data!.Hash!;
} }
public async Task<String> AddNzbLink(String nzbLink) public async Task<String> AddNzbLink(String nzbLink)
{ {
var result = await GetClient().Usenet.AddLinkAsync(nzbLink); return await HandleAddUsenetErrors(async asQueued =>
{
return result.Data!.Hash!; var result = await GetClient().Usenet.AddLinkAsync(nzbLink, as_queued: asQueued);
return result.Data!.Hash!;
});
} }
public virtual async Task<String> AddNzbFile(Byte[] bytes, String? name) public virtual async Task<String> AddNzbFile(Byte[] bytes, String? name)
{ {
var result = await GetClient().Usenet.AddFileAsync(bytes, name: name); return await HandleAddUsenetErrors(async asQueued =>
{
return result.Data!.Hash!; var result = await GetClient().Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued);
return result.Data!.Hash!;
});
} }
public async Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash) public async Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)

View file

@ -84,4 +84,9 @@ public class RemoteService(IHubContext<RdtHub> hub, Torrents torrents)
{ {
await hub.Clients.All.SendCoreAsync("diskSpaceStatus", [status]); await hub.Clients.All.SendCoreAsync("diskSpaceStatus", [status]);
} }
public async Task UpdateRateLimitStatus(RateLimitStatus status)
{
await hub.Clients.All.SendCoreAsync("rateLimitStatus", [status]);
}
} }

View file

@ -11,18 +11,11 @@ using RdtClient.Service.Services.Downloaders;
namespace RdtClient.Service.Services; namespace RdtClient.Service.Services;
public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Downloads downloads) public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Downloads downloads, RemoteService remoteService, IHttpClientFactory httpClientFactory)
{ {
public static readonly ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients = new(); public static readonly ConcurrentDictionary<Guid, DownloadClient> ActiveDownloadClients = new();
public static readonly ConcurrentDictionary<Guid, UnpackClient> ActiveUnpackClients = new(); public static readonly ConcurrentDictionary<Guid, UnpackClient> 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) public static (Int64 Speed, Int64 BytesTotal, Int64 BytesDone) GetStats(Guid downloadId)
{ {
if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient)) if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient))
@ -38,6 +31,10 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
return (0, 0, 0); return (0, 0, 0);
} }
public static Boolean IsPausedForLowDiskSpace { get; set; }
public static DateTimeOffset NextDequeueTime { get; private set; } = DateTimeOffset.MinValue;
public async Task Initialize() public async Task Initialize()
{ {
Log("Initializing TorrentRunner"); Log("Initializing TorrentRunner");
@ -127,7 +124,10 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
{ {
Log("Updating Aria2 status"); 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(); var allDownloads = await aria2NetClient.TellAllAsync();
@ -349,27 +349,51 @@ public class TorrentRunner(ILogger<TorrentRunner> logger, Torrents torrents, Dow
if (torrentsToAddToProvider.Count != 0) if (torrentsToAddToProvider.Count != 0)
{ {
var downloadingTorrentsCount = allTorrents.Count(m => m.RdStatus is not (TorrentStatus.Queued or TorrentStatus.Finished or TorrentStatus.Error)); if (DateTimeOffset.Now < NextDequeueTime)
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))
{ {
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); try
logger.LogWarning(ex, "Could not dequeue torrent {torrentId}", torrent.TorrentId); {
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);
}
} }
} }
} }
@ -725,6 +749,19 @@ public class TorrentRunner(ILogger<TorrentRunner> 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) private void Log(String message, Download? download, Torrent? torrent)
{ {
if (download != null) if (download != null)

View file

@ -14,7 +14,7 @@ namespace RdtClient.Web.Controllers;
[ApiController] [ApiController]
[Route("api/v2")] [Route("api/v2")]
[Route("qbittorrent/api/v2")] [Route("qbittorrent/api/v2")]
public class QBittorrentController(ILogger<QBittorrentController> logger, QBittorrent qBittorrent) : Controller public class QBittorrentController(ILogger<QBittorrentController> logger, QBittorrent qBittorrent, IHttpClientFactory httpClientFactory) : Controller
{ {
[AllowAnonymous] [AllowAnonymous]
[Route("/version/api")] [Route("/version/api")]
@ -348,7 +348,7 @@ public class QBittorrentController(ILogger<QBittorrentController> logger, QBitto
} }
else if (url.StartsWith("http")) else if (url.StartsWith("http"))
{ {
var httpClient = new HttpClient(); var httpClient = httpClientFactory.CreateClient();
var result = await httpClient.GetByteArrayAsync(url); var result = await httpClient.GetByteArrayAsync(url);
await qBittorrent.TorrentsAddFile(result, request.Category, null); await qBittorrent.TorrentsAddFile(result, request.Category, null);
} }

View file

@ -183,6 +183,28 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
return Ok(status); return Ok(status);
} }
[HttpGet]
[Route("RateLimitStatus")]
public ActionResult<RateLimitStatus> 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
});
}
/// <summary> /// <summary>
/// Used for debugging only. Force a tick. /// Used for debugging only. Force a tick.
/// </summary> /// </summary>