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.
This commit is contained in:
parent
6572ba752c
commit
a023d33d90
25 changed files with 688 additions and 132 deletions
4
client/src/app/models/rate-limit-status.model.ts
Normal file
4
client/src/app/models/rate-limit-status.model.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface RateLimitStatus {
|
||||
nextDequeueTime: Date | null;
|
||||
secondsRemaining: number;
|
||||
}
|
||||
|
|
@ -13,6 +13,13 @@
|
|||
<small>Last check: {{ diskSpaceStatus.lastCheckTime | date: 'short' }}</small>
|
||||
</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">
|
||||
<table class="table is-fullwidth is-hoverable">
|
||||
<thead>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Torrent[]> = new Subject();
|
||||
public diskSpaceStatus$: Subject<DiskSpaceStatus> = new Subject();
|
||||
public rateLimitStatus$: Subject<RateLimitStatus> = 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<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> {
|
||||
return this.http.post<void>(`${this.baseHref}Api/Torrents/UploadMagnet`, {
|
||||
magnetLink,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class RateLimitException(String message, TimeSpan retryAfter) : Exception(message)
|
||||
{
|
||||
public TimeSpan RetryAfter { get; } = retryAfter;
|
||||
}
|
||||
7
server/RdtClient.Data/Models/Internal/RateLimitStatus.cs
Normal file
7
server/RdtClient.Data/Models/Internal/RateLimitStatus.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace RdtClient.Data.Models.Internal;
|
||||
|
||||
public class RateLimitStatus
|
||||
{
|
||||
public DateTimeOffset? NextDequeueTime { get; set; }
|
||||
public Double SecondsRemaining { get; set; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
<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.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.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
|
|
|||
|
|
@ -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<ITorBoxNetClient>("GetClient").Returns(torBoxClientMock.Object);
|
||||
|
||||
usenetApiMock.Setup(m => m.AddFileAsync(bytes, -1, name, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Response<UsenetAddResult> { Data = new()
|
||||
{ Hash = "new-hash" } });
|
||||
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 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<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]
|
||||
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<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]
|
||||
public async Task UpdateData_LogsWarning_WhenTorBoxStatusIsUnmapped()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ using TorrentsService = RdtClient.Service.Services.Torrents;
|
|||
|
||||
namespace RdtClient.Service.Test.Services;
|
||||
|
||||
internal class Mocks
|
||||
class Mocks
|
||||
{
|
||||
public readonly Mock<IDownloads> DownloadsMock;
|
||||
public readonly Mock<IEnricher> EnricherMock;
|
||||
|
|
|
|||
|
|
@ -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<ProviderUpdater> logger, IServiceProvider s
|
|||
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var torrentService = scope.ServiceProvider.GetRequiredService<Torrents>();
|
||||
|
||||
var torrentRunner = scope.ServiceProvider.GetRequiredService<TorrentRunner>();
|
||||
logger.LogInformation("ProviderUpdater started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
|
|
@ -28,7 +29,7 @@ public class ProviderUpdater(ILogger<ProviderUpdater> 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<ProviderUpdater> 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<ProviderUpdater> 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);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ using Newtonsoft.Json;
|
|||
|
||||
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 = [];
|
||||
public static String? CurrentVersion { get; private set; }
|
||||
|
|
@ -79,13 +79,13 @@ public class UpdateChecker(ILogger<UpdateChecker> logger) : BackgroundService
|
|||
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();
|
||||
httpClient.DefaultRequestHeaders.UserAgent.Add(new("RdtClient", CurrentVersion));
|
||||
var response = await httpClient.GetStringAsync($"https://api.github.com{endpoint}", cancellationToken);
|
||||
|
||||
return JsonConvert.DeserializeObject<T>(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<T>(response);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RateLimitHandler>();
|
||||
|
||||
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("TorBox rate limit exceeded", delay);
|
||||
}
|
||||
|
||||
return new ValueTask<TimeSpan?>(delay);
|
||||
}
|
||||
|
||||
return new ValueTask<TimeSpan?>((TimeSpan?)null);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
server/RdtClient.Service/Helpers/RateLimitHandler.cs
Normal file
35
server/RdtClient.Service/Helpers/RateLimitHandler.cs
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
<PackageReference Include="MonoTorrent" Version="3.0.2" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<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="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
<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.Wrappers" Version="22.1.0" />
|
||||
<PackageReference Include="TorBox.NET" Version="1.6.0" />
|
||||
<PackageReference Include="TorBox.NET" Version="1.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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<AllDebridDebridClient> logger, IAllDe
|
|||
|
||||
public async Task<String> 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<String> 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<String> AddNzbLink(String nzbLink)
|
||||
|
|
|
|||
|
|
@ -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<DebridLinkClient> logger, IHttpClientFacto
|
|||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<PremiumizeDebridClient> logger, IHtt
|
|||
|
||||
public async Task<String> 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<String> 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<String> AddNzbLink(String nzbLink)
|
||||
|
|
|
|||
|
|
@ -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<RealDebridDebridClient> logger, IHtt
|
|||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<TorBoxDebridClient> 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<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)
|
||||
{
|
||||
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<String> 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<String> 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<String> 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<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
|
||||
|
|
|
|||
|
|
@ -79,4 +79,9 @@ public class RemoteService(IHubContext<RdtHub> hub, Torrents torrents)
|
|||
{
|
||||
await hub.Clients.All.SendCoreAsync("diskSpaceStatus", [status]);
|
||||
}
|
||||
|
||||
public async Task UpdateRateLimitStatus(RateLimitStatus status)
|
||||
{
|
||||
await hub.Clients.All.SendCoreAsync("rateLimitStatus", [status]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,11 @@ using RdtClient.Service.Services.Downloaders;
|
|||
|
||||
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, 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)
|
||||
{
|
||||
if (ActiveDownloadClients.TryGetValue(downloadId, out var downloadClient))
|
||||
|
|
@ -37,7 +30,11 @@ public class TorrentRunner(ILogger<TorrentRunner> 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<TorrentRunner> 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<TorrentRunner> 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<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)
|
||||
{
|
||||
if (download != null)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ namespace RdtClient.Web.Controllers;
|
|||
[ApiController]
|
||||
[Route("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]
|
||||
[Route("auth/login")]
|
||||
|
|
@ -338,7 +338,7 @@ public class QBittorrentController(ILogger<QBittorrentController> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,28 @@ public class TorrentsController(ILogger<TorrentsController> logger, Torrents tor
|
|||
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>
|
||||
/// Used for debugging only. Force a tick.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Reference in a new issue