From 258847a83b4935b680dc95da1d90683fa310e35f Mon Sep 17 00:00:00 2001 From: ta264 Date: Thu, 6 Jan 2022 21:12:31 +0000 Subject: [PATCH] Fixed: Use our own HttpClient for rTorrent RPC requests --- .../Http/XmlRpcRequestBuilder.cs | 103 ++++++++ .../Download/Clients/rTorrent/RTorrent.cs | 6 + .../Clients/rTorrent/RTorrentFault.cs | 28 ++ .../Clients/rTorrent/RTorrentProxy.cs | 247 ++++++------------ .../Clients/rTorrent/RTorrentTorrent.cs | 30 ++- .../Download/Extensions/XmlExtensions.cs | 55 ++++ 6 files changed, 306 insertions(+), 163 deletions(-) create mode 100644 src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs create mode 100644 src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs create mode 100644 src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs diff --git a/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs new file mode 100644 index 000000000..e03161702 --- /dev/null +++ b/src/NzbDrone.Common/Http/XmlRpcRequestBuilder.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Xml.Linq; +using NLog; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Common.Http +{ + public class XmlRpcRequestBuilder : HttpRequestBuilder + { + public static string XmlRpcContentType = "text/xml"; + + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(XmlRpcRequestBuilder)); + + public string XmlMethod { get; private set; } + public List XmlParameters { get; private set; } + + public XmlRpcRequestBuilder(string baseUrl) + : base(baseUrl) + { + Method = HttpMethod.Post; + XmlParameters = new List(); + } + + public XmlRpcRequestBuilder(bool useHttps, string host, int port, string urlBase = null) + : this(BuildBaseUrl(useHttps, host, port, urlBase)) + { + } + + public override HttpRequestBuilder Clone() + { + var clone = base.Clone() as XmlRpcRequestBuilder; + clone.XmlParameters = new List(XmlParameters); + return clone; + } + + public XmlRpcRequestBuilder Call(string method, params object[] parameters) + { + var clone = Clone() as XmlRpcRequestBuilder; + clone.XmlMethod = method; + clone.XmlParameters = parameters.ToList(); + return clone; + } + + protected override void Apply(HttpRequest request) + { + base.Apply(request); + + request.Headers.ContentType = XmlRpcContentType; + + var methodCallElements = new List { new XElement("methodName", XmlMethod) }; + + if (XmlParameters.Any()) + { + var argElements = XmlParameters.Select(x => new XElement("param", ConvertParameter(x))).ToList(); + var paramsElement = new XElement("params", argElements); + methodCallElements.Add(paramsElement); + } + + var message = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("methodCall", methodCallElements)); + + var body = message.ToString(); + + Logger.Debug($"Executing remote method: {XmlMethod}"); + + Logger.Trace($"methodCall {XmlMethod} body:\n{body}"); + + request.SetContent(body); + } + + private static XElement ConvertParameter(object value) + { + XElement data; + + if (value is string s) + { + data = new XElement("string", s); + } + else if (value is List l) + { + data = new XElement("array", new XElement("data", l.Select(x => new XElement("value", new XElement("string", x))))); + } + else if (value is int i) + { + data = new XElement("int", i); + } + else if (value is byte[] bytes) + { + data = new XElement("base64", Convert.ToBase64String(bytes)); + } + else + { + throw new InvalidOperationException($"Unhandled argument type {value.GetType().Name}"); + } + + return new XElement("value", data); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs index 313600955..e8a68405e 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -132,6 +132,12 @@ namespace NzbDrone.Core.Download.Clients.RTorrent continue; } + // Ignore torrents with an empty path + if (torrent.Path.IsNullOrWhiteSpace()) + { + continue; + } + if (torrent.Path.StartsWith(".")) { throw new DownloadClientException("Download paths must be absolute. Please specify variable \"directory\" in rTorrent."); diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs new file mode 100644 index 000000000..ba1a23962 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentFault.cs @@ -0,0 +1,28 @@ +using System.Xml.Linq; +using System.Xml.XPath; +using NzbDrone.Core.Download.Extensions; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentFault + { + public RTorrentFault(XElement element) + { + foreach (var e in element.XPathSelectElements("./value/struct/member")) + { + var name = e.ElementAsString("name"); + if (name == "faultCode") + { + FaultCode = e.Element("value").GetIntValue(); + } + else if (name == "faultString") + { + FaultString = e.Element("value").GetStringValue(); + } + } + } + + public int FaultCode { get; set; } + public string FaultString { get; set; } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs index e41a3e87c..fb32c0598 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; -using CookComputing.XmlRpc; -using NLog; +using System.Xml.Linq; +using System.Xml.XPath; using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; +using NzbDrone.Core.Download.Extensions; namespace NzbDrone.Core.Download.Clients.RTorrent { @@ -21,123 +23,67 @@ namespace NzbDrone.Core.Download.Clients.RTorrent void PushTorrentUniqueView(string hash, string view, RTorrentSettings settings); } - public interface IRTorrent : IXmlRpcProxy - { - [XmlRpcMethod("d.multicall2")] - object[] TorrentMulticall(params string[] parameters); - - [XmlRpcMethod("load.normal")] - int LoadNormal(string target, string data, params string[] commands); - - [XmlRpcMethod("load.start")] - int LoadStart(string target, string data, params string[] commands); - - [XmlRpcMethod("load.raw")] - int LoadRaw(string target, byte[] data, params string[] commands); - - [XmlRpcMethod("load.raw_start")] - int LoadRawStart(string target, byte[] data, params string[] commands); - - [XmlRpcMethod("d.erase")] - int Remove(string hash); - - [XmlRpcMethod("d.name")] - string GetName(string hash); - - [XmlRpcMethod("d.custom1.set")] - string SetLabel(string hash, string label); - - [XmlRpcMethod("d.views.push_back_unique")] - int PushUniqueView(string hash, string view); - - [XmlRpcMethod("system.client_version")] - string GetVersion(); - } - public class RTorrentProxy : IRTorrentProxy { - private readonly Logger _logger; + private readonly IHttpClient _httpClient; - public RTorrentProxy(Logger logger) + public RTorrentProxy(IHttpClient httpClient) { - _logger = logger; + _httpClient = httpClient; } public string GetVersion(RTorrentSettings settings) { - _logger.Debug("Executing remote method: system.client_version"); - - var client = BuildClient(settings); - var version = ExecuteRequest(() => client.GetVersion()); + var document = ExecuteRequest(settings, "system.client_version"); - return version; + return document.Descendants("string").FirstOrDefault()?.Value ?? "0.0.0"; } public List GetTorrents(RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.multicall2"); - - var client = BuildClient(settings); - var ret = ExecuteRequest(() => client.TorrentMulticall( - "", - "", - "d.name=", // string - "d.hash=", // string - "d.base_path=", // string - "d.custom1=", // string (label) - "d.size_bytes=", // long - "d.left_bytes=", // long - "d.down.rate=", // long (in bytes / s) - "d.ratio=", // long - "d.is_open=", // long - "d.is_active=", // long - "d.complete=", // long - "d.timestamp.finished=")); // long (unix timestamp) - - var items = new List(); - - foreach (object[] torrent in ret) - { - var labelDecoded = System.Web.HttpUtility.UrlDecode((string)torrent[3]); - - var item = new RTorrentTorrent(); - item.Name = (string)torrent[0]; - item.Hash = (string)torrent[1]; - item.Path = (string)torrent[2]; - item.Category = labelDecoded; - item.TotalSize = (long)torrent[4]; - item.RemainingSize = (long)torrent[5]; - item.DownRate = (long)torrent[6]; - item.Ratio = (long)torrent[7]; - item.IsOpen = Convert.ToBoolean((long)torrent[8]); - item.IsActive = Convert.ToBoolean((long)torrent[9]); - item.IsFinished = Convert.ToBoolean((long)torrent[10]); - item.FinishedTime = (long)torrent[11]; - - items.Add(item); - } - - return items; + var document = ExecuteRequest(settings, + "d.multicall2", + "", + "", + "d.name=", // string + "d.hash=", // string + "d.base_path=", // string + "d.custom1=", // string (label) + "d.size_bytes=", // long + "d.left_bytes=", // long + "d.down.rate=", // long (in bytes / s) + "d.ratio=", // long + "d.is_open=", // long + "d.is_active=", // long + "d.complete=", //long + "d.timestamp.finished="); // long (unix timestamp) + + var torrents = document.XPathSelectElement("./methodResponse/params/param/value/array/data") + ?.Elements() + .Select(x => new RTorrentTorrent(x)) + .ToList() + ?? new List(); + + return torrents; } public void AddTorrentFromUrl(string torrentUrl, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - var client = BuildClient(settings); - var response = ExecuteRequest(() => + var args = new List { "", torrentUrl }; + args.AddRange(GetCommands(label, priority, directory)); + + XDocument response; + + if (settings.AddStopped) { - if (settings.AddStopped) - { - _logger.Debug("Executing remote method: load.normal"); - return client.LoadNormal("", torrentUrl, GetCommands(label, priority, directory)); - } - else - { - _logger.Debug("Executing remote method: load.start"); - return client.LoadStart("", torrentUrl, GetCommands(label, priority, directory)); - } - }); + response = ExecuteRequest(settings, "load.normal", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.start", args.ToArray()); + } - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); } @@ -145,22 +91,21 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void AddTorrentFromFile(string fileName, byte[] fileContent, string label, RTorrentPriority priority, string directory, RTorrentSettings settings) { - var client = BuildClient(settings); - var response = ExecuteRequest(() => + var args = new List { "", fileContent }; + args.AddRange(GetCommands(label, priority, directory)); + + XDocument response; + + if (settings.AddStopped) { - if (settings.AddStopped) - { - _logger.Debug("Executing remote method: load.raw"); - return client.LoadRaw("", fileContent, GetCommands(label, priority, directory)); - } - else - { - _logger.Debug("Executing remote method: load.raw_start"); - return client.LoadRawStart("", fileContent, GetCommands(label, priority, directory)); - } - }); + response = ExecuteRequest(settings, "load.raw", args.ToArray()); + } + else + { + response = ExecuteRequest(settings, "load.raw_start", args.ToArray()); + } - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not add torrent: {0}.", fileName); } @@ -168,12 +113,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void SetTorrentLabel(string hash, string label, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.custom1.set"); + var response = ExecuteRequest(settings, "d.custom1.set", hash, label); - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.SetLabel(hash, label)); - - if (response != label) + if (response.GetStringResponse() != label) { throw new DownloadClientException("Could not set label to {1} for torrent: {0}.", hash, label); } @@ -181,11 +123,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void PushTorrentUniqueView(string hash, string view, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.views.push_back_unique"); + var response = ExecuteRequest(settings, "d.views.push_back_unique", hash, view); - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.PushUniqueView(hash, view)); - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not push unique view {0} for torrent: {1}.", view, hash); } @@ -193,12 +133,9 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public void RemoveTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.erase"); - - var client = BuildClient(settings); - var response = ExecuteRequest(() => client.Remove(hash)); + var response = ExecuteRequest(settings, "d.erase", hash); - if (response != 0) + if (response.GetIntResponse() != 0) { throw new DownloadClientException("Could not remove torrent: {0}.", hash); } @@ -206,13 +143,10 @@ namespace NzbDrone.Core.Download.Clients.RTorrent public bool HasHashTorrent(string hash, RTorrentSettings settings) { - _logger.Debug("Executing remote method: d.name"); - - var client = BuildClient(settings); - try { - var name = ExecuteRequest(() => client.GetName(hash)); + var response = ExecuteRequest(settings, "d.name", hash); + var name = response.GetStringResponse(); if (name.IsNullOrWhiteSpace()) { @@ -251,45 +185,34 @@ namespace NzbDrone.Core.Download.Clients.RTorrent return result.ToArray(); } - private IRTorrent BuildClient(RTorrentSettings settings) + private XDocument ExecuteRequest(RTorrentSettings settings, string methodName, params object[] args) { - var client = XmlRpcProxyGen.Create(); - - client.Url = string.Format(@"{0}://{1}:{2}/{3}", - settings.UseSsl ? "https" : "http", - settings.Host, - settings.Port, - settings.UrlBase); - - client.EnableCompression = true; + var requestBuilder = new XmlRpcRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase) + { + LogResponseContent = true, + }; if (!settings.Username.IsNullOrWhiteSpace()) { - client.Credentials = new BasicNetworkCredential(settings.Username, settings.Password); + requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); } - return client; - } + var request = requestBuilder.Call(methodName, args).Build(); - private T ExecuteRequest(Func task) - { - try - { - return task(); - } - catch (XmlRpcServerException ex) - { - throw new DownloadClientException("Unable to connect to rTorrent, please check your settings", ex); - } - catch (WebException ex) + var response = _httpClient.Execute(request); + + var doc = XDocument.Parse(response.Content); + + var faultElement = doc.XPathSelectElement("./methodResponse/fault"); + + if (faultElement != null) { - if (ex.Status == WebExceptionStatus.TrustFailure) - { - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, certificate validation failed.", ex); - } + var fault = new RTorrentFault(faultElement); - throw new DownloadClientUnavailableException("Unable to connect to rTorrent, please check your settings", ex); + throw new DownloadClientException($"rTorrent returned error code {fault.FaultCode}: {fault.FaultString}"); } + + return doc; } } } diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs index 14cd0b346..75573b0e9 100644 --- a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs @@ -1,7 +1,35 @@ -namespace NzbDrone.Core.Download.Clients.RTorrent +using System; +using System.Linq; +using System.Web; +using System.Xml.Linq; +using NzbDrone.Core.Download.Extensions; + +namespace NzbDrone.Core.Download.Clients.RTorrent { public class RTorrentTorrent { + public RTorrentTorrent() + { + } + + public RTorrentTorrent(XElement element) + { + var data = element.Descendants("value").ToList(); + + Name = data[0].GetStringValue(); + Hash = data[1].GetStringValue(); + Path = data[2].GetStringValue(); + Category = HttpUtility.UrlDecode(data[3].GetStringValue()); + TotalSize = data[4].GetLongValue(); + RemainingSize = data[5].GetLongValue(); + DownRate = data[6].GetLongValue(); + Ratio = data[7].GetLongValue(); + IsOpen = Convert.ToBoolean(data[8].GetLongValue()); + IsActive = Convert.ToBoolean(data[9].GetLongValue()); + IsFinished = Convert.ToBoolean(data[10].GetLongValue()); + FinishedTime = data[11].GetLongValue(); + } + public string Name { get; set; } public string Hash { get; set; } public string Path { get; set; } diff --git a/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs new file mode 100644 index 000000000..1e9deec9f --- /dev/null +++ b/src/NzbDrone.Core/Download/Extensions/XmlExtensions.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; + +namespace NzbDrone.Core.Download.Extensions +{ + internal static class XmlExtensions + { + public static string GetStringValue(this XElement element) + { + return element.ElementAsString("string"); + } + + public static long GetLongValue(this XElement element) + { + return element.ElementAsLong("i8"); + } + + public static int GetIntValue(this XElement element) + { + return element.ElementAsInt("i4"); + } + + public static string ElementAsString(this XElement element, XName name, bool trim = false) + { + var el = element.Element(name); + + return string.IsNullOrWhiteSpace(el?.Value) + ? null + : (trim ? el.Value.Trim() : el.Value); + } + + public static long ElementAsLong(this XElement element, XName name) + { + var el = element.Element(name); + return long.TryParse(el?.Value, out long value) ? value : default; + } + + public static int ElementAsInt(this XElement element, XName name) + { + var el = element.Element(name); + return int.TryParse(el?.Value, out int value) ? value : default(int); + } + + public static int GetIntResponse(this XDocument document) + { + return document.XPathSelectElement("./methodResponse/params/param/value").GetIntValue(); + } + + public static string GetStringResponse(this XDocument document) + { + return document.XPathSelectElement("./methodResponse/params/param/value").GetStringValue(); + } + } +}