From f87bcfa37ddfdcf574a3144422f4ff2cb3b71533 Mon Sep 17 00:00:00 2001 From: Mark Mendoza Date: Sun, 27 Oct 2024 03:35:21 -0700 Subject: [PATCH 01/10] Initial RQBit support added --- .../Download/Clients/RQBit/RQBitFile.cs | 8 + .../Download/Clients/RQBit/RQBitTorrent.cs | 16 ++ .../Download/Clients/RQBit/RQbitProxy.cs | 217 ++++++++++++++++++ .../Clients/RQBit/RQbitSettingsValidator.cs | 15 ++ .../ResponseModels/PostTorrentResponse.cs | 18 ++ .../RQBit/ResponseModels/RootResponse.cs | 10 + .../ResponseModels/TorrentFileResponse.cs | 11 + .../ResponseModels/TorrentListResponse.cs | 8 + .../ResponseModels/TorrentListingResponse.cs | 7 + .../RQBit/ResponseModels/TorrentResponse.cs | 10 + .../RQBit/ResponseModels/TorrentStatus.cs | 11 + .../ResponseModels/TorrentV1StatResponse.cs | 49 ++++ .../Download/Clients/RQBit/rQBit.cs | 173 ++++++++++++++ .../Download/Clients/RQBit/rQbitSettings.cs | 36 +++ 14 files changed, 589 insertions(+) create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs new file mode 100644 index 0000000000..f70cd384a7 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitFile.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.RQBit; + +public class RQBitFile +{ + public string FileName { get; set; } + public int FileSize { get; set; } + public int FileDownloaded { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs new file mode 100644 index 0000000000..ee383f5149 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs @@ -0,0 +1,16 @@ +namespace NzbDrone.Core.Download.Clients.RQBit; + +public class RQBitTorrent +{ + public long id { get; set; } + public string Name { get; set; } + public string Hash { get; set; } + public long TotalSize { get; set; } + public long RemainingSize { get; set; } + public string Category { get; set; } + public double? Ratio { get; set; } + public long DownRate { get; set; } + public bool IsFinished { get; set; } + public bool IsActive { get; set; } + public long FinishedTime { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs new file mode 100644 index 0000000000..8fe36db240 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -0,0 +1,217 @@ +using System.Collections.Generic; +using System.Net; +using System.Text; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Clients.RQbit; +using NzbDrone.Core.Download.Clients.RQBit; +using NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +namespace NzbDrone.Core.Download.Clients.rQbit; + +public interface IRQbitProxy +{ + string GetVersion(RQbitSettings settings); + List GetTorrents(RQbitSettings settings); + void RemoveTorrent(string hash, bool removeData, RQbitSettings settings); + + string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings); + + string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings); + + void SetTorrentLabel(string hash, string label, RQbitSettings settings); + bool HasHashTorrent(string hash, RQbitSettings settings); +} + +public class RQbitProxy : IRQbitProxy +{ + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public RQbitProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public string GetVersion(RQbitSettings settings) + { + var version = ""; + var request = BuildRequest(settings).Resource(""); + var response = _httpClient.Get(request.Build()); + if (response.StatusCode == HttpStatusCode.OK) + { + var jsonStr = Encoding.UTF8.GetString(response.ResponseData); + var rootResponse = JsonConvert.DeserializeObject(jsonStr); + version = rootResponse.version; + } + else + { + _logger.Error("Failed to get torrent version"); + } + + return version; + } + + public List GetTorrents(RQbitSettings settings) + { + List result = null; + var request = BuildRequest(settings).Resource("/torrents"); + var response = _httpClient.Get(request.Build()); + TorrentListResponse torrentList = null; + if (response.StatusCode == HttpStatusCode.OK) + { + var jsonStr = Encoding.UTF8.GetString(response.ResponseData); + torrentList = JsonConvert.DeserializeObject(jsonStr); + } + else + { + _logger.Error("Failed to get torrent version"); + } + + if (torrentList != null) + { + result = new List(); + foreach (var torrentListItem in torrentList.torrents) + { + var torrentResponse = getTorrent(torrentListItem.info_hash, settings); + var torrentStatsResponse = getTorrentStats(torrentListItem.info_hash, settings); + var torrent = new RQBitTorrent(); + + torrent.id = torrentListItem.id; + torrent.Name = torrentResponse.name; + torrent.Hash = torrentResponse.info_hash; + torrent.TotalSize = torrentStatsResponse.total_bytes; + + var statsLive = torrentStatsResponse.live; + if (statsLive != null && statsLive.snapshot != null) + { + torrent.DownRate = statsLive.download_speed.mbps * 1048576; // mib/sec -> bytes per second + } + + torrent.RemainingSize = torrentStatsResponse.total_bytes - torrentStatsResponse.progress_bytes; + torrent.Ratio = torrentStatsResponse.uploaded_bytes / torrentStatsResponse.progress_bytes; + torrent.IsFinished = torrentStatsResponse.finished; + torrent.IsActive = torrentStatsResponse.state != "paused"; + + result.Add(torrent); + } + } + + return result; + } + + public void RemoveTorrent(string info_hash, bool removeData, RQbitSettings settings) + { + var endpoint = removeData ? "/delete" : "/forget"; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash + endpoint); + _httpClient.Post(itemRequest.Build()); + } + + public string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings) + { + string info_hash = null; + var itemRequest = BuildRequest(settings).Resource("/torrents?overwrite=true").Post().Build(); + itemRequest.SetContent(torrentUrl); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return info_hash; + } + + var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); + var response = JsonConvert.DeserializeObject(jsonStr); + + if (response.details != null) + { + info_hash = response.details.info_hash; + } + + return info_hash; + } + + public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings) + { + string info_hash = null; + var itemRequest = BuildRequest(settings) + .Post() + .Resource("/torrents?overwrite=true") + .Build(); + itemRequest.SetContent(fileContent); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return info_hash; + } + + var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); + var response = JsonConvert.DeserializeObject(jsonStr); + + if (response.details != null) + { + info_hash = response.details.info_hash; + } + + return info_hash; + } + + public void SetTorrentLabel(string hash, string label, RQbitSettings settings) + { + _logger.Warn("Torrent labels currently unsupported by RQBit"); + } + + public bool HasHashTorrent(string hash, RQbitSettings settings) + { + var result = true; + var rqBitTorrentResponse = getTorrent(hash, settings); + if (rqBitTorrentResponse == null || string.IsNullOrWhiteSpace(rqBitTorrentResponse.info_hash)) + { + result = false; + } + + return result; + } + + private TorrentResponse getTorrent(string info_hash, RQbitSettings settings) + { + TorrentResponse result = null; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash); + var itemResponse = _httpClient.Get(itemRequest.Build()); + if (itemResponse.StatusCode != HttpStatusCode.OK) + { + return result; + } + + var jsonStr = Encoding.UTF8.GetString(itemResponse.ResponseData); + result = JsonConvert.DeserializeObject(jsonStr); + + return result; + } + + private TorrentV1StatResponse getTorrentStats(string info_hash, RQbitSettings settings) + { + TorrentV1StatResponse result = null; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash + "/stats/v1"); + var itemResponse = _httpClient.Get(itemRequest.Build()); + if (itemResponse.StatusCode != HttpStatusCode.OK) + { + return result; + } + + var jsonStr = Encoding.UTF8.GetString(itemResponse.ResponseData); + result = JsonConvert.DeserializeObject(jsonStr); + + return result; + } + + private HttpRequestBuilder BuildRequest(RQbitSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + }; + return requestBuilder; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs new file mode 100644 index 0000000000..bae434bd11 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs @@ -0,0 +1,15 @@ +using FluentValidation; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RQbit; + +public class RQbitSettingsValidator : AbstractValidator +{ + public RQbitSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(1, 65535); + + RuleFor(c => c.UrlBase).ValidUrlBase(); + } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs new file mode 100644 index 0000000000..4cfaf0c23a --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class PostTorrentResponse +{ + public long id { get; set; } + public PostTorrentDetailsResponse details { get; set; } + public string output_folder { get; set; } + public List seen_peers { get; set; } +} + +public class PostTorrentDetailsResponse +{ + public string info_hash { get; set; } + public string name { get; set; } + public List files { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs new file mode 100644 index 0000000000..c0b83df296 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.rQbit; + +public class RootResponse +{ + public Dictionary apis { get; set; } + public string server { get; set; } + public string version { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs new file mode 100644 index 0000000000..7e4ffdd5b0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentFileResponse +{ + public string name { get; set; } + public List components { get; set; } + public long length { get; set; } + public bool included { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs new file mode 100644 index 0000000000..d532a1553b --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentListResponse +{ + public List torrents { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs new file mode 100644 index 0000000000..a7b143ee7f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentListingResponse +{ + public long id { get; set; } + public string info_hash { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs new file mode 100644 index 0000000000..375a2910dd --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentResponse +{ + public string info_hash { get; set; } + public string name { get; set; } + public List files { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs new file mode 100644 index 0000000000..d4a6dcbea5 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Download.Clients.RQBit; + +// https://github.com/ikatson/rqbit/blob/946ad3625892f4f40dde3d0e6bbc3030f68a973c/crates/librqbit/src/torrent_state/mod.rs#L65 +public enum TorrentStatus +{ + Initializing = 0, + Paused = 1, + Live = 2, + Error = 3, + Invalid = 4 +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs new file mode 100644 index 0000000000..63f4aec76d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; + +public class TorrentV1StatResponse +{ + public string state { get; set; } + public List file_progress { get; set; } + public string error { get; set; } + public long progress_bytes { get; set; } + public long uploaded_bytes { get; set; } + public long total_bytes { get; set; } + public bool finished { get; set; } + public TorrentV1StatLiveResponse live { get; set; } +} + +public class RQBitTorrentSpeedResponse +{ + public long mbps { get; set; } + public string human_readable { get; set; } +} + +public class TorrentV1StatLiveResponse +{ + public TorrentV1StatLiveSnapshotResponse snapshot { get; set; } + public RQBitTorrentSpeedResponse download_speed { get; set; } + public RQBitTorrentSpeedResponse upload_speed { get; set; } +} + +public class TorrentV1StatLiveSnapshotResponse +{ + public long downloaded_and_checked_bytes { get; set; } + public long fetched_bytes { get; set; } + public long uploaded_bytes { get; set; } + public long downloaded_and_checked_pieces { get; set; } + public long total_piece_downloaded_ms { get; set; } + public TorrentV1StatLiveSnapshotPeerStatsResponse peer_stats { get; set; } +} + +public class TorrentV1StatLiveSnapshotPeerStatsResponse +{ + public int queued { get; set; } + public int connecting { get; set; } + public int live { get; set; } + public int seen { get; set; } + public int dead { get; set; } + public int not_needed { get; set; } + public int steals { get; set; } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs new file mode 100644 index 0000000000..ed8b685e47 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Blocklisting; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Download.Clients.RQbit; +using NzbDrone.Core.Download.Clients.rTorrent; +using NzbDrone.Core.Localization; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.rQbit +{ + public class RQBit : TorrentClientBase + { + private readonly IRQbitProxy _proxy; + private readonly IDownloadSeedConfigProvider _downloadSeedConfigProvider; + + public RQBit(IRQbitProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + IDownloadSeedConfigProvider downloadSeedConfigProvider, + IRTorrentDirectoryValidator rTorrentDirectoryValidator, + ILocalizationService localizationService, + IBlocklistService blocklistService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, localizationService, blocklistService, logger) + { + _proxy = proxy; + _downloadSeedConfigProvider = downloadSeedConfigProvider; + } + + public override IEnumerable GetItems() + { + var torrents = _proxy.GetTorrents(Settings); + + _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count); + + var items = new List(); + foreach (var torrent in torrents) + { + // // Ignore torrents with an empty path + // if (torrent.Path.IsNullOrWhiteSpace()) + // { + // _logger.Warn("Torrent '{0}' has an empty download path and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name); + // continue; + // } + // + // if (torrent.Path.StartsWith(".")) + // { + // _logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name); + // continue; + // } + + var item = new DownloadClientItem(); + item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false); + item.Title = torrent.Name; + item.DownloadId = torrent.Hash; + + // item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path)); + item.TotalSize = torrent.TotalSize; + item.RemainingSize = torrent.RemainingSize; + item.Category = torrent.Category; + item.SeedRatio = torrent.Ratio; + + if (torrent.DownRate > 0) + { + var secondsLeft = torrent.RemainingSize / torrent.DownRate; + item.RemainingTime = TimeSpan.FromSeconds(secondsLeft); + } + else + { + item.RemainingTime = TimeSpan.Zero; + } + + if (torrent.IsFinished) + { + item.Status = DownloadItemStatus.Completed; + } + else if (torrent.IsActive) + { + item.Status = DownloadItemStatus.Downloading; + } + else if (!torrent.IsActive) + { + item.Status = DownloadItemStatus.Paused; + } + + // Grab cached seedConfig + var seedConfig = _downloadSeedConfigProvider.GetSeedConfiguration(torrent.Hash); + + if (item.DownloadClientInfo.RemoveCompletedDownloads && torrent.IsFinished && seedConfig != null) + { + var canRemove = false; + + if (torrent.Ratio / 1000.0 >= seedConfig.Ratio) + { + _logger.Trace($"{item} has met seed ratio goal of {seedConfig.Ratio}"); + canRemove = true; + } + else if (DateTimeOffset.Now - DateTimeOffset.FromUnixTimeSeconds(torrent.FinishedTime) >= seedConfig.SeedTime) + { + _logger.Trace($"{item} has met seed time goal of {seedConfig.SeedTime} minutes"); + canRemove = true; + } + else + { + _logger.Trace($"{item} seeding goals have not yet been reached"); + } + + // Check if torrent is finished and if it exceeds cached seedConfig + item.CanMoveFiles = item.CanBeRemoved = canRemove; + } + + items.Add(item); + } + + return items; + } + + public override void RemoveItem(DownloadClientItem item, bool deleteData) + { + _proxy.RemoveTorrent(item.DownloadId, deleteData, Settings); + } + + public override DownloadClientInfo GetStatus() + { + return new DownloadClientInfo + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", + }; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.HasErrors()) + { + return; + } + + // failures.AddIfNotNull(TestGetTorrents()); + // failures.AddIfNotNull(TestDirectory()); + } + + private ValidationFailure TestConnection() + { + var version = _proxy.GetVersion(Settings); + return null; + } + + protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink) + { + return _proxy.AddTorrentFromUrl(magnetLink, Settings); + } + + protected override string AddFromTorrentFile(RemoteMovie remoteMovie, string hash, string filename, byte[] fileContent) + { + return _proxy.AddTorrentFromFile(filename, fileContent, Settings); + } + + public override string Name => "RQBit"; + } +} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs new file mode 100644 index 0000000000..e547da3263 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RQbit +{ + public class RQbitSettings : DownloadClientSettingsBase + { + private static readonly RQbitSettingsValidator Validator = new RQbitSettingsValidator(); + + public RQbitSettings() + { + Host = "localhost"; + Port = 3030; + UrlBase = "/"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public int Port { get; set; } + + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsUseSslHelpText")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "RQBit")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/")] + public string UrlBase { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} From 10c3d47e2d9c98c66804df3e534393721ea16d8f Mon Sep 17 00:00:00 2001 From: Mark Mendoza Date: Sat, 2 Nov 2024 00:13:06 -0700 Subject: [PATCH 02/10] Fixed pascal casing and fixed class name --- .../Download/Clients/RQBit/RQbitProxy.cs | 16 ++-- .../ResponseModels/TorrentV1StatResponse.cs | 92 +++++++++++++------ 2 files changed, 74 insertions(+), 34 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs index 8fe36db240..0b546a520b 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -83,18 +83,18 @@ public List GetTorrents(RQbitSettings settings) torrent.id = torrentListItem.id; torrent.Name = torrentResponse.name; torrent.Hash = torrentResponse.info_hash; - torrent.TotalSize = torrentStatsResponse.total_bytes; + torrent.TotalSize = torrentStatsResponse.TotalBytes; - var statsLive = torrentStatsResponse.live; - if (statsLive != null && statsLive.snapshot != null) + var statsLive = torrentStatsResponse.Live; + if (statsLive != null && statsLive.Snapshot != null) { - torrent.DownRate = statsLive.download_speed.mbps * 1048576; // mib/sec -> bytes per second + torrent.DownRate = statsLive.DownloadSpeed.Mbps * 1048576; // mib/sec -> bytes per second } - torrent.RemainingSize = torrentStatsResponse.total_bytes - torrentStatsResponse.progress_bytes; - torrent.Ratio = torrentStatsResponse.uploaded_bytes / torrentStatsResponse.progress_bytes; - torrent.IsFinished = torrentStatsResponse.finished; - torrent.IsActive = torrentStatsResponse.state != "paused"; + torrent.RemainingSize = torrentStatsResponse.TotalBytes - torrentStatsResponse.ProgressBytes; + torrent.Ratio = torrentStatsResponse.UploadedBytes / torrentStatsResponse.ProgressBytes; + torrent.IsFinished = torrentStatsResponse.Finished; + torrent.IsActive = torrentStatsResponse.State != "paused"; result.Add(torrent); } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs index 63f4aec76d..f67ad9fdf6 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs @@ -1,49 +1,89 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class TorrentV1StatResponse { - public string state { get; set; } - public List file_progress { get; set; } - public string error { get; set; } - public long progress_bytes { get; set; } - public long uploaded_bytes { get; set; } - public long total_bytes { get; set; } - public bool finished { get; set; } - public TorrentV1StatLiveResponse live { get; set; } + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("file_progress")] + public List FileProgress { get; set; } + + [JsonProperty("error")] + public string Error { get; set; } + + [JsonProperty("progress_bytes")] + public long ProgressBytes { get; set; } + + [JsonProperty("uploaded_bytes")] + public long UploadedBytes { get; set; } + + [JsonProperty("total_bytes")] + public long TotalBytes { get; set; } + + [JsonProperty("finished")] + public bool Finished { get; set; } + + [JsonProperty("live")] + public TorrentV1StatLiveResponse Live { get; set; } } public class RQBitTorrentSpeedResponse { - public long mbps { get; set; } - public string human_readable { get; set; } + [JsonProperty("mbps")] + public long Mbps { get; set; } + + [JsonProperty("human_readable")] + public string HumanReadable { get; set; } } public class TorrentV1StatLiveResponse { - public TorrentV1StatLiveSnapshotResponse snapshot { get; set; } - public RQBitTorrentSpeedResponse download_speed { get; set; } - public RQBitTorrentSpeedResponse upload_speed { get; set; } + [JsonProperty("snapshot")] + public TorrentV1StatLiveSnapshotResponse Snapshot { get; set; } + [JsonProperty("download_speed")] + public RQBitTorrentSpeedResponse DownloadSpeed { get; set; } + [JsonProperty("upload_speed")] + public RQBitTorrentSpeedResponse UploadSpeed { get; set; } } public class TorrentV1StatLiveSnapshotResponse { - public long downloaded_and_checked_bytes { get; set; } - public long fetched_bytes { get; set; } - public long uploaded_bytes { get; set; } - public long downloaded_and_checked_pieces { get; set; } - public long total_piece_downloaded_ms { get; set; } - public TorrentV1StatLiveSnapshotPeerStatsResponse peer_stats { get; set; } + [JsonProperty("downloaded_and_checked_bytes")] + public long DownloadedAndCheckedBytes { get; set; } + + [JsonProperty("fetched_bytes")] + public long FetchedBytes { get; set; } + + [JsonProperty("uploaded_bytes")] + public long UploadedBytes { get; set; } + + [JsonProperty("downloaded_and_checked_pieces")] + public long DownloadedAndCheckedPieces { get; set; } + + [JsonProperty("total_piece_downloaded_ms")] + public long TotalPieceDownloadedMs { get; set; } + + [JsonProperty("peer_stats")] + public TorrentV1StatLiveSnapshotPeerStatsResponse PeerStats { get; set; } } public class TorrentV1StatLiveSnapshotPeerStatsResponse { - public int queued { get; set; } - public int connecting { get; set; } - public int live { get; set; } - public int seen { get; set; } - public int dead { get; set; } - public int not_needed { get; set; } - public int steals { get; set; } + [JsonProperty("queued")] + public int Queued { get; set; } + [JsonProperty("connecting")] + public int Connecting { get; set; } + [JsonProperty("live")] + public int Live { get; set; } + [JsonProperty("seen")] + public int Seen { get; set; } + [JsonProperty("dead")] + public int Dead { get; set; } + [JsonProperty("not_needed")] + public int NotNeeded { get; set; } + [JsonProperty("steals")] + public int Steals { get; set; } } From d67f9d16a51d1c1547a0032ab10cf3949e12d51d Mon Sep 17 00:00:00 2001 From: Mark Mendoza Date: Sat, 2 Nov 2024 00:16:06 -0700 Subject: [PATCH 03/10] fixed casing on filename --- src/NzbDrone.Core/Download/Clients/RQBit/{rQBit.cs => RQBit.cs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/NzbDrone.Core/Download/Clients/RQBit/{rQBit.cs => RQBit.cs} (100%) diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs similarity index 100% rename from src/NzbDrone.Core/Download/Clients/RQBit/rQBit.cs rename to src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs From b8648bbd1281f831bccb246a26e340c4b5cb1f25 Mon Sep 17 00:00:00 2001 From: Mark Mendoza Date: Sat, 2 Nov 2024 00:16:53 -0700 Subject: [PATCH 04/10] fixed git case sensitivity --- .../Download/Clients/RQBit/RQbitSettings.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs new file mode 100644 index 0000000000..e547da3263 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RQbit +{ + public class RQbitSettings : DownloadClientSettingsBase + { + private static readonly RQbitSettingsValidator Validator = new RQbitSettingsValidator(); + + public RQbitSettings() + { + Host = "localhost"; + Port = 3030; + UrlBase = "/"; + } + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] + public int Port { get; set; } + + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsUseSslHelpText")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] + [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "RQBit")] + [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/")] + public string UrlBase { get; set; } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} From 1578695c20a25ec36905852a9152a70e5286d1e4 Mon Sep 17 00:00:00 2001 From: Mark Mendoza Date: Sat, 2 Nov 2024 00:33:48 -0700 Subject: [PATCH 05/10] FIxed pascal casing added path from new datapoint in a TorrentResponse --- .../Download/Clients/RQBit/RQBit.cs | 2 +- .../Download/Clients/RQBit/RQBitTorrent.cs | 1 + .../Download/Clients/RQBit/RQbitProxy.cs | 23 ++++++------ .../ResponseModels/PostTorrentResponse.cs | 22 ++++++++---- .../RQBit/ResponseModels/RootResponse.cs | 10 ++++-- .../ResponseModels/TorrentFileResponse.cs | 13 ++++--- .../ResponseModels/TorrentListResponse.cs | 2 ++ .../ResponseModels/TorrentListingResponse.cs | 10 ++++-- .../RQBit/ResponseModels/TorrentResponse.cs | 12 +++++-- .../Download/Clients/RQBit/rQbitSettings.cs | 36 ------------------- 10 files changed, 63 insertions(+), 68 deletions(-) delete mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs index ed8b685e47..ba508d57ba 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs @@ -66,7 +66,7 @@ public override IEnumerable GetItems() item.Title = torrent.Name; item.DownloadId = torrent.Hash; - // item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path)); + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.Path)); item.TotalSize = torrent.TotalSize; item.RemainingSize = torrent.RemainingSize; item.Category = torrent.Category; diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs index ee383f5149..789411d5cd 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs @@ -13,4 +13,5 @@ public class RQBitTorrent public bool IsFinished { get; set; } public bool IsActive { get; set; } public long FinishedTime { get; set; } + public string Path { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs index 0b546a520b..50877e2cbe 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -45,7 +45,7 @@ public string GetVersion(RQbitSettings settings) { var jsonStr = Encoding.UTF8.GetString(response.ResponseData); var rootResponse = JsonConvert.DeserializeObject(jsonStr); - version = rootResponse.version; + version = rootResponse.Version; } else { @@ -76,14 +76,15 @@ public List GetTorrents(RQbitSettings settings) result = new List(); foreach (var torrentListItem in torrentList.torrents) { - var torrentResponse = getTorrent(torrentListItem.info_hash, settings); - var torrentStatsResponse = getTorrentStats(torrentListItem.info_hash, settings); + var torrentResponse = getTorrent(torrentListItem.InfoHash, settings); + var torrentStatsResponse = getTorrentStats(torrentListItem.InfoHash, settings); var torrent = new RQBitTorrent(); - torrent.id = torrentListItem.id; - torrent.Name = torrentResponse.name; - torrent.Hash = torrentResponse.info_hash; + torrent.id = torrentListItem.Id; + torrent.Name = torrentResponse.Name; + torrent.Hash = torrentResponse.InfoHash; torrent.TotalSize = torrentStatsResponse.TotalBytes; + torrent.Path = torrentResponse.OutputFolder + torrentResponse.Name; var statsLive = torrentStatsResponse.Live; if (statsLive != null && statsLive.Snapshot != null) @@ -124,9 +125,9 @@ public string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings) var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); var response = JsonConvert.DeserializeObject(jsonStr); - if (response.details != null) + if (response.Details != null) { - info_hash = response.details.info_hash; + info_hash = response.Details.InfoHash; } return info_hash; @@ -149,9 +150,9 @@ public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSetti var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); var response = JsonConvert.DeserializeObject(jsonStr); - if (response.details != null) + if (response.Details != null) { - info_hash = response.details.info_hash; + info_hash = response.Details.InfoHash; } return info_hash; @@ -166,7 +167,7 @@ public bool HasHashTorrent(string hash, RQbitSettings settings) { var result = true; var rqBitTorrentResponse = getTorrent(hash, settings); - if (rqBitTorrentResponse == null || string.IsNullOrWhiteSpace(rqBitTorrentResponse.info_hash)) + if (rqBitTorrentResponse == null || string.IsNullOrWhiteSpace(rqBitTorrentResponse.InfoHash)) { result = false; } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs index 4cfaf0c23a..debd6a2a78 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/PostTorrentResponse.cs @@ -1,18 +1,26 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class PostTorrentResponse { - public long id { get; set; } - public PostTorrentDetailsResponse details { get; set; } - public string output_folder { get; set; } - public List seen_peers { get; set; } + [JsonProperty("id")] + public long Id { get; set; } + [JsonProperty("details")] + public PostTorrentDetailsResponse Details { get; set; } + [JsonProperty("output_folder")] + public string OutputFolder { get; set; } + [JsonProperty("seen_peers")] + public List SeenPeers { get; set; } } public class PostTorrentDetailsResponse { - public string info_hash { get; set; } - public string name { get; set; } - public List files { get; set; } + [JsonProperty("info_hash")] + public string InfoHash { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("files")] + public List Files { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs index c0b83df296..f3f7af1e97 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs @@ -1,10 +1,14 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.rQbit; public class RootResponse { - public Dictionary apis { get; set; } - public string server { get; set; } - public string version { get; set; } + [JsonProperty("apis")] + public Dictionary Apis { get; set; } + [JsonProperty("server")] + public string Server { get; set; } + [JsonProperty("version")] + public string Version { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs index 7e4ffdd5b0..05c19c7617 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentFileResponse.cs @@ -1,11 +1,16 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class TorrentFileResponse { - public string name { get; set; } - public List components { get; set; } - public long length { get; set; } - public bool included { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("components")] + public List Components { get; set; } + [JsonProperty("length")] + public long Length { get; set; } + [JsonProperty("included")] + public bool Included { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs index d532a1553b..0efd985ab2 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListResponse.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class TorrentListResponse { + [JsonProperty("torrents")] public List torrents { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs index a7b143ee7f..0675a78ddc 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentListingResponse.cs @@ -1,7 +1,11 @@ -namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class TorrentListingResponse { - public long id { get; set; } - public string info_hash { get; set; } + [JsonProperty("id")] + public long Id { get; set; } + [JsonProperty("info_hash")] + public string InfoHash { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs index 375a2910dd..f15df682bc 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentResponse.cs @@ -1,10 +1,16 @@ using System.Collections.Generic; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class TorrentResponse { - public string info_hash { get; set; } - public string name { get; set; } - public List files { get; set; } + [JsonProperty("info_hash")] + public string InfoHash { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("files")] + public List Files { get; set; } + [JsonProperty("output_folder")] + public string OutputFolder { get; set; } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs deleted file mode 100644 index e547da3263..0000000000 --- a/src/NzbDrone.Core/Download/Clients/RQBit/rQbitSettings.cs +++ /dev/null @@ -1,36 +0,0 @@ -using NzbDrone.Core.Annotations; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Download.Clients.RQbit -{ - public class RQbitSettings : DownloadClientSettingsBase - { - private static readonly RQbitSettingsValidator Validator = new RQbitSettingsValidator(); - - public RQbitSettings() - { - Host = "localhost"; - Port = 3030; - UrlBase = "/"; - } - - [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)] - public string Host { get; set; } - - [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] - public int Port { get; set; } - - [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsUseSslHelpText")] - public bool UseSsl { get; set; } - - [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] - [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "RQBit")] - [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/")] - public string UrlBase { get; set; } - - public override NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} From 36786756a6ca2b31d5f8f9ee1c8727b42dd29500 Mon Sep 17 00:00:00 2001 From: Alexander Westber Date: Sun, 27 Jul 2025 14:11:59 +0200 Subject: [PATCH 06/10] Update RQbitPRoxy to use new stats api Avoid do doing an extra request for each torrent. Get all the needed data from /torrents?with_stats=true --- .../Download/Clients/RQBit/RQBitTorrent.cs | 31 +- .../Download/Clients/RQBit/RQbitProxy.cs | 356 +++++++++--------- .../ResponseModels/TorrentV1StatResponse.cs | 89 ----- .../TorrentWithStatsResponse.cs | 137 +++++++ 4 files changed, 339 insertions(+), 274 deletions(-) delete mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs index 789411d5cd..8b7b82ee5b 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBitTorrent.cs @@ -1,17 +1,18 @@ -namespace NzbDrone.Core.Download.Clients.RQBit; - -public class RQBitTorrent +namespace NzbDrone.Core.Download.Clients.RQBit { - public long id { get; set; } - public string Name { get; set; } - public string Hash { get; set; } - public long TotalSize { get; set; } - public long RemainingSize { get; set; } - public string Category { get; set; } - public double? Ratio { get; set; } - public long DownRate { get; set; } - public bool IsFinished { get; set; } - public bool IsActive { get; set; } - public long FinishedTime { get; set; } - public string Path { get; set; } + public class RQBitTorrent + { + public long Id { get; set; } + public string Name { get; set; } + public string Hash { get; set; } + public long TotalSize { get; set; } + public long RemainingSize { get; set; } + public string Category { get; set; } + public double? Ratio { get; set; } + public long DownRate { get; set; } + public bool IsFinished { get; set; } + public bool IsActive { get; set; } + public long FinishedTime { get; set; } + public string Path { get; set; } + } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs index 50877e2cbe..4ac9ca74d6 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -1,218 +1,234 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Net; -using System.Text; +using System.Linq; using Newtonsoft.Json; using NLog; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; -using NzbDrone.Core.Download.Clients.RQbit; using NzbDrone.Core.Download.Clients.RQBit; using NzbDrone.Core.Download.Clients.RQBit.ResponseModels; -namespace NzbDrone.Core.Download.Clients.rQbit; - -public interface IRQbitProxy +namespace NzbDrone.Core.Download.Clients.RQBit { - string GetVersion(RQbitSettings settings); - List GetTorrents(RQbitSettings settings); - void RemoveTorrent(string hash, bool removeData, RQbitSettings settings); - - string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings); - - string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings); - - void SetTorrentLabel(string hash, string label, RQbitSettings settings); - bool HasHashTorrent(string hash, RQbitSettings settings); -} - -public class RQbitProxy : IRQbitProxy -{ - private readonly IHttpClient _httpClient; - private readonly Logger _logger; - - public RQbitProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + public interface IRQbitProxy { - _httpClient = httpClient; - _logger = logger; + bool IsApiSupported(RQbitSettings settings); + string GetVersion(RQbitSettings settings); + List GetTorrents(RQbitSettings settings); + void RemoveTorrent(string hash, bool removeData, RQbitSettings settings); + string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings); + string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings); + void SetTorrentLabel(string hash, string label, RQbitSettings settings); + bool HasHashTorrent(string hash, RQbitSettings settings); } - public string GetVersion(RQbitSettings settings) + public class RQbitProxy : IRQbitProxy { - var version = ""; - var request = BuildRequest(settings).Resource(""); - var response = _httpClient.Get(request.Build()); - if (response.StatusCode == HttpStatusCode.OK) + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public RQbitProxy(IHttpClient httpClient, Logger logger) { - var jsonStr = Encoding.UTF8.GetString(response.ResponseData); - var rootResponse = JsonConvert.DeserializeObject(jsonStr); - version = rootResponse.Version; - } - else - { - _logger.Error("Failed to get torrent version"); + _httpClient = httpClient; + _logger = logger; } - return version; - } + public bool IsApiSupported(RQbitSettings settings) + { + var request = BuildRequest(settings).Resource(""); + request.SuppressHttpError = true; - public List GetTorrents(RQbitSettings settings) - { - List result = null; - var request = BuildRequest(settings).Resource("/torrents"); - var response = _httpClient.Get(request.Build()); - TorrentListResponse torrentList = null; - if (response.StatusCode == HttpStatusCode.OK) - { - var jsonStr = Encoding.UTF8.GetString(response.ResponseData); - torrentList = JsonConvert.DeserializeObject(jsonStr); - } - else - { - _logger.Error("Failed to get torrent version"); - } - - if (torrentList != null) - { - result = new List(); - foreach (var torrentListItem in torrentList.torrents) + try { - var torrentResponse = getTorrent(torrentListItem.InfoHash, settings); - var torrentStatsResponse = getTorrentStats(torrentListItem.InfoHash, settings); - var torrent = new RQBitTorrent(); + var response = _httpClient.Get(request.Build()); - torrent.id = torrentListItem.Id; - torrent.Name = torrentResponse.Name; - torrent.Hash = torrentResponse.InfoHash; - torrent.TotalSize = torrentStatsResponse.TotalBytes; - torrent.Path = torrentResponse.OutputFolder + torrentResponse.Name; - - var statsLive = torrentStatsResponse.Live; - if (statsLive != null && statsLive.Snapshot != null) + // Check if we can connect and get a valid response + if (response.StatusCode == HttpStatusCode.OK) { - torrent.DownRate = statsLive.DownloadSpeed.Mbps * 1048576; // mib/sec -> bytes per second + var rootResponse = JsonConvert.DeserializeObject(response.Content); + return !string.IsNullOrWhiteSpace(rootResponse?.Version); } - torrent.RemainingSize = torrentStatsResponse.TotalBytes - torrentStatsResponse.ProgressBytes; - torrent.Ratio = torrentStatsResponse.UploadedBytes / torrentStatsResponse.ProgressBytes; - torrent.IsFinished = torrentStatsResponse.Finished; - torrent.IsActive = torrentStatsResponse.State != "paused"; - - result.Add(torrent); + return false; + } + catch (Exception ex) + { + _logger.Debug(ex, "RQBit API not supported or not reachable"); + return false; } } - return result; - } - - public void RemoveTorrent(string info_hash, bool removeData, RQbitSettings settings) - { - var endpoint = removeData ? "/delete" : "/forget"; - var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash + endpoint); - _httpClient.Post(itemRequest.Build()); - } - - public string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings) - { - string info_hash = null; - var itemRequest = BuildRequest(settings).Resource("/torrents?overwrite=true").Post().Build(); - itemRequest.SetContent(torrentUrl); - var httpResponse = _httpClient.Post(itemRequest); - if (httpResponse.StatusCode != HttpStatusCode.OK) + public string GetVersion(RQbitSettings settings) { - return info_hash; + var version = ""; + var request = BuildRequest(settings).Resource(""); + var response = _httpClient.Get(request.Build()); + if (response.StatusCode == HttpStatusCode.OK) + { + var rootResponse = JsonConvert.DeserializeObject(response.Content); + version = rootResponse.Version; + } + else + { + _logger.Error("Failed to get torrent version"); + } + + return version; } - var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); - var response = JsonConvert.DeserializeObject(jsonStr); - - if (response.Details != null) + public List GetTorrents(RQbitSettings settings) { - info_hash = response.Details.InfoHash; - } + var result = new List(); + var request = BuildRequest(settings).Resource("/torrents?with_stats=true"); + var response = _httpClient.Get(request.Build()); - return info_hash; - } + if (response.StatusCode != HttpStatusCode.OK) + { + _logger.Error("Failed to get torrent list with stats"); + return result; + } - public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings) - { - string info_hash = null; - var itemRequest = BuildRequest(settings) - .Post() - .Resource("/torrents?overwrite=true") - .Build(); - itemRequest.SetContent(fileContent); - var httpResponse = _httpClient.Post(itemRequest); - if (httpResponse.StatusCode != HttpStatusCode.OK) - { - return info_hash; - } + var torrentListWithStats = JsonConvert.DeserializeObject(response.Content); - var jsonStr = Encoding.UTF8.GetString(httpResponse.ResponseData); - var response = JsonConvert.DeserializeObject(jsonStr); + if (torrentListWithStats?.Torrents != null) + { + foreach (var torrentWithStats in torrentListWithStats.Torrents) + { + try + { + var torrent = new RQBitTorrent(); + torrent.Id = torrentWithStats.Id; + torrent.Name = torrentWithStats.Name; + torrent.Hash = torrentWithStats.InfoHash; + torrent.TotalSize = torrentWithStats.Stats.TotalBytes; + torrent.Path = torrentWithStats.OutputFolder + torrentWithStats.Name; - if (response.Details != null) - { - info_hash = response.Details.InfoHash; - } + var statsLive = torrentWithStats.Stats.Live; + if (statsLive?.DownloadSpeed != null) + { + // Convert mib/sec to bytes per second + torrent.DownRate = (long)(statsLive.DownloadSpeed.Mbps * 1048576); + } - return info_hash; - } + torrent.RemainingSize = torrentWithStats.Stats.TotalBytes - torrentWithStats.Stats.ProgressBytes; - public void SetTorrentLabel(string hash, string label, RQbitSettings settings) - { - _logger.Warn("Torrent labels currently unsupported by RQBit"); - } + // Avoid division by zero + if (torrentWithStats.Stats.ProgressBytes > 0) + { + torrent.Ratio = (double)torrentWithStats.Stats.UploadedBytes / torrentWithStats.Stats.ProgressBytes; + } + else + { + torrent.Ratio = 0; + } - public bool HasHashTorrent(string hash, RQbitSettings settings) - { - var result = true; - var rqBitTorrentResponse = getTorrent(hash, settings); - if (rqBitTorrentResponse == null || string.IsNullOrWhiteSpace(rqBitTorrentResponse.InfoHash)) - { - result = false; - } + torrent.IsFinished = torrentWithStats.Stats.Finished; + torrent.IsActive = torrentWithStats.Stats.State != "paused"; - return result; - } + result.Add(torrent); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to process torrent {0}", torrentWithStats.InfoHash); + } + } + } - private TorrentResponse getTorrent(string info_hash, RQbitSettings settings) - { - TorrentResponse result = null; - var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash); - var itemResponse = _httpClient.Get(itemRequest.Build()); - if (itemResponse.StatusCode != HttpStatusCode.OK) - { return result; } - var jsonStr = Encoding.UTF8.GetString(itemResponse.ResponseData); - result = JsonConvert.DeserializeObject(jsonStr); - - return result; - } - - private TorrentV1StatResponse getTorrentStats(string info_hash, RQbitSettings settings) - { - TorrentV1StatResponse result = null; - var itemRequest = BuildRequest(settings).Resource("/torrents/" + info_hash + "/stats/v1"); - var itemResponse = _httpClient.Get(itemRequest.Build()); - if (itemResponse.StatusCode != HttpStatusCode.OK) + public void RemoveTorrent(string infoHash, bool removeData, RQbitSettings settings) { + var endpoint = removeData ? "/delete" : "/forget"; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + infoHash + endpoint); + _httpClient.Post(itemRequest.Build()); + } + + public string AddTorrentFromUrl(string torrentUrl, RQbitSettings settings) + { + string infoHash = null; + var itemRequest = BuildRequest(settings).Resource("/torrents?overwrite=true").Post().Build(); + itemRequest.SetContent(torrentUrl); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return infoHash; + } + + var response = JsonConvert.DeserializeObject(httpResponse.Content); + + if (response.Details != null) + { + infoHash = response.Details.InfoHash; + } + + return infoHash; + } + + public string AddTorrentFromFile(string fileName, byte[] fileContent, RQbitSettings settings) + { + string infoHash = null; + var itemRequest = BuildRequest(settings) + .Post() + .Resource("/torrents?overwrite=true") + .Build(); + itemRequest.SetContent(fileContent); + var httpResponse = _httpClient.Post(itemRequest); + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + return infoHash; + } + + var response = JsonConvert.DeserializeObject(httpResponse.Content); + + if (response.Details != null) + { + infoHash = response.Details.InfoHash; + } + + return infoHash; + } + + public void SetTorrentLabel(string hash, string label, RQbitSettings settings) + { + _logger.Warn("Torrent labels currently unsupported by RQBit"); + } + + public bool HasHashTorrent(string hash, RQbitSettings settings) + { + var result = true; + var rqBitTorrentResponse = GetTorrent(hash, settings); + if (rqBitTorrentResponse == null || string.IsNullOrWhiteSpace(rqBitTorrentResponse.InfoHash)) + { + result = false; + } + return result; } - var jsonStr = Encoding.UTF8.GetString(itemResponse.ResponseData); - result = JsonConvert.DeserializeObject(jsonStr); - - return result; - } - - private HttpRequestBuilder BuildRequest(RQbitSettings settings) - { - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + private TorrentResponse GetTorrent(string infoHash, RQbitSettings settings) { - LogResponseContent = true, - }; - return requestBuilder; + TorrentResponse result = null; + var itemRequest = BuildRequest(settings).Resource("/torrents/" + infoHash); + var itemResponse = _httpClient.Get(itemRequest.Build()); + if (itemResponse.StatusCode != HttpStatusCode.OK) + { + return result; + } + + result = JsonConvert.DeserializeObject(itemResponse.Content); + + return result; + } + + private HttpRequestBuilder BuildRequest(RQbitSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + }; + return requestBuilder; + } } } diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs deleted file mode 100644 index f67ad9fdf6..0000000000 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentV1StatResponse.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; - -public class TorrentV1StatResponse -{ - [JsonProperty("state")] - public string State { get; set; } - - [JsonProperty("file_progress")] - public List FileProgress { get; set; } - - [JsonProperty("error")] - public string Error { get; set; } - - [JsonProperty("progress_bytes")] - public long ProgressBytes { get; set; } - - [JsonProperty("uploaded_bytes")] - public long UploadedBytes { get; set; } - - [JsonProperty("total_bytes")] - public long TotalBytes { get; set; } - - [JsonProperty("finished")] - public bool Finished { get; set; } - - [JsonProperty("live")] - public TorrentV1StatLiveResponse Live { get; set; } -} - -public class RQBitTorrentSpeedResponse -{ - [JsonProperty("mbps")] - public long Mbps { get; set; } - - [JsonProperty("human_readable")] - public string HumanReadable { get; set; } -} - -public class TorrentV1StatLiveResponse -{ - [JsonProperty("snapshot")] - public TorrentV1StatLiveSnapshotResponse Snapshot { get; set; } - [JsonProperty("download_speed")] - public RQBitTorrentSpeedResponse DownloadSpeed { get; set; } - [JsonProperty("upload_speed")] - public RQBitTorrentSpeedResponse UploadSpeed { get; set; } -} - -public class TorrentV1StatLiveSnapshotResponse -{ - [JsonProperty("downloaded_and_checked_bytes")] - public long DownloadedAndCheckedBytes { get; set; } - - [JsonProperty("fetched_bytes")] - public long FetchedBytes { get; set; } - - [JsonProperty("uploaded_bytes")] - public long UploadedBytes { get; set; } - - [JsonProperty("downloaded_and_checked_pieces")] - public long DownloadedAndCheckedPieces { get; set; } - - [JsonProperty("total_piece_downloaded_ms")] - public long TotalPieceDownloadedMs { get; set; } - - [JsonProperty("peer_stats")] - public TorrentV1StatLiveSnapshotPeerStatsResponse PeerStats { get; set; } -} - -public class TorrentV1StatLiveSnapshotPeerStatsResponse -{ - [JsonProperty("queued")] - public int Queued { get; set; } - [JsonProperty("connecting")] - public int Connecting { get; set; } - [JsonProperty("live")] - public int Live { get; set; } - [JsonProperty("seen")] - public int Seen { get; set; } - [JsonProperty("dead")] - public int Dead { get; set; } - [JsonProperty("not_needed")] - public int NotNeeded { get; set; } - [JsonProperty("steals")] - public int Steals { get; set; } -} diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs new file mode 100644 index 0000000000..19fb71bb51 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels +{ + public class TorrentWithStatsListResponse + { + [JsonProperty("torrents")] + public List Torrents { get; set; } + } + + public class TorrentWithStatsResponse + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("info_hash")] + public string InfoHash { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("output_folder")] + public string OutputFolder { get; set; } + + [JsonProperty("stats")] + public TorrentStatsResponse Stats { get; set; } + } + + public class TorrentStatsResponse + { + [JsonProperty("state")] + public string State { get; set; } + + [JsonProperty("file_progress")] + public List FileProgress { get; set; } + + [JsonProperty("error")] + public string Error { get; set; } + + [JsonProperty("progress_bytes")] + public long ProgressBytes { get; set; } + + [JsonProperty("uploaded_bytes")] + public long UploadedBytes { get; set; } + + [JsonProperty("total_bytes")] + public long TotalBytes { get; set; } + + [JsonProperty("finished")] + public bool Finished { get; set; } + + [JsonProperty("live")] + public TorrentLiveStatsResponse Live { get; set; } + } + + public class TorrentLiveStatsResponse + { + [JsonProperty("snapshot")] + public TorrentSnapshotResponse Snapshot { get; set; } + + [JsonProperty("download_speed")] + public TorrentSpeedResponse DownloadSpeed { get; set; } + + [JsonProperty("upload_speed")] + public TorrentSpeedResponse UploadSpeed { get; set; } + + [JsonProperty("time_remaining")] + public TorrentTimeRemainingResponse TimeRemaining { get; set; } + } + + public class TorrentSnapshotResponse + { + [JsonProperty("downloaded_and_checked_bytes")] + public long DownloadedAndCheckedBytes { get; set; } + + [JsonProperty("fetched_bytes")] + public long FetchedBytes { get; set; } + + [JsonProperty("uploaded_bytes")] + public long UploadedBytes { get; set; } + + [JsonProperty("peer_stats")] + public TorrentPeerStatsResponse PeerStats { get; set; } + } + + public class TorrentSpeedResponse + { + [JsonProperty("mbps")] + public double Mbps { get; set; } + + [JsonProperty("human_readable")] + public string HumanReadable { get; set; } + } + + public class TorrentTimeRemainingResponse + { + [JsonProperty("duration")] + public TorrentDurationResponse Duration { get; set; } + + [JsonProperty("human_readable")] + public string HumanReadable { get; set; } + } + + public class TorrentDurationResponse + { + [JsonProperty("secs")] + public long Secs { get; set; } + + [JsonProperty("nanos")] + public long Nanos { get; set; } + } + + public class TorrentPeerStatsResponse + { + [JsonProperty("queued")] + public int Queued { get; set; } + + [JsonProperty("connecting")] + public int Connecting { get; set; } + + [JsonProperty("live")] + public int Live { get; set; } + + [JsonProperty("seen")] + public int Seen { get; set; } + + [JsonProperty("dead")] + public int Dead { get; set; } + + [JsonProperty("not_needed")] + public int NotNeeded { get; set; } + + [JsonProperty("steals")] + public int Steals { get; set; } + } +} From 9779d994ee4e123bd14019cfc870748a0e02a89d Mon Sep 17 00:00:00 2001 From: Mare Salis Date: Thu, 24 Jul 2025 14:48:48 +0200 Subject: [PATCH 07/10] Add RQBit translation keys --- .../Download/Clients/RQBit/RQbitSettings.cs | 13 +++++++------ src/NzbDrone.Core/Localization/Core/en.json | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs index e547da3263..1e852381ae 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs @@ -1,11 +1,11 @@ using NzbDrone.Core.Annotations; using NzbDrone.Core.Validation; -namespace NzbDrone.Core.Download.Clients.RQbit +namespace NzbDrone.Core.Download.Clients.RQBit { public class RQbitSettings : DownloadClientSettingsBase { - private static readonly RQbitSettingsValidator Validator = new RQbitSettingsValidator(); + private static readonly RQbitSettingsValidator Validator = new (); public RQbitSettings() { @@ -20,12 +20,12 @@ public RQbitSettings() [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox)] public int Port { get; set; } - [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox, HelpText = "DownloadClientQbittorrentSettingsUseSslHelpText")] + [FieldDefinition(2, Label = "UseSsl", Type = FieldType.Checkbox)] + [FieldToken(TokenField.HelpText, "DownloadClientRQbitSettingsUseSslHelpText")] public bool UseSsl { get; set; } - [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsUrlBaseHelpText")] - [FieldToken(TokenField.HelpText, "UrlBase", "clientName", "RQBit")] - [FieldToken(TokenField.HelpText, "UrlBase", "url", "http://[host]:[port]/")] + [FieldDefinition(3, Label = "UrlBase", Type = FieldType.Textbox, Advanced = true)] + [FieldToken(TokenField.HelpText, "DownloadClientRQbitSettingsUrlBaseHelpText")] public string UrlBase { get; set; } public override NzbDroneValidationResult Validate() @@ -34,3 +34,4 @@ public override NzbDroneValidationResult Validate() } } } + \ No newline at end of file diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 47d10fa767..47300f250c 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -544,6 +544,8 @@ "DownloadClientTransmissionSettingsUrlBaseHelpText": "Adds a prefix to the {clientName} rpc url, eg {url}, defaults to '{defaultUrl}'", "DownloadClientUTorrentProviderMessage": "uTorrent has a history of including cryptominers, malware and ads, we strongly encourage you to choose a different client.", "DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error", + "DownloadClientRQbitSettingsUseSslHelpText": "Use SSL when connecting to RQBit", + "DownloadClientRQbitSettingsUrlBaseHelpText": "Adds a prefix to the RQBit API, such as /rqbit", "DownloadClientUnavailable": "Download Client Unavailable", "DownloadClientValidationApiKeyIncorrect": "API Key Incorrect", "DownloadClientValidationApiKeyRequired": "API Key Required", From 710c4ab31485f9e0d42d1d1b5bf25df3b083fabe Mon Sep 17 00:00:00 2001 From: Alexander Westber Date: Sun, 27 Jul 2025 14:12:26 +0200 Subject: [PATCH 08/10] Add proxy selector --- .../Download/Clients/RQBit/RQBit.cs | 62 ++++++---- .../Clients/RQBit/RQbitProxySelector.cs | 112 ++++++++++++++++++ 2 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs index ba508d57ba..c0f9405ed5 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs @@ -7,38 +7,38 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.Clients.RQbit; -using NzbDrone.Core.Download.Clients.rTorrent; +using NzbDrone.Core.Download.Clients.RQBit; using NzbDrone.Core.Localization; using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Validation; -namespace NzbDrone.Core.Download.Clients.rQbit +namespace NzbDrone.Core.Download.Clients.RQBit { public class RQBit : TorrentClientBase { - private readonly IRQbitProxy _proxy; + private readonly IRQbitProxySelector _proxySelector; private readonly IDownloadSeedConfigProvider _downloadSeedConfigProvider; - public RQBit(IRQbitProxy proxy, + public RQBit(IRQbitProxySelector proxySelector, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, IConfigService configService, IDiskProvider diskProvider, IRemotePathMappingService remotePathMappingService, IDownloadSeedConfigProvider downloadSeedConfigProvider, - IRTorrentDirectoryValidator rTorrentDirectoryValidator, ILocalizationService localizationService, IBlocklistService blocklistService, Logger logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, localizationService, blocklistService, logger) { - _proxy = proxy; + _proxySelector = proxySelector; _downloadSeedConfigProvider = downloadSeedConfigProvider; } + private IRQbitProxy _proxy => _proxySelector.GetProxy(Settings); + public override IEnumerable GetItems() { var torrents = _proxy.GetTorrents(Settings); @@ -48,18 +48,18 @@ public override IEnumerable GetItems() var items = new List(); foreach (var torrent in torrents) { - // // Ignore torrents with an empty path - // if (torrent.Path.IsNullOrWhiteSpace()) - // { - // _logger.Warn("Torrent '{0}' has an empty download path and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name); - // continue; - // } - // - // if (torrent.Path.StartsWith(".")) - // { - // _logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed. Adjust this to an absolute path in rTorrent", torrent.Name); - // continue; - // } + // Ignore torrents with an empty path + if (torrent.Path.IsNullOrWhiteSpace()) + { + _logger.Warn("Torrent '{0}' has an empty download path and will not be processed", torrent.Name); + continue; + } + + if (torrent.Path.StartsWith(".")) + { + _logger.Warn("Torrent '{0}' has a download path starting with '.' and will not be processed", torrent.Name); + continue; + } var item = new DownloadClientItem(); item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false); @@ -148,8 +148,7 @@ protected override void Test(List failures) return; } - // failures.AddIfNotNull(TestGetTorrents()); - // failures.AddIfNotNull(TestDirectory()); + failures.AddIfNotNull(TestVersion()); } private ValidationFailure TestConnection() @@ -158,6 +157,27 @@ private ValidationFailure TestConnection() return null; } + private ValidationFailure TestVersion() + { + try + { + var apiVersion = _proxySelector.GetApiVersion(Settings); + var minimumVersion = new Version(8, 0, 0); + + if (apiVersion < minimumVersion) + { + return new ValidationFailure("", $"RQBit version {apiVersion} is not supported. Please upgrade to version {minimumVersion} or higher."); + } + + return null; + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to determine RQBit version"); + return new ValidationFailure("", "Unable to determine RQBit version. Please check that RQBit is running and accessible."); + } + } + protected override string AddFromMagnetLink(RemoteMovie remoteMovie, string hash, string magnetLink) { return _proxy.AddTorrentFromUrl(magnetLink, Settings); diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs new file mode 100644 index 0000000000..e047f250d6 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs @@ -0,0 +1,112 @@ +namespace NzbDrone.Core.Download.Clients.RQBit +{ + using System; + using NLog; + using NzbDrone.Common.Cache; + + public interface IRQbitProxySelector + { + IRQbitProxy GetProxy(RQbitSettings settings, bool force = false); + Version GetApiVersion(RQbitSettings settings, bool force = false); + } + + public class RQbitProxySelector : IRQbitProxySelector + { + private readonly ICached> _proxyCache; + private readonly Logger _logger; + private readonly IRQbitProxy _proxyV1; + + public RQbitProxySelector(RQbitProxy proxyV1, ICacheManager cacheManager, Logger logger) + { + _proxyCache = cacheManager.GetCache>(GetType()); + _logger = logger; + _proxyV1 = proxyV1; + } + + public IRQbitProxy GetProxy(RQbitSettings settings, bool force) + { + return GetProxyCache(settings, force).Item1; + } + + public Version GetApiVersion(RQbitSettings settings, bool force) + { + return GetProxyCache(settings, force).Item2; + } + + private Tuple GetProxyCache(RQbitSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private Tuple FetchProxy(RQbitSettings settings) + { + // For now, we only have one API version, but this pattern allows for future extensions + if (_proxyV1.IsApiSupported(settings)) + { + var version = ParseVersion(_proxyV1.GetVersion(settings)); + _logger.Trace("Using RQBit API v1, detected version: {0}", version); + return Tuple.Create(_proxyV1, version); + } + + throw new DownloadClientException("Unable to determine RQBit API version or RQBit is not responding"); + } + + private Version ParseVersion(string versionString) + { + // RQBit version might be in different formats, try to parse it safely + if (string.IsNullOrWhiteSpace(versionString)) + { + return new Version(1, 0, 0); + } + + try + { + // Remove any non-numeric prefix/suffix and try to parse as version + var cleanVersion = versionString.Trim().TrimStart('v'); + + // Handle semantic versioning (e.g., "8.1.0", "8.0.0-rc1") + // Split on '-' to remove pre-release identifiers + var versionPart = cleanVersion.Split('-')[0]; + + // If it's just numbers with dots, parse as version + if (Version.TryParse(versionPart, out var version)) + { + return version; + } + + // Try to extract just the major.minor.patch numbers using regex + var match = System.Text.RegularExpressions.Regex.Match(cleanVersion, @"(\d+)\.(\d+)\.(\d+)"); + if (match.Success) + { + var major = int.Parse(match.Groups[1].Value); + var minor = int.Parse(match.Groups[2].Value); + var patch = int.Parse(match.Groups[3].Value); + return new Version(major, minor, patch); + } + + // Try major.minor format + var simpleMatch = System.Text.RegularExpressions.Regex.Match(cleanVersion, @"(\d+)\.(\d+)"); + if (simpleMatch.Success) + { + var major = int.Parse(simpleMatch.Groups[1].Value); + var minor = int.Parse(simpleMatch.Groups[2].Value); + return new Version(major, minor, 0); + } + + // If parsing fails, default to 1.0.0 + return new Version(1, 0, 0); + } + catch + { + return new Version(1, 0, 0); + } + } + } +} From 56e8f95dc67d3ca61e35f8f8348ba81457e22be3 Mon Sep 17 00:00:00 2001 From: Alexander Westber Date: Sun, 27 Jul 2025 14:12:29 +0200 Subject: [PATCH 09/10] Add test fixture --- .../RQBitTests/RQBitFixture.cs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs new file mode 100644 index 0000000000..93f09e88ef --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RQBitTests/RQBitFixture.cs @@ -0,0 +1,241 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.RQBit; +using NzbDrone.Core.MediaFiles.TorrentInfo; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.RQBitTests +{ + [TestFixture] + public class RQBitFixture : DownloadClientFixtureBase + { + protected RQBitTorrent _queued; + protected RQBitTorrent _downloading; + protected RQBitTorrent _failed; + protected RQBitTorrent _completed; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new RQbitSettings + { + Host = "127.0.0.1", + Port = 3030, + UseSsl = false + }; + + _queued = new RQBitTorrent + { + Hash = "HASH", + IsFinished = false, + IsActive = false, + Name = _title, + TotalSize = 1000, + RemainingSize = 1000, + Path = "somepath" + }; + + _downloading = new RQBitTorrent + { + Hash = "HASH", + IsFinished = false, + IsActive = true, + Name = _title, + TotalSize = 1000, + RemainingSize = 100, + Path = "somepath" + }; + + _failed = new RQBitTorrent + { + Hash = "HASH", + IsFinished = false, + IsActive = false, + Name = _title, + TotalSize = 1000, + RemainingSize = 1000, + Path = "somepath" + }; + + _completed = new RQBitTorrent + { + Hash = "HASH", + IsFinished = true, + IsActive = false, + Name = _title, + TotalSize = 1000, + RemainingSize = 0, + Path = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + } + + protected void GivenSuccessfulDownload() + { + var mockProxy = new Mock(); + mockProxy.Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + mockProxy.Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(mockProxy.Object); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + var mockProxy = new Mock(); + mockProxy.Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(mockProxy.Object); + } + + protected void PrepareClientToReturnQueuedItem() + { + GivenTorrents(new List + { + _queued + }); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnFailedItem() + { + GivenTorrents(new List + { + _failed + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void queued_item_should_have_required_properties() + { + PrepareClientToReturnQueuedItem(); + var item = Subject.GetItems().Single(); + VerifyPaused(item); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void failed_item_should_have_required_properties() + { + PrepareClientToReturnFailedItem(); + var item = Subject.GetItems().Single(); + VerifyPaused(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public async Task Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteMovie = CreateRemoteMovie(); + + var id = await Subject.Download(remoteMovie, CreateIndexer()); + + id.Should().NotBeNullOrEmpty(); + } + + [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")] + public async Task Download_should_get_hash_from_magnet_url(string magnetUrl, string expectedHash) + { + GivenSuccessfulDownload(); + + var remoteMovie = CreateRemoteMovie(); + remoteMovie.Release.DownloadUrl = magnetUrl; + + var id = await Subject.Download(remoteMovie, CreateIndexer()); + + id.Should().Be(expectedHash); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + } + + [Test] + public void GetItems_should_ignore_torrents_with_empty_path() + { + var torrents = new List + { + new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "" }, + new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/valid/path" } + }; + + GivenTorrents(torrents); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Title.Should().Be("Test2"); + } + + [Test] + public void GetItems_should_ignore_torrents_with_relative_path() + { + var torrents = new List + { + new RQBitTorrent { Name = "Test1", Hash = "Hash1", Path = "./relative/path" }, + new RQBitTorrent { Name = "Test2", Hash = "Hash2", Path = "/absolute/path" } + }; + + GivenTorrents(torrents); + + var items = Subject.GetItems(); + + items.Should().HaveCount(1); + items.First().Title.Should().Be("Test2"); + } + } +} From 5be82e047a16fe3d44c4e8b1006d54ce0511c3e1 Mon Sep 17 00:00:00 2001 From: Alexander Westber Date: Sun, 27 Jul 2025 14:12:32 +0200 Subject: [PATCH 10/10] Fix minor style issues --- src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs | 1 - src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs | 5 +---- .../Download/Clients/RQBit/RQbitProxySelector.cs | 8 ++++---- src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs | 1 - .../Download/Clients/RQBit/RQbitSettingsValidator.cs | 4 ++-- .../Download/Clients/RQBit/ResponseModels/RootResponse.cs | 4 ++-- .../Clients/RQBit/ResponseModels/TorrentStatus.cs | 2 +- .../RQBit/ResponseModels/TorrentWithStatsResponse.cs | 2 +- 8 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs index c0f9405ed5..1851170eea 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQBit.cs @@ -7,7 +7,6 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Download.Clients.RQBit; using NzbDrone.Core.Localization; using NzbDrone.Core.MediaFiles.TorrentInfo; using NzbDrone.Core.Parser.Model; diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs index 4ac9ca74d6..97e91d0e9f 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxy.cs @@ -1,12 +1,9 @@ -using System; +using System; using System.Collections.Generic; using System.Net; -using System.Linq; using Newtonsoft.Json; using NLog; -using NzbDrone.Common.Cache; using NzbDrone.Common.Http; -using NzbDrone.Core.Download.Clients.RQBit; using NzbDrone.Core.Download.Clients.RQBit.ResponseModels; namespace NzbDrone.Core.Download.Clients.RQBit diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs index e047f250d6..34d6f395c3 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitProxySelector.cs @@ -1,9 +1,9 @@ +using System; +using NLog; +using NzbDrone.Common.Cache; + namespace NzbDrone.Core.Download.Clients.RQBit { - using System; - using NLog; - using NzbDrone.Common.Cache; - public interface IRQbitProxySelector { IRQbitProxy GetProxy(RQbitSettings settings, bool force = false); diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs index 1e852381ae..fe8c3fe527 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettings.cs @@ -34,4 +34,3 @@ public override NzbDroneValidationResult Validate() } } } - \ No newline at end of file diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs index bae434bd11..059a7652b6 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/RQbitSettingsValidator.cs @@ -1,7 +1,7 @@ -using FluentValidation; +using FluentValidation; using NzbDrone.Core.Validation; -namespace NzbDrone.Core.Download.Clients.RQbit; +namespace NzbDrone.Core.Download.Clients.RQBit; public class RQbitSettingsValidator : AbstractValidator { diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs index f3f7af1e97..a954ea04e1 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/RootResponse.cs @@ -1,7 +1,7 @@ -using System.Collections.Generic; +using System.Collections.Generic; using Newtonsoft.Json; -namespace NzbDrone.Core.Download.Clients.rQbit; +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; public class RootResponse { diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs index d4a6dcbea5..c98571cea1 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentStatus.cs @@ -1,4 +1,4 @@ -namespace NzbDrone.Core.Download.Clients.RQBit; +namespace NzbDrone.Core.Download.Clients.RQBit.ResponseModels; // https://github.com/ikatson/rqbit/blob/946ad3625892f4f40dde3d0e6bbc3030f68a973c/crates/librqbit/src/torrent_state/mod.rs#L65 public enum TorrentStatus diff --git a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs index 19fb71bb51..93878f65c9 100644 --- a/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs +++ b/src/NzbDrone.Core/Download/Clients/RQBit/ResponseModels/TorrentWithStatsResponse.cs @@ -134,4 +134,4 @@ public class TorrentPeerStatsResponse [JsonProperty("steals")] public int Steals { get; set; } } -} +}