diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs new file mode 100644 index 000000000..034693bea --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/RTorrentTests/RTorrentFixture.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using NzbDrone.Common.Http; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NzbDrone.Core.Download; +using NzbDrone.Core.Download.Clients.RTorrent; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.RTorrentTests +{ + [TestFixture] + public class RTorrentFixture : DownloadClientFixtureBase + { + protected RTorrentTorrent _downloading; + protected RTorrentTorrent _completed; + + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new RTorrentSettings() + { + TvCategory = null + }; + + _downloading = new RTorrentTorrent + { + Hash = "HASH", + IsFinished = false, + IsOpen = true, + IsActive = true, + Name = _title, + TotalSize = 1000, + RemainingSize = 500, + Path = "somepath" + }; + + _completed = new RTorrentTorrent + { + Hash = "HASH", + IsFinished = true, + Name = _title, + TotalSize = 1000, + RemainingSize = 0, + Path = "somepath" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnCompletedItem); + + Mocker.GetMock() + .Setup(s => s.AddTorrentFromFile(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(PrepareClientToReturnCompletedItem); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + { + torrents = new List(); + } + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + protected void PrepareClientToReturnDownloadingItem() + { + GivenTorrents(new List + { + _downloading + }); + } + + protected void PrepareClientToReturnCompletedItem() + { + GivenTorrents(new List + { + _completed + }); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + PrepareClientToReturnDownloadingItem(); + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + } + + [Test] + public void completed_download_should_have_required_properties() + { + PrepareClientToReturnCompletedItem(); + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 1328e6e35..74e3e7a28 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -157,6 +157,7 @@ + @@ -513,4 +514,4 @@ --> - \ No newline at end of file + diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs new file mode 100644 index 000000000..4aa491704 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrent.cs @@ -0,0 +1,214 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TorrentInfo; +using NLog; +using NzbDrone.Core.Validation; +using FluentValidation.Results; +using System.Net; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.RemotePathMappings; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrent : TorrentClientBase + { + private readonly IRTorrentProxy _proxy; + + public RTorrent(IRTorrentProxy proxy, + ITorrentFileInfoReader torrentFileInfoReader, + IHttpClient httpClient, + IConfigService configService, + IDiskProvider diskProvider, + IRemotePathMappingService remotePathMappingService, + Logger logger) + : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) + { + _proxy = proxy; + } + + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) + { + _proxy.AddTorrentFromUrl(magnetLink, Settings); + + // Wait until url has been resolved before returning + var TRIES = 5; + var RETRY_DELAY = 500; //ms + var ready = false; + for (var i = 0; i < TRIES; i++) + { + ready = _proxy.HasHashTorrent(hash, Settings); + if (ready) + { + break; + } + + Thread.Sleep(RETRY_DELAY); + } + + if (ready) + { + _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + + var priority = (RTorrentPriority)(remoteEpisode.IsRecentEpisode() ? + Settings.RecentTvPriority : Settings.OlderTvPriority); + _proxy.SetTorrentPriority(hash, Settings, priority); + + return hash; + } + else + { + _logger.Debug("Magnet {0} could not be resolved in {1} tries at {2} ms intervals.", magnetLink, TRIES, RETRY_DELAY); + // Remove from client, since it is discarded + RemoveItem(hash, true); + + return null; + } + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, byte[] fileContent) + { + _proxy.AddTorrentFromFile(filename, fileContent, Settings); + + _proxy.SetTorrentLabel(hash, Settings.TvCategory, Settings); + + var priority = (RTorrentPriority)(remoteEpisode.IsRecentEpisode() ? + Settings.RecentTvPriority : Settings.OlderTvPriority); + _proxy.SetTorrentPriority(hash, Settings, priority); + + return hash; + } + + public override string Name + { + get + { + return "rTorrent"; + } + } + + public override IEnumerable GetItems() + { + try + { + var torrents = _proxy.GetTorrents(Settings); + + _logger.Debug("Retrieved metadata of {0} torrents in client", torrents.Count); + + var items = new List(); + foreach (RTorrentTorrent torrent in torrents) + { + // Don't concern ourselves with categories other than specified + if (torrent.Category != Settings.TvCategory) continue; + + if (torrent.Path.StartsWith(".")) + { + throw new DownloadClientException("Download paths paths must be absolute. Please specify variable \"directory\" in rTorrent."); + } + + var item = new DownloadClientItem(); + item.DownloadClient = Definition.Name; + 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; + + 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; + + // Since we do not know the user's intent, do not let Sonarr to remove the torrent + item.IsReadOnly = true; + + items.Add(item); + } + + return items; + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + } + + public override void RemoveItem(string downloadId, bool deleteData) + { + if (deleteData) + { + DeleteItemData(downloadId); + } + + _proxy.RemoveTorrent(downloadId, Settings); + } + + public override DownloadClientStatus GetStatus() + { + // XXX: This function's correctness has not been considered + + var status = new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost" + }; + + return status; + } + + protected override void Test(List failures) + { + failures.AddIfNotNull(TestConnection()); + if (failures.Any()) return; + failures.AddIfNotNull(TestGetTorrents()); + } + + private ValidationFailure TestConnection() + { + try + { + var version = _proxy.GetVersion(Settings); + + if (new Version(version) < new Version("0.9.0")) + { + return new ValidationFailure(string.Empty, "rTorrent version should be at least 0.9.0. Version reported is {0}", version); + } + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(string.Empty, "Unknown exception: " + ex.Message); + } + + return null; + } + + private ValidationFailure TestGetTorrents() + { + try + { + _proxy.GetTorrents(Settings); + } + catch (Exception ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure(string.Empty, "Failed to get the list of torrents: " + ex.Message); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs new file mode 100644 index 000000000..99b289e8e --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentPriority.cs @@ -0,0 +1,10 @@ +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public enum RTorrentPriority + { + DoNotDownload = 0, + Low = 1, + Normal = 2, + High = 3 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs new file mode 100644 index 000000000..3cc6f128f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentProxy.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Extensions; +using CookComputing.XmlRpc; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public interface IRTorrentProxy + { + string GetVersion(RTorrentSettings settings); + List GetTorrents(RTorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, RTorrentSettings settings); + void AddTorrentFromFile(string fileName, byte[] fileContent, RTorrentSettings settings); + void RemoveTorrent(string hash, RTorrentSettings settings); + void SetTorrentPriority(string hash, RTorrentSettings settings, RTorrentPriority priority); + void SetTorrentLabel(string hash, string label, RTorrentSettings settings); + bool HasHashTorrent(string hash, RTorrentSettings settings); + } + + public interface IRTorrent : IXmlRpcProxy + { + [XmlRpcMethod("d.multicall")] + object[] TorrentMulticall(params string[] parameters); + + [XmlRpcMethod("load_start")] + int LoadURL(string data); + + [XmlRpcMethod("load_raw_start")] + int LoadBinary(byte[] data); + + [XmlRpcMethod("d.erase")] + int Remove(string hash); + + [XmlRpcMethod("d.set_custom1")] + string SetLabel(string hash, string label); + + [XmlRpcMethod("d.set_priority")] + int SetPriority(string hash, long priority); + + [XmlRpcMethod("d.get_name")] + string GetName(string hash); + + [XmlRpcMethod("system.client_version")] + string GetVersion(); + } + + public class RTorrentProxy : IRTorrentProxy + { + private readonly Logger _logger; + + public RTorrentProxy(Logger logger) + { + _logger = logger; + } + + public string GetVersion(RTorrentSettings settings) + { + _logger.Debug("Executing remote method: system.client_version"); + + var client = BuildClient(settings); + + var version = client.GetVersion(); + + return version; + } + + public List GetTorrents(RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.multicall"); + + var client = BuildClient(settings); + var ret = client.TorrentMulticall("main", + "d.get_name=", // string + "d.get_hash=", // string + "d.get_base_path=", // string + "d.get_custom1=", // string (label) + "d.get_size_bytes=", // long + "d.get_left_bytes=", // long + "d.get_down_rate=", // long (in bytes / s) + "d.get_ratio=", // long + "d.is_open=", // long + "d.is_active=", // long + "d.get_complete="); //long + + var items = new List(); + foreach (object[] torrent in ret) + { + var item = new RTorrentTorrent(); + item.Name = (string) torrent[0]; + item.Hash = (string) torrent[1]; + item.Path = (string) torrent[2]; + item.Category = (string) torrent[3]; + 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]); + + items.Add(item); + } + + return items; + } + + public bool HasHashTorrent(string hash, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.get_name"); + + var client = BuildClient(settings); + + try + { + var name = client.GetName(hash); + if (name.IsNullOrWhiteSpace()) return false; + bool metaTorrent = name == (hash + ".meta"); + return !metaTorrent; + } + catch (Exception) + { + return false; + } + } + + public void AddTorrentFromUrl(string torrentUrl, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: load_start"); + + var client = BuildClient(settings); + + var response = client.LoadURL(torrentUrl); + if (response != 0) + { + throw new DownloadClientException("Could not add torrent: {0}.", torrentUrl); + } + } + + public void AddTorrentFromFile(string fileName, Byte[] fileContent, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: load_raw_start"); + + var client = BuildClient(settings); + + var response = client.LoadBinary(fileContent); + if (response != 0) + { + throw new DownloadClientException("Could not add torrent: {0}.", fileName); + } + } + + public void RemoveTorrent(string hash, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.erase"); + + var client = BuildClient(settings); + + var response = client.Remove(hash); + if (response != 0) + { + throw new DownloadClientException("Could not remove torrent: {0}.", hash); + } + } + + public void SetTorrentPriority(string hash, RTorrentSettings settings, RTorrentPriority priority) + { + _logger.Debug("Executing remote method: d.set_priority"); + + var client = BuildClient(settings); + + var response = client.SetPriority(hash, (long) priority); + if (response != 0) + { + throw new DownloadClientException("Could not set priority on torrent: {0}.", hash); + } + } + + public void SetTorrentLabel(string hash, string label, RTorrentSettings settings) + { + _logger.Debug("Executing remote method: d.set_custom1"); + + var client = BuildClient(settings); + + var satLabel = client.SetLabel(hash, label); + if (satLabel != label) + { + throw new DownloadClientException("Could set label on torrent: {0}.", hash); + } + } + + private IRTorrent BuildClient(RTorrentSettings settings) + { + var url = string.Format(@"{0}://{1}:{2}/{3}", + settings.UseSsl ? "https" : "http", + settings.Host, + settings.Port, + settings.UrlBase); + + var client = XmlRpcProxyGen.Create(); + client.Url = url; + + if (!settings.Username.IsNullOrWhiteSpace()) + { + client.Credentials = new NetworkCredential(settings.Username, settings.Password); + } + + return client; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs new file mode 100644 index 000000000..aa8293d3f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentSettings.cs @@ -0,0 +1,66 @@ +using System; +using FluentValidation; +using FluentValidation.Results; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentSettingsValidator : AbstractValidator + { + public RTorrentSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).InclusiveBetween(0, 65535); + RuleFor(c => c.TvCategory).NotEmpty(); + } + } + + public class RTorrentSettings : IProviderConfig + { + private static readonly RTorrentSettingsValidator Validator = new RTorrentSettingsValidator(); + + public RTorrentSettings() + { + Host = "localhost"; + Port = 8080; + UrlBase = "RPC2"; + TvCategory = "tv-sonarr"; + OlderTvPriority = (int)RTorrentPriority.Normal; + RecentTvPriority = (int)RTorrentPriority.Normal; + } + + [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 = "UrlBase", Type = FieldType.Textbox)] + public string UrlBase { get; set; } + + [FieldDefinition(3, Label = "Use SSL", Type = FieldType.Checkbox)] + public bool UseSsl { get; set; } + + [FieldDefinition(4, Label = "Username", Type = FieldType.Textbox)] + public string Username { get; set; } + + [FieldDefinition(5, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "Adding a category specific to Sonarr avoids conflicts with unrelated downloads, but it's optional.")] + public string TvCategory { get; set; } + + [FieldDefinition(7, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public int RecentTvPriority { get; set; } + + [FieldDefinition(8, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(RTorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public int OlderTvPriority { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs new file mode 100644 index 000000000..d00df188f --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/rTorrent/RTorrentTorrent.cs @@ -0,0 +1,17 @@ +namespace NzbDrone.Core.Download.Clients.RTorrent +{ + public class RTorrentTorrent + { + public string Name { get; set; } + public string Hash { get; set; } + public string Path { get; set; } + public string Category { get; set; } + public long TotalSize { get; set; } + public long RemainingSize { get; set; } + public long DownRate { get; set; } + public long Ratio { get; set; } + public bool IsFinished { get; set; } + public bool IsOpen { get; set; } + public bool IsActive { get; set; } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 497144d20..38f3a5b44 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -83,6 +83,10 @@ False ..\packages\RestSharp.105.0.1\lib\net4\RestSharp.dll + + + False + ..\packages\xmlrpcnet.2.5.0\lib\net20\CookComputing.XmlRpcV2.dll @@ -357,6 +361,11 @@ + + + + + diff --git a/src/NzbDrone.Core/packages.config b/src/NzbDrone.Core/packages.config index efda7d74f..229277e3e 100644 --- a/src/NzbDrone.Core/packages.config +++ b/src/NzbDrone.Core/packages.config @@ -1,5 +1,6 @@  +