rdt-client/server/RdtClient.Service/Services/DebridClients/RealDebridDebridClient.cs
omgbeez a023d33d90 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.
2026-02-20 09:47:13 -05:00

418 lines
13 KiB
C#

using System.Web;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
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;
namespace RdtClient.Service.Services.DebridClients;
public class RealDebridDebridClient(ILogger<RealDebridDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
private TimeSpan? _offset;
private RdNetClient GetClient()
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
throw new("Real-Debrid API Key not set in the settings");
}
var httpClient = httpClientFactory.CreateClient(DiConfig.RD_CLIENT);
httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout);
var rdtNetClient = new RdNetClient(null, httpClient, 5, Settings.Get.Provider.ApiHostname);
rdtNetClient.UseApiAuthentication(apiKey);
// Get the server time to fix up the timezones on results
if (_offset == null)
{
var serverTime = rdtNetClient.Api.GetIsoTimeAsync().Result;
_offset = serverTime.Offset;
}
return rdtNetClient;
}
catch (AggregateException ae)
{
foreach (var inner in ae.InnerExceptions)
{
logger.LogError(inner, $"The connection to RealDebrid has failed: {inner.Message}");
}
throw;
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}");
throw;
}
catch (TaskCanceledException ex)
{
logger.LogError(ex, $"The connection to RealDebrid has timed out: {ex.Message}");
throw;
}
}
private DebridClientTorrent Map(Torrent torrent)
{
return new()
{
Id = torrent.Id,
Filename = torrent.Filename,
OriginalFilename = torrent.OriginalFilename,
Hash = torrent.Hash,
Bytes = torrent.Bytes,
OriginalBytes = torrent.OriginalBytes,
Host = torrent.Host,
Split = torrent.Split,
Progress = torrent.Progress,
Status = torrent.Status,
Added = ChangeTimeZone(torrent.Added)!.Value,
Files = (torrent.Files ?? []).Select(m => new DebridClientFile
{
Path = m.Path,
Bytes = m.Bytes,
Id = m.Id,
Selected = m.Selected
}).ToList(),
Links = torrent.Links,
Ended = ChangeTimeZone(torrent.Ended),
Speed = torrent.Speed,
Seeders = torrent.Seeders,
};
}
public async Task<IList<DebridClientTorrent>> GetDownloads()
{
var offset = 0;
var results = new List<Torrent>();
while (true)
{
var pagedResults = await GetClient().Torrents.GetAsync(offset, 5000);
results.AddRange(pagedResults);
if (pagedResults.Count == 0)
{
break;
}
offset += 5000;
}
return results.Select(Map).ToList();
}
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().User.GetAsync();
return new()
{
Username = user.Username,
Expiration = user.Premium > 0 ? user.Expiration : null
};
}
public async Task<String> AddTorrentMagnet(String magnetLink)
{
try
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
var result = await GetClient().Torrents.AddMagnetAsync(magnetLink, timeoutCancellationToken.Token);
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)
{
try
{
var timeoutCancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(Settings.Get.Provider.Timeout));
var result = await GetClient().Torrents.AddFileAsync(bytes, timeoutCancellationToken.Token);
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)
{
throw new NotSupportedException();
}
public Task<String> AddNzbFile(Byte[] bytes, String? name)
{
throw new NotSupportedException();
}
public Task<IList<DebridClientAvailableFile>> GetAvailableFiles(String hash)
{
return Task.FromResult<IList<DebridClientAvailableFile>>([]);
}
/// <inheritdoc />
public async Task<Int32?> SelectFiles(Data.Models.Data.Torrent torrent)
{
List<DebridClientFile> files;
Log("Seleting files", torrent);
if (torrent.DownloadAction == TorrentDownloadAction.DownloadManual)
{
Log("Selecting manual selected files", torrent);
files = torrent.Files.Where(m => torrent.ManualFiles.Any(f => m.Path.EndsWith(f))).ToList();
}
else
{
Log("Selecting files", torrent);
files = [.. torrent.Files];
}
files = files.Where(f => fileFilter.IsDownloadable(torrent, f.Path, f.Bytes)).ToList();
Log($"Selecting {files.Count}/{torrent.Files.Count} files", torrent);
var fileIds = files.Select(m => m.Id.ToString()).ToArray();
if (fileIds.Length == 0)
{
return 0;
}
await GetClient().Torrents.SelectFilesAsync(torrent.RdId!, [.. fileIds]);
return fileIds.Length;
}
public async Task Delete(Data.Models.Data.Torrent torrent)
{
await GetClient().Torrents.DeleteAsync(torrent.RdId!);
}
public async Task<String> Unrestrict(Data.Models.Data.Torrent torrent, String link)
{
var result = await GetClient().Unrestrict.LinkAsync(link);
if (result.Download == null)
{
throw new($"Unrestrict returned an invalid download");
}
return result.Download;
}
public async Task<Data.Models.Data.Torrent> UpdateData(Data.Models.Data.Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
if (torrent.RdId == null)
{
return torrent;
}
if (torrentClientTorrent == null || torrentClientTorrent.Ended == null || String.IsNullOrEmpty(torrentClientTorrent.Filename))
{
torrentClientTorrent = await GetInfo(torrent.RdId) ?? throw new($"Resource not found");
}
if (!String.IsNullOrWhiteSpace(torrentClientTorrent.Filename))
{
torrent.RdName = torrentClientTorrent.Filename;
}
if (!String.IsNullOrWhiteSpace(torrentClientTorrent.OriginalFilename))
{
torrent.RdName = torrentClientTorrent.OriginalFilename;
}
if (torrentClientTorrent.Bytes > 0)
{
torrent.RdSize = torrentClientTorrent.Bytes;
}
else if (torrentClientTorrent.OriginalBytes > 0)
{
torrent.RdSize = torrentClientTorrent.OriginalBytes;
}
if (torrentClientTorrent.Files != null && torrentClientTorrent.Files.Count > 0)
{
torrent.RdFiles = JsonConvert.SerializeObject(torrentClientTorrent.Files);
}
torrent.ClientKind = Provider.RealDebrid;
torrent.RdHost = torrentClientTorrent.Host;
torrent.RdSplit = torrentClientTorrent.Split;
torrent.RdProgress = torrentClientTorrent.Progress;
torrent.RdAdded = torrentClientTorrent.Added;
torrent.RdEnded = torrentClientTorrent.Ended;
torrent.RdSpeed = torrentClientTorrent.Speed;
torrent.RdSeeders = torrentClientTorrent.Seeders;
torrent.RdStatusRaw = torrentClientTorrent.Status;
torrent.RdStatus = torrentClientTorrent.Status switch
{
"magnet_error" => TorrentStatus.Error,
"magnet_conversion" => TorrentStatus.Processing,
"waiting_files_selection" => TorrentStatus.WaitingForFileSelection,
"queued" => TorrentStatus.Downloading,
"downloading" => TorrentStatus.Downloading,
"downloaded" => TorrentStatus.Finished,
"error" => TorrentStatus.Error,
"virus" => TorrentStatus.Error,
"compressing" => TorrentStatus.Downloading,
"uploading" => TorrentStatus.Uploading,
"dead" => TorrentStatus.Error,
_ => TorrentStatus.Error
};
}
catch (Exception ex)
{
if (ex.Message == "Resource not found")
{
torrent.RdStatusRaw = "deleted";
}
else
{
throw;
}
}
return torrent;
}
public async Task<IList<DownloadInfo>?> GetDownloadInfos(Data.Models.Data.Torrent torrent)
{
if (torrent.RdId == null)
{
return null;
}
var rdTorrent = await GetInfo(torrent.RdId);
if (rdTorrent.Links == null)
{
return null;
}
var downloadLinks = rdTorrent.Links
.Where(m => !String.IsNullOrWhiteSpace(m))
.Select(l => new DownloadInfo
{
RestrictedLink = l,
FileName = null
})
.ToList();
Log($"Found {downloadLinks.Count} links", torrent);
foreach (var link in downloadLinks)
{
Log($"{link}", torrent);
}
Log($"Torrent has {torrent.Files.Count(m => m.Selected)} selected files out of {torrent.Files.Count} files, found {downloadLinks.Count} links, torrent ended: {torrent.RdEnded}",
torrent);
// Check if all the links are set that have been selected
if (torrent.Files.Count(m => m.Selected) == downloadLinks.Count)
{
Log($"Matched {torrent.Files.Count(m => m.Selected)} selected files expected files to {downloadLinks.Count} found files", torrent);
return downloadLinks;
}
// Check if all all the links are set for manual selection
if (torrent.ManualFiles.Count == downloadLinks.Count)
{
Log($"Matched {torrent.ManualFiles.Count} manual files expected files to {downloadLinks.Count} found files", torrent);
return downloadLinks;
}
// If there is only 1 link, delay for 1 minute to see if more links pop up.
if (downloadLinks.Count == 1 && torrent.RdEnded.HasValue)
{
var expired = DateTime.UtcNow - torrent.RdEnded.Value.ToUniversalTime();
Log($"Waiting to see if more links appear, checked for {expired.TotalSeconds} seconds", torrent);
if (expired.TotalSeconds > 60.0)
{
Log($"Waited long enough", torrent);
return downloadLinks;
}
}
Log($"Did not find any suiteable download links", torrent);
return null;
}
/// <inheritdoc />
public Task<String> GetFileName(Download download)
{
if (String.IsNullOrWhiteSpace(download.Link))
{
return Task.FromResult("");
}
var uri = new Uri(download.Link);
return Task.FromResult(HttpUtility.UrlDecode(uri.Segments.Last()));
}
private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset)
{
if (_offset == null)
{
return dateTimeOffset;
}
return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value);
}
private async Task<DebridClientTorrent> GetInfo(String torrentId)
{
var result = await GetClient().Torrents.GetInfoAsync(torrentId);
return Map(result);
}
private void Log(String message, Data.Models.Data.Torrent? torrent = null)
{
if (torrent != null)
{
message = $"{message} {torrent.ToLog()}";
}
logger.LogDebug(message);
}
}