rdt-client/server/RdtClient.Service/Services/DebridClients/PremiumizeDebridClient.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

366 lines
11 KiB
C#

using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
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;
namespace RdtClient.Service.Services.DebridClients;
public class PremiumizeDebridClient(ILogger<PremiumizeDebridClient> logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter) : IDebridClient
{
private PremiumizeNETClient GetClient()
{
try
{
var apiKey = Settings.Get.Provider.ApiKey;
if (String.IsNullOrWhiteSpace(apiKey))
{
throw new("Premiumize API Key not set in the settings");
}
var httpClient = httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var premiumizeNetClient = new PremiumizeNETClient(apiKey, httpClient);
return premiumizeNetClient;
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}");
throw;
}
catch (TaskCanceledException ex)
{
logger.LogError(ex, $"The connection to Premiumize has timed out: {ex.Message}");
throw;
}
}
private static DebridClientTorrent Map(Transfer transfer)
{
return new()
{
Id = transfer.Id,
Filename = transfer.Name,
OriginalFilename = transfer.Name,
Hash = transfer.Src,
Bytes = 0,
OriginalBytes = 0,
Host = null,
Split = 0,
Progress = (Int64) ((transfer.Progress ?? 1.0) * 100.0),
Status = transfer.Status,
Message = transfer.Message,
StatusCode = 0,
Added = null,
Files = [],
Links =
[
transfer.FolderId
],
Ended = null,
Speed = 0,
Seeders = 0
};
}
public async Task<IList<DebridClientTorrent>> GetDownloads()
{
var results = await GetClient().Transfers.ListAsync();
return results.Select(Map).ToList();
}
public async Task<DebridClientUser> GetUser()
{
var user = await GetClient().Account.InfoAsync() ?? throw new("Unable to get user");
return new()
{
Username = user.CustomerId.ToString(),
Expiration = user.PremiumUntil > 0 ? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(user.PremiumUntil.Value) : null
};
}
public async Task<String> AddTorrentMagnet(String magnetLink)
{
try
{
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));
}
}
public async Task<String> AddTorrentFile(Byte[] bytes)
{
try
{
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));
}
}
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 Task<Int32?> SelectFiles(Torrent torrent)
{
// torrent.Files is not populated when this function is called
// by returning 1, we ensure no logic for "all files excluded" is followed
return Task.FromResult<Int32?>(1);
}
public async Task Delete(Torrent torrent)
{
await GetClient().Transfers.DeleteAsync(torrent.RdId);
}
public Task<String> Unrestrict(Torrent torrent, String link)
{
return Task.FromResult(link);
}
public async Task<Torrent> UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent)
{
try
{
if (torrent.RdId == null)
{
return torrent;
}
torrentClientTorrent ??= await GetInfo(torrent.RdId);
if (!String.IsNullOrWhiteSpace(torrentClientTorrent.Filename))
{
torrent.RdName = torrentClientTorrent.Filename;
}
if (torrentClientTorrent.Bytes > 0)
{
torrent.RdSize = torrentClientTorrent.Bytes;
}
if (torrentClientTorrent.Files != null)
{
torrent.RdFiles = JsonConvert.SerializeObject(torrentClientTorrent.Files);
}
torrent.ClientKind = Provider.Premiumize;
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
{
"waiting" => TorrentStatus.Processing,
"queued" => TorrentStatus.Processing,
"running" => TorrentStatus.Downloading,
"seeding" => TorrentStatus.Finished,
"finished" => TorrentStatus.Finished,
_ => TorrentStatus.Error
};
}
catch (PremiumizeException ex)
{
if (ex.Message == "MAGNET_INVALID_ID")
{
torrent.RdStatusRaw = "deleted";
}
else
{
throw;
}
}
return torrent;
}
public async Task<IList<DownloadInfo>?> GetDownloadInfos(Torrent torrent)
{
if (torrent.RdId == null)
{
return null;
}
var transfers = await GetClient().Transfers.ListAsync();
Log($"Found {transfers.Count} transfers", torrent);
var transfer = transfers.FirstOrDefault(m => m.Id == torrent.RdId) ?? throw new($"Transfer {torrent.RdId} not found!");
Log($"Found transfer {transfer.Name} ({transfer.Id})", torrent);
var downloadInfos = await GetAllDownloadInfos(torrent, transfer.FolderId);
if (!String.IsNullOrWhiteSpace(transfer.FileId))
{
var file = await GetClient().Items.DetailsAsync(transfer.FileId);
Log($"Found {transfer.FileId}", torrent);
if (String.IsNullOrWhiteSpace(file.Link))
{
Log($"File {file.Name} ({file.Id}) does not contain a link", torrent);
}
downloadInfos.Add(new()
{
RestrictedLink = file.Link,
FileName = file.Name
});
}
foreach (var info in downloadInfos)
{
Log($"Found {info.RestrictedLink}", torrent);
}
return downloadInfos;
}
/// <inheritdoc />
public Task<String> GetFileName(Download download)
{
// FileName is set in GetDownlaadInfos
Debug.Assert(download.FileName != null);
return Task.FromResult(download.FileName);
}
private async Task<DebridClientTorrent> GetInfo(String id)
{
var results = await GetClient().Transfers.ListAsync();
var result = results.FirstOrDefault(m => m.Id == id) ?? throw new($"Unable to find transfer with ID {id}");
return Map(result);
}
private async Task<List<DownloadInfo>> GetAllDownloadInfos(Torrent torrent, String folderId)
{
if (String.IsNullOrWhiteSpace(folderId))
{
return [];
}
var folder = await GetClient().Folder.ListAsync(folderId);
if (folder.Content == null)
{
Log($"Found no items in folder {folder.Name} ({folderId})", torrent);
return [];
}
Log($"Found {folder.Content.Count} items in folder {folder.Name} ({folderId})", torrent);
var downloadInfos = new List<DownloadInfo>();
foreach (var item in folder.Content)
{
if (item.Type == "file")
{
if (String.IsNullOrWhiteSpace(item.Link))
{
Log($"Found item {item.Name} in folder {folder.Name} ({folderId}), but has no link", torrent);
continue;
}
if (!fileFilter.IsDownloadable(torrent, item.Name, item.Size))
{
continue;
}
Log($"Found item {item.Name} in folder {folder.Name} ({folderId})", torrent);
downloadInfos.Add(new()
{
RestrictedLink = item.Link,
FileName = item.Name
});
}
else if (item.Type == "folder")
{
// Folders don't have Size, use maximum Int64 so it always passes min size check
if (!fileFilter.IsDownloadable(torrent, item.Name, Int64.MaxValue))
{
continue;
}
Log($"Found subfolder {item.Name} in {folder.Name} ({folderId}), searching subfolder for files", torrent);
var subDownloadLinks = await GetAllDownloadInfos(torrent, item.Id);
downloadInfos.AddRange(subDownloadLinks);
}
else
{
Log($"Found item {item.Name} with unknown type {item.Type} in folder {folder.Name} ({folderId})", torrent);
}
}
return downloadInfos;
}
private void Log(String message, Torrent? torrent = null)
{
if (torrent != null)
{
message = $"{message} {torrent.ToLog()}";
}
logger.LogDebug(message);
}
}