diff --git a/server/RdtClient.Service/RdtClient.Service.csproj b/server/RdtClient.Service/RdtClient.Service.csproj index c736d2e..2c5daa6 100644 --- a/server/RdtClient.Service/RdtClient.Service.csproj +++ b/server/RdtClient.Service/RdtClient.Service.csproj @@ -22,6 +22,7 @@ + diff --git a/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs b/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs new file mode 100644 index 0000000..02c1d21 --- /dev/null +++ b/server/RdtClient.Service/Services/TorrentClients/TorBoxTorrentClient.cs @@ -0,0 +1,429 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using TorBoxNET; +using RdtClient.Data.Enums; +using RdtClient.Data.Models.TorrentClient; +using RdtClient.Service.Helpers; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace RdtClient.Service.Services.TorrentClients; + +public class TorBoxTorrentClient(ILogger logger, IHttpClientFactory httpClientFactory) : ITorrentClient +{ + private TimeSpan? _offset; + + private TorBoxNetClient GetClient() + { + try + { + var apiKey = Settings.Get.Provider.ApiKey; + + if (String.IsNullOrWhiteSpace(apiKey)) + { + throw new("TorBox API Key not set in the settings"); + } + + var httpClient = httpClientFactory.CreateClient(); + httpClient.Timeout = TimeSpan.FromSeconds(Settings.Get.Provider.Timeout); + + var rdtNetClient = new TorBoxNetClient(null, httpClient, 5); + rdtNetClient.UseApiAuthentication(apiKey); + + // Get the server time to fix up the timezones on results + if (_offset == null) + { + var serverTime = rdtNetClient.Api.GetIsoTimeAsync(); + _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 TorrentClientTorrent Map(Torrent torrent) + { + return new() + { + Id = torrent.Id.ToString(), + Filename = torrent.Name, + OriginalFilename = torrent.Name, + Hash = torrent.Hash, + Bytes = torrent.Size, + OriginalBytes = torrent.Size, + Host = null, + Split = 0, + Progress = (Int64)((torrent.Progress) * 100.0), + Status = torrent.DownloadState, + Added = ChangeTimeZone(torrent.CreatedAt)!.Value, + Files = (torrent.Files ?? []).Select(m => new TorrentClientFile + { + Path = m.S3Path.Replace(m.Hash + "/", string.Empty), + Bytes = m.Size, + Id = m.Id, + Selected = true + }).ToList(), + Links = [], + Ended = ChangeTimeZone(torrent.UpdatedAt), + Speed = torrent.DownloadSpeed, + Seeders = torrent.Seeds, + }; + } + + public async Task> GetTorrents() + { + var results = await GetClient().Torrents.GetAsync(true); + + return results!.Select(Map).ToList(); + } + + public async Task GetUser() + { + var user = await GetClient().User.GetAsync(false); + + return new() + { + Username = user.Data!.Email, + Expiration = user.Data!.Plan != 0 ? user.Data!.PremiumExpiresAt.Value : null + }; + } + + public async Task AddMagnet(String magnetLink) + { + var result = await GetClient().Torrents.AddMagnetAsync(magnetLink); + + return result.Data?.Torrent_ID.ToString()!; + } + + public async Task AddFile(Byte[] bytes) + { + var result = await GetClient().Torrents.AddFileAsync(bytes); + + return result.Data?.Torrent_ID.ToString()!; + } + + public async Task> GetAvailableFiles(String hash) + { + var result = await GetClient().Torrents.GetAvailableFiles(hash); + + var files = result.SelectMany(m => m.Value).SelectMany(m => m.Value).SelectMany(m => m.Values); + + var groups = files.Where(m => m.Filename != null).GroupBy(m => $"{m.Filename}-{m.Filesize}"); + + var torrentClientAvailableFiles = groups.Select(m => new TorrentClientAvailableFile + { + Filename = m.First().Filename!, + Filesize = m.First().Filesize + }).ToList(); + + return torrentClientAvailableFiles; + } + + public async Task SelectFiles(Data.Models.Data.Torrent torrent) + { + var files = torrent.Files; + + Log("Seleting files", torrent); + + if (torrent.DownloadAction == TorrentDownloadAction.DownloadAvailableFiles) + { + Log($"Determining which files are already available on RealDebrid", torrent); + + var availableFiles = await GetAvailableFiles(torrent.Hash); + + Log($"Found {files.Count}/{torrent.Files.Count} available files on RealDebrid", torrent); + + files = torrent.Files.Where(m => availableFiles.Any(f => m.Path.EndsWith(f.Filename))).ToList(); + } + else if (torrent.DownloadAction == TorrentDownloadAction.DownloadAll) + { + Log("Selecting all files", torrent); + files = [.. torrent.Files]; + } + else 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(); + } + + Log($"Selecting {files.Count}/{torrent.Files.Count} files", torrent); + + if (torrent.DownloadAction != TorrentDownloadAction.DownloadManual && torrent.DownloadMinSize > 0) + { + var minFileSize = torrent.DownloadMinSize * 1024 * 1024; + + Log($"Determining which files are over {minFileSize} bytes", torrent); + + files = files.Where(m => m.Bytes > minFileSize) + .ToList(); + + Log($"Found {files.Count} files that match the minimum file size criterea", torrent); + } + + if (!String.IsNullOrWhiteSpace(torrent.IncludeRegex)) + { + Log($"Using regular expression {torrent.IncludeRegex} to include only files matching this regex", torrent); + + var newFiles = new List(); + foreach (var file in files) + { + if (Regex.IsMatch(file.Path, torrent.IncludeRegex)) + { + Log($"* Including {file.Path}", torrent); + newFiles.Add(file); + } + else + { + Log($"* Excluding {file.Path}", torrent); + } + } + + files = newFiles; + + Log($"Found {files.Count} files that match the regex", torrent); + } + else if (!String.IsNullOrWhiteSpace(torrent.ExcludeRegex)) + { + Log($"Using regular expression {torrent.IncludeRegex} to ignore files matching this regex", torrent); + + var newFiles = new List(); + foreach (var file in files) + { + if (!Regex.IsMatch(file.Path, torrent.ExcludeRegex)) + { + Log($"* Including {file.Path}", torrent); + newFiles.Add(file); + } + else + { + Log($"* Excluding {file.Path}", torrent); + } + } + + files = newFiles; + + Log($"Found {files.Count} files that match the regex", torrent); + } + + if (files.Count == 0) + { + Log($"Filtered all files out! Downloading ALL files instead!", torrent); + + files = torrent.Files; + } + + var fileIds = files.Select(m => m.Id.ToString()).ToArray(); + + Log($"Selecting files:"); + + foreach (var file in files) + { + Log($"{file.Id}: {file.Path} ({file.Bytes}b)"); + } + + Log("", torrent); + + await GetClient().Torrents.SelectFilesAsync(torrent.RdId!, [.. fileIds]); + } + + public async Task Delete(String torrentId) + { + await GetClient().Torrents.DeleteAsync(torrentId); + } + + public async Task Unrestrict(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 UpdateData(Data.Models.Data.Torrent torrent, TorrentClientTorrent? torrentClientTorrent) + { + try + { + if (torrent.RdId == null) + { + return torrent; + } + + var rdTorrent = await GetInfo(torrent.RdId) ?? 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.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; + + torrent.RdStatus = rdTorrent.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?> GetDownloadLinks(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)).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; + } + + private DateTimeOffset? ChangeTimeZone(DateTimeOffset? dateTimeOffset) + { + if (_offset == null) + { + return dateTimeOffset; + } + + return dateTimeOffset?.Subtract(_offset.Value).ToOffset(_offset.Value); + } + + private async Task 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); + } +} \ No newline at end of file