using System.Diagnostics; using Microsoft.Extensions.Logging; 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 TorBoxNET; using Torrent = RdtClient.Data.Models.Data.Torrent; namespace RdtClient.Service.Services.DebridClients; public class TorBoxDebridClient( ILogger logger, IHttpClientFactory httpClientFactory, IDownloadableFileFilter fileFilter, IRateLimitCoordinator coordinator, ISettings settings) : IDebridClient { private const String TorBoxApiHost = "api.torbox.app"; private TimeSpan? _offset; public async Task> GetDownloads() { var results = new List(); var currentTorrents = await GetCurrentTorrents(); if (currentTorrents != null) { results.AddRange(currentTorrents.Select(Map)); } var queuedTorrents = await GetQueuedTorrents(); if (queuedTorrents != null) { results.AddRange(queuedTorrents.Select(Map)); } var currentNzbs = await GetCurrentUsenet(); if (currentNzbs != null) { results.AddRange(currentNzbs.Select(Map)); } var queuedNzbs = await GetQueuedUsenet(); if (queuedNzbs != null) { results.AddRange(queuedNzbs.Select(Map)); } return results; } public async Task GetUser() { var user = await HandleErrors(() => GetClient().User.GetAsync(false)); return new() { Username = user.Data!.Email, Expiration = user.Data!.Plan != 0 ? user.Data!.PremiumExpiresAt!.Value : null }; } public async Task AddTorrentMagnet(String magnetLink) { return await HandleAddTorrentErrors(async asQueued => { var user = await GetClient().User.GetAsync(true); var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW) .Torrents.AddMagnetAsync(magnetLink, user.Data?.Settings?.SeedTorrents ?? 3, allowZip: Settings.Get.Provider.PreferZippedDownloads, as_queued: asQueued); return result.Data?.TorrentId?.ToString() ?? throw new InvalidOperationException("TorBox API did not return torrent ID."); }); } public async Task AddTorrentFile(Byte[] bytes) { return await HandleAddTorrentErrors(async asQueued => { var user = await GetClient().User.GetAsync(true); var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW) .Torrents.AddFileAsync(bytes, user.Data?.Settings?.SeedTorrents ?? 3, allowZip: Settings.Get.Provider.PreferZippedDownloads, as_queued: asQueued); return result.Data?.TorrentId?.ToString() ?? throw new InvalidOperationException("TorBox API did not return torrent ID"); }); } public async Task AddNzbLink(String nzbLink) { return await HandleAddUsenetErrors(async asQueued => { var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddLinkAsync(nzbLink, as_queued: asQueued); return result.Data!.Hash!; }); } public virtual async Task AddNzbFile(Byte[] bytes, String? name) { return await HandleAddUsenetErrors(async asQueued => { var result = await GetClient(DiConfig.TORBOX_CLIENT_SLOW).Usenet.AddFileAsync(bytes, name: name, as_queued: asQueued); return result.Data!.Hash!; }); } public async Task> GetAvailableFiles(String hash) { var availability = await GetTorrentAvailability(hash); if (availability.Data != null && availability.Data.Count > 0) { return (availability.Data[0]?.Files ?? []).Select(file => new DebridClientAvailableFile { Filename = file.Name, Filesize = file.Size }) .ToList(); } var usenetAvailability = await GetUsenetAvailability(hash); if (usenetAvailability.Data != null && usenetAvailability.Data.Count > 0) { return (usenetAvailability.Data[0]?.Files ?? []).Select(file => new DebridClientAvailableFile { Filename = file.Name, Filesize = file.Size }) .ToList(); } return []; } /// public Task SelectFiles(Torrent torrent) { return Task.FromResult(torrent.Files.Count); } public async Task Delete(Torrent torrent) { if (torrent.RdId == null) { return; } await HandleErrors(async () => { if (torrent.Type == DownloadType.Nzb) { await GetClient().Usenet.ControlAsync(torrent.RdId, "delete"); } else { await GetClient().Torrents.ControlByIdAsync(Int32.Parse(torrent.RdId), "delete"); } }); } public async Task Unrestrict(Torrent torrent, String link) { if (String.IsNullOrWhiteSpace(link)) { throw new ArgumentException("Link cannot be null or empty", nameof(link)); } var segments = link.Split('/'); if (segments is not [_, _, _, _, var torrentIdStr, var fileIdStrOrZip]) { throw new ArgumentException($"Invalid link format: {link}", nameof(link)); } var zipped = fileIdStrOrZip == "zip"; var fileIdStr = zipped ? "0" : fileIdStrOrZip; if (!Int32.TryParse(torrentIdStr, out var torrentId)) { throw new ArgumentException($"Invalid torrent ID in link segment 4: {torrentIdStr}", nameof(link)); } if (!Int32.TryParse(fileIdStr, out var fileId)) { throw new ArgumentException($"Invalid file ID in link segment 5: {fileId}", nameof(link)); } Response result; if (torrent.Type == DownloadType.Nzb) { result = await HandleErrors(() => GetClient().Usenet.RequestDownloadAsync(torrentId, fileId, zipped)); } else { result = await HandleErrors(() => GetClient().Torrents.RequestDownloadAsync(torrentId, fileId, zipped)); } if (result.Error != null) { throw new($"Unrestrict returned an invalid download: {result.Error}"); } return result.Data!; } public async Task UpdateData(Torrent torrent, DebridClientTorrent? torrentClientTorrent) { try { if (torrent.RdId == null) { return torrent; } var rdTorrent = torrentClientTorrent ?? await GetInfo(torrent.RdId, torrent.Type) ?? throw new($"Resource not found"); if (!String.IsNullOrWhiteSpace(rdTorrent.Filename)) { torrent.RdName = rdTorrent.Filename; } if (!String.IsNullOrWhiteSpace(rdTorrent.OriginalFilename)) { torrent.RdName = rdTorrent.OriginalFilename; } if (rdTorrent.Bytes > 0) { torrent.RdSize = rdTorrent.Bytes; } else if (rdTorrent.OriginalBytes > 0) { torrent.RdSize = rdTorrent.OriginalBytes; } if (rdTorrent.Files != null) { torrent.RdFiles = JsonConvert.SerializeObject(rdTorrent.Files); } torrent.ClientKind = Provider.TorBox; torrent.RdHost = rdTorrent.Host; torrent.RdSplit = rdTorrent.Split; torrent.RdProgress = rdTorrent.Progress; torrent.RdAdded = rdTorrent.Added; torrent.RdEnded = rdTorrent.Ended; torrent.RdSpeed = rdTorrent.Speed; torrent.RdSeeders = rdTorrent.Seeders; torrent.RdStatusRaw = rdTorrent.Status; if (rdTorrent.Host == "True") { torrent.RdStatus = TorrentStatus.Finished; } else { logger.LogTrace("Updating status for {TorrentName} from {OldStatus} to {NewStatus}", torrent.RdName, torrent.RdStatus, rdTorrent.Status); torrent.RdStatus = rdTorrent.Status switch { "allocating" => TorrentStatus.Processing, "metaDL" => TorrentStatus.Processing, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("queued", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Processing, "completed" => TorrentStatus.Downloading, "processing" => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("paused", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("stalled", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("downloading", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("checking", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("waiting", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("direct unpack", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("repair", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("verifying", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Downloading, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("uploading", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Uploading, "cached" => TorrentStatus.Finished, "missing" => TorrentStatus.Error, // NZB missing parts "error" => TorrentStatus.Error, _ when rdTorrent.Status != null && rdTorrent.Status.StartsWith("failed", StringComparison.OrdinalIgnoreCase) => TorrentStatus.Error, _ => LogUnmappedStatus(rdTorrent.Status, torrent) }; } } catch (Exception ex) { if (ex.Message == "Resource not found") { torrent.RdStatusRaw = "deleted"; } else { throw; } } return torrent; } public async Task?> GetDownloadInfos(Torrent torrent) { Int32? id; if (torrent.Type == DownloadType.Nzb) { if (torrent.RdId == null) { return null; } var usenets = await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true)); var usenet = usenets?.FirstOrDefault(m => m.Hash == torrent.RdId); id = (Int32?)usenet?.Id; } else { if (torrent.RdId == null) { return null; } id = Int32.Parse(torrent.RdId); } if (id == null) { return null; } var downloadableFiles = torrent.Files.Where(file => fileFilter.IsDownloadable(torrent, file.Path, file.Bytes)).ToList(); if (downloadableFiles.Count == torrent.Files.Count && torrent.DownloadClient != Data.Enums.DownloadClient.Symlink && settings.Current.Provider.PreferZippedDownloads) { logger.LogDebug("Downloading files from TorBox as a zip."); return [ new() { RestrictedLink = $"https://torbox.app/fakedl/{id}/zip", FileName = $"{torrent.RdName}.zip" } ]; } logger.LogDebug("Downloading files from TorBox individually."); return downloadableFiles.Select(file => new DownloadInfo { RestrictedLink = $"https://torbox.app/fakedl/{id}/{file.Id}", FileName = Path.GetFileName(file.Path) }) .ToList(); } /// public Task GetFileName(Download download) { // FileName is set in GetDownlaadInfos Debug.Assert(download.FileName != null); return Task.FromResult(download.FileName); } protected virtual ITorBoxNetClient GetClient(String clientId = DiConfig.TORBOX_CLIENT) { try { var apiKey = settings.Current.Provider.ApiKey; if (String.IsNullOrWhiteSpace(apiKey)) { throw new("TorBox API Key not set in the settings"); } var httpClient = httpClientFactory.CreateClient(clientId); var torBoxNetClient = new TorBoxNetClient(null, httpClient, retryCount: 5); torBoxNetClient.UseApiAuthentication(apiKey); // Get the server time to fix up the timezones on results if (_offset == null) { var serverTime = DateTimeOffset.UtcNow; _offset = serverTime.Offset; } return torBoxNetClient; } catch (AggregateException ae) { foreach (var inner in ae.InnerExceptions) { logger.LogError(inner, $"The connection to TorBox has failed: {inner.Message}"); } throw; } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); throw; } catch (TaskCanceledException ex) { logger.LogError(ex, $"The connection to TorBox has timed out: {ex.Message}"); throw; } } protected virtual async Task?> GetCurrentTorrents() { return await HandleErrors(() => GetClient().Torrents.GetCurrentAsync(true)); } protected virtual async Task?> GetQueuedTorrents() { return await HandleErrors(() => GetClient().Torrents.GetQueuedAsync(true)); } protected virtual async Task?> GetCurrentUsenet() { return await HandleErrors(() => GetClient().Usenet.GetCurrentAsync(true)); } protected virtual async Task?> GetQueuedUsenet() { return await HandleErrors(() => GetClient().Usenet.GetQueuedAsync(true)); } protected virtual async Task>> GetTorrentAvailability(String hash) { return await HandleErrors(() => GetClient().Torrents.GetAvailabilityAsync(hash, true)); } protected virtual async Task>> GetUsenetAvailability(String hash) { return await HandleErrors(() => GetClient().Usenet.GetAvailabilityAsync(hash, true)); } private DebridClientTorrent Map(TorrentInfoResult torrent) { return new() { Id = torrent.Id.ToString(), Filename = torrent.Name, OriginalFilename = torrent.Name, Hash = torrent.Hash, Bytes = torrent.Size, OriginalBytes = torrent.Size, Host = torrent.DownloadPresent.ToString(), Split = 0, Progress = (Int64)(torrent.Progress * 100.0), Status = torrent.DownloadState, Type = DownloadType.Torrent, Added = ChangeTimeZone(torrent.CreatedAt)!.Value, Files = (torrent.Files ?? []).Select(m => new DebridClientFile { Path = String.Join("/", m.Name.Split('/').Skip(1)), Bytes = m.Size, Id = m.Id, Selected = true }) .ToList(), Links = [], Ended = ChangeTimeZone(torrent.UpdatedAt), Speed = torrent.DownloadSpeed, Seeders = torrent.Seeds }; } private DebridClientTorrent Map(UsenetInfoResult usenet) { return new() { Id = usenet.Hash, Filename = usenet.Name, OriginalFilename = usenet.Name, Hash = usenet.Hash, Bytes = usenet.Size, OriginalBytes = usenet.Size, Host = usenet.DownloadPresent.ToString(), Split = 0, Progress = (Int64)(usenet.Progress * 100.0), Status = usenet.DownloadState, Type = DownloadType.Nzb, Added = ChangeTimeZone(usenet.CreatedAt)!.Value, Files = (usenet.Files ?? []).Select(m => new DebridClientFile { Path = String.Join("/", m.Name.Split('/').Skip(1)), Bytes = m.Size, Id = m.Id, Selected = true }) .ToList(), Links = [], Ended = ChangeTimeZone(usenet.UpdatedAt), Speed = usenet.DownloadSpeed, Seeders = 0 }; } private async Task HandleErrors(Func> action) { try { return await action(); } catch (RateLimitException) { throw; } catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) { throw rateLimitException; } catch (TorBoxException ex) when (IsRateLimit(ex)) { coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) { coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } } private async Task HandleErrors(Func action) { try { await action(); } catch (RateLimitException) { throw; } catch (Exception ex) when (ex.InnerException is RateLimitException rateLimitException) { throw rateLimitException; } catch (TorBoxException ex) when (IsRateLimit(ex)) { coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } catch (Exception ex) when (ex.Message.Contains("slow_down", StringComparison.OrdinalIgnoreCase)) { coordinator.UpdateCooldown(TorBoxApiHost, TimeSpan.FromMinutes(2)); throw new RateLimitException(ex.Message, TimeSpan.FromMinutes(2)); } } private async Task HandleAddTorrentErrors(Func> action) { return await HandleErrors(() => action(false)); } private static Boolean IsRateLimit(TorBoxException exception) { return exception.Error.Equals("RATE_LIMIT", StringComparison.OrdinalIgnoreCase) || exception.Error.Equals("ACTIVE_LIMIT", StringComparison.OrdinalIgnoreCase); } private async Task HandleAddUsenetErrors(Func> action) { return await HandleErrors(() => action(false)); } private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) { if (_offset == null) { return dateTimeOffset; } return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value); } private async Task GetInfo(String id, DownloadType type) { return await HandleErrors(async () => { if (type == DownloadType.Nzb) { var usenet = await GetClient().Usenet.GetHashInfoAsync(id, true); if (usenet != null) { return Map(usenet); } } else { var result = await GetClient().Torrents.GetIdInfoAsync(Int32.Parse(id), true); if (result != null) { return Map(result); } } return null; }); } public static void MoveHashDirContents(String extractPath, Torrent torrent) { var hashDir = Path.Combine(extractPath, torrent.Hash); if (Directory.Exists(hashDir)) { var innerFolder = Directory.GetDirectories(hashDir)[0]; var moveDir = extractPath; if (!extractPath.EndsWith(torrent.RdName!)) { moveDir = hashDir; } foreach (var file in Directory.GetFiles(innerFolder)) { var destFile = Path.Combine(moveDir, Path.GetFileName(file)); File.Move(file, destFile); } foreach (var dir in Directory.GetDirectories(innerFolder)) { var destDir = Path.Combine(moveDir, Path.GetFileName(dir)); Directory.Move(dir, destDir); } if (!extractPath.Contains(torrent.RdName!)) { Directory.Delete(innerFolder, true); } else { Directory.Delete(hashDir, true); } } } private TorrentStatus LogUnmappedStatus(String? status, Torrent torrent) { if (!String.IsNullOrWhiteSpace(status)) { logger.LogInformation("TorBoxDebridClient encountered an unmapped status: {Status} for torrent {TorrentName} with previous status {PreviousStatus}", status, torrent.RdName, torrent.RdStatus); } // Once TorBox has acknowledged the torrent, an unknown provider-side status should not fall all the way back to // the local queue state. Treat it as provider-side processing so the UI does not incorrectly show // "Not Yet Added to Provider" while TorBox is already handling it. return torrent.RdStatus is null or TorrentStatus.Queued ? TorrentStatus.Processing : torrent.RdStatus.Value; } private void Log(String message, Torrent? torrent = null) { if (torrent != null) { message = $"{message} {torrent.ToLog()}"; } logger.LogDebug(message); } }