From aa462161178d4ec132da3f5fc75d12562714e147 Mon Sep 17 00:00:00 2001 From: Mark Bebbington Date: Fri, 8 Feb 2019 19:05:29 +0000 Subject: [PATCH] Fixed: qBittorrent api v2 support (qbit v4.1+) fixes #2887 closes #2951 ref #2945 --- .../QBittorrentTests/QBittorrentFixture.cs | 22 +- .../Clients/QBittorrent/QBittorrent.cs | 71 ++-- .../QBittorrent/QBittorrentProxySelector.cs | 88 +++++ ...ttorrentProxy.cs => QBittorrentProxyV1.cs} | 115 +++--- .../Clients/QBittorrent/QBittorrentProxyV2.cs | 328 ++++++++++++++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 4 +- 6 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs rename src/NzbDrone.Core/Download/Clients/QBittorrent/{QBittorrentProxy.cs => QBittorrentProxyV1.cs} (77%) create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index ecac74902..cc36a447b 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -37,8 +37,12 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); Mocker.GetMock() - .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences()); + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences()); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(Mocker.GetMock().Object); } protected void GivenRedirectToMagnet() @@ -508,5 +512,19 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests torrent.Eta.ToString().Should().Be("18446744073709335000"); } + + [Test] + public void Test_should_force_api_version_check() + { + // Set TestConnection up to fail quick + Mocker.GetMock() + .Setup(v => v.GetApiVersion(It.IsAny())) + .Returns(new Version(1, 0)); + + Subject.Test(); + + Mocker.GetMock() + .Verify(v => v.GetProxy(It.IsAny(), true), Times.Once()); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 66c12449f..a519134b4 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -17,9 +17,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { public class QBittorrent : TorrentClientBase { - private readonly IQBittorrentProxy _proxy; + private readonly IQBittorrentProxySelector _proxySelector; - public QBittorrent(IQBittorrentProxy proxy, + public QBittorrent(IQBittorrentProxySelector proxySelector, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, IConfigService configService, @@ -28,16 +28,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent Logger logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) { - _proxy = proxy; + _proxySelector = proxySelector; } + private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings); + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) { - _proxy.AddTorrentFromUrl(magnetLink, Settings); + Proxy.AddTorrentFromUrl(magnetLink, Settings); if (Settings.TvCategory.IsNotNullOrWhiteSpace()) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + Proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); } var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -45,7 +47,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } SetInitialState(hash.ToLower()); @@ -55,13 +57,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, Byte[] fileContent) { - _proxy.AddTorrentFromFile(filename, fileContent, Settings); + Proxy.AddTorrentFromFile(filename, fileContent, Settings); try { if (Settings.TvCategory.IsNotNullOrWhiteSpace()) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + Proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); } } catch (Exception ex) @@ -76,7 +78,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } } catch (Exception ex) @@ -93,28 +95,29 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override IEnumerable GetItems() { - var config = _proxy.GetConfig(Settings); - var torrents = _proxy.GetTorrents(Settings); + var config = Proxy.GetConfig(Settings); + var torrents = Proxy.GetTorrents(Settings); var queueItems = new List(); foreach (var torrent in torrents) { - var item = new DownloadClientItem(); - item.DownloadId = torrent.Hash.ToUpper(); - item.Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.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 = GetRemainingTime(torrent); - item.SeedRatio = torrent.Ratio; - - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); - + var item = new DownloadClientItem() + { + DownloadId = torrent.Hash.ToUpper(), + Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClient = Definition.Name, + RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), + RemainingTime = GetRemainingTime(torrent), + SeedRatio = torrent.Ratio, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)), + }; // Avoid removing torrents that haven't reached the global max ratio. // Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api). item.CanMoveFiles = item.CanBeRemoved = (!config.MaxRatioEnabled || config.MaxRatio <= torrent.Ratio) && torrent.State == "pausedUP"; + if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) { @@ -166,12 +169,12 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public override void RemoveItem(string hash, bool deleteData) { - _proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); + Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); } public override DownloadClientInfo GetStatus() { - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); var destDir = new OsPath(config.SavePath); @@ -194,8 +197,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { try { - var version = _proxy.GetVersion(Settings); - if (version < 5) + var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings); + if (version < Version.Parse("1.5")) { // API version 5 introduced the "save_path" property in /query/torrents return new NzbDroneValidationFailure("Host", "Unsupported client version") @@ -203,7 +206,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." }; } - else if (version < 6) + else if (version < Version.Parse("1.6")) { // API version 6 introduced support for labels if (Settings.TvCategory.IsNotNullOrWhiteSpace()) @@ -225,7 +228,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent } // Complain if qBittorrent is configured to remove torrents on max ratio - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); if (config.MaxRatioEnabled && config.RemoveOnMaxRatio) { return new NzbDroneValidationFailure(String.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit") @@ -275,7 +278,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent try { - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); if (!config.QueueingEnabled) { @@ -302,7 +305,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { try { - _proxy.GetTorrents(Settings); + Proxy.GetTorrents(Settings); } catch (Exception ex) { @@ -320,13 +323,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent switch ((QBittorrentState)Settings.InitialState) { case QBittorrentState.ForceStart: - _proxy.SetForceStart(hash, true, Settings); + Proxy.SetForceStart(hash, true, Settings); break; case QBittorrentState.Start: - _proxy.ResumeTorrent(hash, Settings); + Proxy.ResumeTorrent(hash, Settings); break; case QBittorrentState.Pause: - _proxy.PauseTorrent(hash, Settings); + Proxy.PauseTorrent(hash, Settings); break; } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs new file mode 100644 index 000000000..0cbc44343 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; + +using NLog; +using NzbDrone.Common.Cache; + +using NzbDrone.Common.Http; + + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public interface IQBittorrentProxy + { + bool IsApiSupported(QBittorrentSettings settings); + Version GetApiVersion(QBittorrentSettings settings); + string GetVersion(QBittorrentSettings settings); + QBittorrentPreferences 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); + void PauseTorrent(string hash, QBittorrentSettings settings); + void ResumeTorrent(string hash, QBittorrentSettings settings); + void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); + } + + public interface IQBittorrentProxySelector + { + IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force = false); + } + + public class QBittorrentProxySelector : IQBittorrentProxySelector + { + private readonly IHttpClient _httpClient; + private readonly ICached _proxyCache; + private readonly Logger _logger; + + private readonly IQBittorrentProxy _proxyV1; + private readonly IQBittorrentProxy _proxyV2; + + public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, + QBittorrentProxyV2 proxyV2, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _proxyCache = cacheManager.GetCache(GetType()); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IQBittorrentProxy GetProxy(QBittorrentSettings 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 IQBittorrentProxy FetchProxy(QBittorrentSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v2"); + return _proxyV2; + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v1"); + return _proxyV1; + } + + throw new DownloadClientException("Unable to determine qBittorrent API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs similarity index 77% rename from src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs rename to src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 600802a73..eb76695dd 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -11,41 +11,68 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation - public interface IQBittorrentProxy - { - int GetVersion(QBittorrentSettings settings); - QBittorrentPreferences 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); - void PauseTorrent(string hash, QBittorrentSettings settings); - void ResumeTorrent(string hash, QBittorrentSettings settings); - void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); - } - - public class QBittorrentProxy : IQBittorrentProxy + public class QBittorrentProxyV1 : IQBittorrentProxy { private readonly IHttpClient _httpClient; private readonly Logger _logger; private readonly ICached> _authCookieCache; - public QBittorrentProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) { _httpClient = httpClient; _logger = logger; - _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } - public int GetVersion(QBittorrentSettings settings) + public bool IsApiSupported(QBittorrentSettings settings) { + // We can do the api test without having to authenticate since v4.1 will return 404 on the request. var request = BuildRequest(settings).Resource("/version/api"); - var response = ProcessRequest(request, settings); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + // Version request does not require authentication and will return 404 if it doesn't exist. + var request = BuildRequest(settings).Resource("/version/api"); + var response = Version.Parse("1." + ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + // Version request does not require authentication. + var request = BuildRequest(settings).Resource("/version/qbittorrent"); + var response = ProcessRequest(request, settings).TrimStart('v'); return response; } @@ -63,7 +90,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = BuildRequest(settings).Resource("/query/torrents") .AddQueryParam("label", settings.TvCategory) .AddQueryParam("category", settings.TvCategory); - var response = ProcessRequest>(request, settings); return response; @@ -107,7 +133,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) { - request.AddFormParameter("paused", true); + request.AddFormParameter("paused", "true"); } var result = ProcessRequest(request, settings); @@ -122,8 +148,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") - .Post() - .AddFormParameter("hashes", hash); + .Post() + .AddFormParameter("hashes", hash); ProcessRequest(request, settings); } @@ -138,7 +164,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { ProcessRequest(setCategoryRequest, settings); } - catch(DownloadClientException ex) + catch (DownloadClientException ex) { // if setCategory fails due to method not being found, then try older setLabel command for qBittorrent < v.3.3.5 if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) @@ -151,14 +177,14 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent ProcessRequest(setLabelRequest, settings); } } + } public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/topPrio") - .Post() - .AddFormParameter("hashes", hash); - + .Post() + .AddFormParameter("hashes", hash); try { ProcessRequest(request, settings); @@ -166,7 +192,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent catch (DownloadClientException ex) { // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled - #warning FIXME: so wouldn't the reauthenticate logic trigger on Forbidden? if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) { return; @@ -180,9 +205,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent public void PauseTorrent(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/pause") - .Post() - .AddFormParameter("hash", hash); - + .Post() + .AddFormParameter("hash", hash); ProcessRequest(request, settings); } @@ -191,7 +215,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = BuildRequest(settings).Resource("/command/resume") .Post() .AddFormParameter("hash", hash); - ProcessRequest(request, settings); } @@ -200,17 +223,17 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = BuildRequest(settings).Resource("/command/setForceStart") .Post() .AddFormParameter("hashes", hash) - .AddFormParameter("value", enabled ? "true": "false"); - + .AddFormParameter("value", enabled ? "true" : "false"); ProcessRequest(request, settings); } private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) { - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port); - requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); - + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; return requestBuilder; } @@ -274,11 +297,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { _authCookieCache.Remove(authKey); - var authLoginRequest = BuildRequest(settings).Resource("/login") - .Post() - .AddFormParameter("username", settings.Username ?? string.Empty) - .AddFormParameter("password", settings.Password ?? string.Empty) - .Build(); + var authLoginRequest = BuildRequest(settings).Resource( "/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); HttpResponse response; try diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs new file mode 100644 index 000000000..0f0c6c1b4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation + + public class QBittorrentProxyV2 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v3.2.0-v4.0.4 will return 404 on the request. + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + var response = Version.Parse(ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/version"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + // eg "4.2alpha" + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/info") + .AddQueryParam("category", settings.TvCategory); + var response = ProcessRequest>(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormParameter("urls", torrentUrl); + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + var result = ProcessRequest(request, settings); + + // Note: Older qbit versions returned nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent by url"); + } + } + + public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", "true"); + } + + var result = ProcessRequest(request, settings); + + // Note: Current qbit versions return nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent"); + } + } + + public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/delete") + .Post() + .AddFormParameter("hashes", hash); + + if (removeData) + { + request.AddFormParameter("deleteFiles", "true"); + } + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/topPrio") + .Post() + .AddFormParameter("hashes", hash); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 409: Conflict if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Conflict) + { + return; + } + + throw; + } + + } + + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/pause") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/resume") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setForceStart") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("value", enabled ? "true" : "false"); + ProcessRequest(request, settings); + } + + private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + return requestBuilder; + } + + private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/api/v2/auth/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + if (response.Content != "Ok.") // returns "Fails." on bad login + { + _logger.Debug("qbitTorrent authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index e92508e37..bf6eb22e7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -460,7 +460,7 @@ - + @@ -1234,6 +1234,8 @@ + +