From 0552b56b71624741a50eaa9f98d6f8ec1db4b226 Mon Sep 17 00:00:00 2001 From: Casey Bodley Date: Thu, 3 Sep 2015 00:28:08 -0400 Subject: [PATCH] qbittorrent: client plugin based heavily on uTorrent supports a minimum qBittorrent version of 3.2.4, and uses labels for v3.3.0 and later Signed-off-by: Casey Bodley --- .../QBittorrentTests/QBittorrentFixture.cs | 297 ++++++++++++++++++ .../NzbDrone.Core.Test.csproj | 1 + .../qBittorrent/DigestAuthenticator.cs | 22 ++ .../Clients/qBittorrent/QBittorrent.cs | 261 +++++++++++++++ .../qBittorrent/QBittorrentPriority.cs | 8 + .../Clients/qBittorrent/QBittorrentProxy.cs | 192 +++++++++++ .../qBittorrent/QBittorrentSettings.cs | 58 ++++ .../Clients/qBittorrent/QBittorrentTorrent.cs | 26 ++ src/NzbDrone.Core/NzbDrone.Core.csproj | 6 + 9 files changed, 871 insertions(+) create mode 100644 src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/DigestAuthenticator.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrent.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentPriority.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentProxy.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentSettings.cs create mode 100644 src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentTorrent.cs diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs new file mode 100644 index 000000000..3845f5b99 --- /dev/null +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -0,0 +1,297 @@ +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.QBittorrent; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests +{ + [TestFixture] + public class QBittorrentFixture : DownloadClientFixtureBase + { + [SetUp] + public void Setup() + { + Subject.Definition = new DownloadClientDefinition(); + Subject.Definition.Settings = new QBittorrentSettings + { + Host = "127.0.0.1", + Port = 2222, + Username = "admin", + Password = "pass", + TvCategory = "tv" + }; + + Mocker.GetMock() + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + } + + protected void GivenRedirectToMagnet() + { + var httpHeader = new HttpHeader(); + httpHeader["Location"] = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp"; + + Mocker.GetMock() + .Setup(s => s.Get(It.IsAny())) + .Returns(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.SeeOther)); + } + + protected void GivenRedirectToTorrent() + { + var httpHeader = new HttpHeader(); + httpHeader["Location"] = "http://test.sonarr.tv/not-a-real-torrent.torrent"; + + Mocker.GetMock() + .Setup(s => s.Get(It.Is(h => h.Url.AbsoluteUri == _downloadUrl))) + .Returns(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.Found)); + } + + protected void GivenFailedDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Throws(); + } + + protected void GivenSuccessfulDownload() + { + Mocker.GetMock() + .Setup(s => s.AddTorrentFromUrl(It.IsAny(), It.IsAny())) + .Callback(() => + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 1.0, + Eta = 8640000, + State = "queuedUP", + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + }); + } + + protected virtual void GivenTorrents(List torrents) + { + if (torrents == null) + torrents = new List(); + + Mocker.GetMock() + .Setup(s => s.GetTorrents(It.IsAny())) + .Returns(torrents); + } + + [Test] + public void error_item_should_have_required_properties() + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 0.7, + Eta = 8640000, + State = "error", + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyFailed(item); + } + + [Test] + public void paused_item_should_have_required_properties() + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 0.7, + Eta = 8640000, + State = "pausedDL", + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyPaused(item); + item.RemainingTime.Should().NotBe(TimeSpan.Zero); + } + + [TestCase("pausedUP")] + [TestCase("queuedUP")] + [TestCase("uploading")] + [TestCase("stalledUP")] + [TestCase("checkingUP")] + public void completed_item_should_have_required_properties(string state) + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 1.0, + Eta = 8640000, + State = state, + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyCompleted(item); + item.RemainingTime.Should().Be(TimeSpan.Zero); + } + + [TestCase("queuedDL")] + [TestCase("checkingDL")] + public void queued_item_should_have_required_properties(string state) + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 0.7, + Eta = 8640000, + State = state, + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyQueued(item); + item.RemainingTime.Should().NotBe(TimeSpan.Zero); + } + + [Test] + public void downloading_item_should_have_required_properties() + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 0.7, + Eta = 60, + State = "downloading", + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyDownloading(item); + item.RemainingTime.Should().NotBe(TimeSpan.Zero); + } + + [Test] + public void stalledDL_item_should_have_required_properties() + { + var torrent = new QBittorrentTorrent + { + Hash = "HASH", + Name = _title, + Size = 1000, + Progress = 0.7, + Eta = 8640000, + State = "stalledDL", + Label = "", + SavePath = "" + }; + GivenTorrents(new List { torrent }); + + var item = Subject.GetItems().Single(); + VerifyWarning(item); + item.RemainingTime.Should().NotBe(TimeSpan.Zero); + } + + [Test] + public void Download_should_return_unique_id() + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [TestCase("magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp", "CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951")] + public void Download_should_get_hash_from_magnet_url(string magnetUrl, string expectedHash) + { + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.DownloadUrl = magnetUrl; + + var id = Subject.Download(remoteEpisode); + + id.Should().Be(expectedHash); + } + + [Test] + public void should_return_status_with_outputdirs() + { + var configItems = new Dictionary(); + + configItems.Add("save_path", @"C:\Downloads\Finished\QBittorrent".AsOsAgnostic()); + + Mocker.GetMock() + .Setup(v => v.GetConfig(It.IsAny())) + .Returns(configItems); + + var result = Subject.GetStatus(); + + result.IsLocalhost.Should().BeTrue(); + result.OutputRootFolders.Should().NotBeNull(); + result.OutputRootFolders.First().Should().Be(@"C:\Downloads\Finished\QBittorrent".AsOsAgnostic()); + } + + [Test] + public void Download_should_handle_http_redirect_to_magnet() + { + GivenRedirectToMagnet(); + GivenSuccessfulDownload(); + + var remoteEpisode = CreateRemoteEpisode(); + + var id = Subject.Download(remoteEpisode); + + id.Should().NotBeNullOrEmpty(); + } + + [Test] + public void Download_should_handle_http_redirect_to_torrent() + { + GivenRedirectToTorrent(); + 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 28fab06e6..1eaf23bd5 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -163,6 +163,7 @@ + diff --git a/src/NzbDrone.Core/Download/Clients/qBittorrent/DigestAuthenticator.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/DigestAuthenticator.cs new file mode 100644 index 000000000..6668d2661 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/DigestAuthenticator.cs @@ -0,0 +1,22 @@ +using RestSharp; +using System.Net; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public class DigestAuthenticator : IAuthenticator + { + private readonly string _user; + private readonly string _pass; + + public DigestAuthenticator(string user, string pass) + { + _user = user; + _pass = pass; + } + + public void Authenticate(IRestClient client, IRestRequest request) + { + request.Credentials = new NetworkCredential(_user, _pass); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrent.cs new file mode 100644 index 000000000..6dfaf2382 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrent.cs @@ -0,0 +1,261 @@ +using System; +using System.Linq; +using System.Collections.Generic; +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.QBittorrent +{ + public class QBittorrent : TorrentClientBase + { + private readonly IQBittorrentProxy _proxy; + + public QBittorrent(IQBittorrentProxy 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); + + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + } + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + + return hash; + } + + protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, Byte[] fileContent) + { + _proxy.AddTorrentFromFile(filename, fileContent, Settings); + + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + } + + var isRecentEpisode = remoteEpisode.IsRecentEpisode(); + + if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || + !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) + { + _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + } + + return hash; + } + + public override string Name + { + get + { + return "qBittorrent"; + } + } + + public override IEnumerable GetItems() + { + List torrents; + + try + { + torrents = _proxy.GetTorrents(Settings); + } + catch (DownloadClientException ex) + { + _logger.ErrorException(ex.Message, ex); + return Enumerable.Empty(); + } + + var queueItems = new List(); + + foreach (var torrent in torrents) + { + var item = new DownloadClientItem(); + item.DownloadId = torrent.Hash.ToUpper(); + item.Category = torrent.Label; + item.Title = torrent.Name; + item.TotalSize = torrent.Size; + item.DownloadClient = Definition.Name; + item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); + item.RemainingTime = TimeSpan.FromSeconds(torrent.Eta); + + item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); + + if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) + { + item.OutputPath += torrent.Name; + } + + switch (torrent.State) + { + case "error": // some error occurred, applies to paused torrents + item.Status = DownloadItemStatus.Failed; + item.Message = "QBittorrent is reporting an error"; + break; + + case "pausedDL": // torrent is paused and has NOT finished downloading + item.Status = DownloadItemStatus.Paused; + break; + + case "queuedDL": // queuing is enabled and torrent is queued for download + case "checkingDL": // same as checkingUP, but torrent has NOT finished downloading + item.Status = DownloadItemStatus.Queued; + break; + + case "pausedUP": // torrent is paused and has finished downloading + case "uploading": // torrent is being seeded and data is being transfered + case "stalledUP": // torrent is being seeded, but no connection were made + case "queuedUP": // queuing is enabled and torrent is queued for upload + case "checkingUP": // torrent has finished downloading and is being checked + item.Status = DownloadItemStatus.Completed; + item.RemainingTime = TimeSpan.Zero; // qBittorrent sends eta=8640000 for completed torrents + break; + + case "stalledDL": // torrent is being downloaded, but no connection were made + item.Status = DownloadItemStatus.Warning; + item.Message = "The download is stalled with no connections"; + break; + + case "downloading": // torrent is being downloaded and data is being transfered + default: // new status in API? default to downloading + item.Status = DownloadItemStatus.Downloading; + break; + } + + queueItems.Add(item); + } + + return queueItems; + } + + public override void RemoveItem(string hash, bool deleteData) + { + _proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); + } + + public override DownloadClientStatus GetStatus() + { + var config = _proxy.GetConfig(Settings); + + var destDir = new OsPath((string)config.GetValueOrDefault("save_path")); + + return new DownloadClientStatus + { + IsLocalhost = Settings.Host == "127.0.0.1" || Settings.Host == "localhost", + OutputRootFolders = new List { _remotePathMappingService.RemapRemoteToLocal(Settings.Host, destDir) } + }; + } + + 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 (version < 5) + { + // API version 5 introduced the "save_path" property in /query/torrents + return new NzbDroneValidationFailure("Host", "Unsupported client version") + { + DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." + }; + } + else if (version < 6) + { + // API version 6 introduced support for labels + if (Settings.TvCategory.IsNotNullOrWhiteSpace()) + { + return new NzbDroneValidationFailure("Category", "Category is not supported") + { + DetailedDescription = "Labels are not supported until qBittorrent version 3.3.0. Please upgrade or try again with an empty Category." + }; + } + } + else if (Settings.TvCategory.IsNullOrWhiteSpace()) + { + // warn if labels are supported, but category is not provided + return new NzbDroneValidationFailure("TvCategory", "Category is recommended") + { + IsWarning = true, + DetailedDescription = "Sonarr will not attempt to import completed downloads without a category." + }; + } + } + catch (DownloadClientAuthenticationException ex) + { + _logger.ErrorException(ex.Message, ex); + return new NzbDroneValidationFailure("Username", "Authentication failure") + { + DetailedDescription = "Please verify your username and password." + }; + } + catch (WebException ex) + { + _logger.ErrorException(ex.Message, ex); + if (ex.Status == WebExceptionStatus.ConnectFailure) + { + return new NzbDroneValidationFailure("Host", "Unable to connect") + { + DetailedDescription = "Please verify the hostname and port." + }; + } + return new NzbDroneValidationFailure(String.Empty, "Unknown exception: " + ex.Message); + } + 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/qBittorrent/QBittorrentPriority.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentPriority.cs new file mode 100644 index 000000000..7374fc312 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentPriority.cs @@ -0,0 +1,8 @@ +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public enum QBittorrentPriority + { + Last = 0, + First = 1 + } +} diff --git a/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentProxy.cs new file mode 100644 index 000000000..510054689 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentProxy.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Rest; +using RestSharp; +using NzbDrone.Common.Cache; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation + + public interface IQBittorrentProxy + { + int GetVersion(QBittorrentSettings settings); + Dictionary GetConfig(QBittorrentSettings settings); + List GetTorrents(QBittorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); + void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); + + void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); + void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); + void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); + } + + public class QBittorrentProxy : IQBittorrentProxy + { + private readonly Logger _logger; + private readonly CookieContainer _cookieContainer; + private readonly ICached _logins; + private readonly TimeSpan _loginTimeout = TimeSpan.FromSeconds(10); + + public QBittorrentProxy(ICacheManager cacheManager, Logger logger) + { + _logger = logger; + _cookieContainer = new CookieContainer(); + _logins = cacheManager.GetCache(GetType(), "logins"); + } + + public int GetVersion(QBittorrentSettings settings) + { + var request = new RestRequest("/version/api", Method.GET); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + return Convert.ToInt32(response.Content); + } + + public Dictionary GetConfig(QBittorrentSettings settings) + { + var request = new RestRequest("/query/preferences", Method.GET); + request.RequestFormat = DataFormat.Json; + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + return response.Read>(client); + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = new RestRequest("/query/torrents", Method.GET); + request.RequestFormat = DataFormat.Json; + request.AddParameter("label", settings.TvCategory); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + return response.Read>(client); + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = new RestRequest("/command/download", Method.POST); + request.AddParameter("urls", torrentUrl); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + } + + public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) + { + var request = new RestRequest("/command/upload", Method.POST); + request.AddFile("torrents", fileContent, fileName); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + } + + public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) + { + var cmd = removeData ? "/command/deletePerm" : "/command/delete"; + var request = new RestRequest(cmd, Method.POST); + request.AddParameter("hashes", hash); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var request = new RestRequest("/command/setLabel", Method.POST); + request.AddParameter("hashes", hash); + request.AddParameter("label", label); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + response.ValidateResponse(client); + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = new RestRequest("/command/topPrio", Method.POST); + request.AddParameter("hashes", hash); + + var client = BuildClient(settings); + var response = ProcessRequest(client, request, settings); + + // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return; + } + + response.ValidateResponse(client); + } + + private IRestResponse ProcessRequest(IRestClient client, IRestRequest request, QBittorrentSettings settings) + { + var response = client.Execute(request); + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Info("Authentication required, logging in."); + + var loggedIn = _logins.Get(settings.Username + settings.Password, () => Login(client, settings), _loginTimeout); + + if (!loggedIn) + { + throw new DownloadClientAuthenticationException("Failed to authenticate"); + } + + // success! retry the original request + response = client.Execute(request); + } + + return response; + } + + private bool Login(IRestClient client, QBittorrentSettings settings) + { + var request = new RestRequest("/login", Method.POST); + request.AddParameter("username", settings.Username); + request.AddParameter("password", settings.Password); + + var response = client.Execute(request); + + if (response.StatusCode != HttpStatusCode.OK) + { + _logger.Warn("Login failed with {0}.", response.StatusCode); + return false; + } + + if (response.Content != "Ok.") // returns "Fails." on bad login + { + _logger.Warn("Login failed, incorrect username or password."); + return false; + } + + response.ValidateResponse(client); + return true; + } + + private IRestClient BuildClient(QBittorrentSettings settings) + { + var protocol = settings.UseSsl ? "https" : "http"; + var url = String.Format(@"{0}://{1}:{2}", protocol, settings.Host, settings.Port); + var client = RestClientFactory.BuildClient(url); + + client.Authenticator = new DigestAuthenticator(settings.Username, settings.Password); + client.CookieContainer = _cookieContainer; + return client; + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentSettings.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentSettings.cs new file mode 100644 index 000000000..25a5be9c0 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentSettings.cs @@ -0,0 +1,58 @@ +using System; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public class QBittorrentSettingsValidator : AbstractValidator + { + public QBittorrentSettingsValidator() + { + RuleFor(c => c.Host).ValidHost(); + RuleFor(c => c.Port).InclusiveBetween(0, 65535); + } + } + + public class QBittorrentSettings : IProviderConfig + { + private static readonly QBittorrentSettingsValidator Validator = new QBittorrentSettingsValidator(); + + public QBittorrentSettings() + { + Host = "localhost"; + Port = 9091; + TvCategory = "tv-sonarr"; + } + + [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 = "Username", Type = FieldType.Textbox)] + public string Username { get; set; } + + [FieldDefinition(3, Label = "Password", Type = FieldType.Password)] + public string Password { get; set; } + + [FieldDefinition(4, 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(5, Label = "Recent Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired within the last 14 days")] + public int RecentTvPriority { get; set; } + + [FieldDefinition(6, Label = "Older Priority", Type = FieldType.Select, SelectOptions = typeof(QBittorrentPriority), HelpText = "Priority to use when grabbing episodes that aired over 14 days ago")] + public int OlderTvPriority { get; set; } + + [FieldDefinition(7, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection. See Options -> Web UI -> 'Use HTTPS instead of HTTP' in qBittorrent.")] + public bool UseSsl { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentTorrent.cs new file mode 100644 index 000000000..96a1fab08 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/qBittorrent/QBittorrentTorrent.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using System; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // torrent properties from the list returned by /query/torrents + public class QBittorrentTorrent + { + public string Hash { get; set; } // Torrent hash + + public string Name { get; set; } // Torrent name + + public long Size { get; set; } // Torrent size (bytes) + + public double Progress { get; set; } // Torrent progress (%/100) + + public int Eta { get; set; } // Torrent ETA (seconds) + + public string State { get; set; } // Torrent state. See possible values here below + + public string Label { get; set; } // Label of the torrent + + [JsonProperty(PropertyName = "save_path")] + public string SavePath { get; set; } // Torrent save path + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index edd3a2103..2d2435cc9 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -356,7 +356,13 @@ + + + + + +