New: Add qBittorrent API V2 support, Indexer seed limit Support (#653)

* Fixed: qBittorrent api v2 support (qbit v4.1+)

Co-Authored-By: taloth <taloth@users.noreply.github.com>
Co-Authored-By: Mark Bebbington <mark@thebebs.uk>

* Fixed: Magnet Link progress visualisation and adding magnet links if dht is disabled in qBittorrent

* New: Indexer Seed Limit settings applied to new downloads for qBit

Co-Authored-By: taloth <taloth@users.noreply.github.com>

* Handle Deluge v2 beta breaking change in their api.

closes #2412

* Fixed: Codacy Format Issues
pull/6/head
Qstick 6 years ago committed by GitHub
parent 61b0b2681a
commit 1e48ea58b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -75,11 +75,12 @@ namespace NzbDrone.Common.Test.Http
[Test] [Test]
public void should_execute_typed_get() public void should_execute_typed_get()
{ {
var request = new HttpRequest($"http://{_httpBinHost}/get"); var request = new HttpRequest($"http://{_httpBinHost}/get?test=1");
var response = Subject.Get<HttpBinResource>(request); var response = Subject.Get<HttpBinResource>(request);
response.Resource.Url.Should().Be(request.Url.FullUri); response.Resource.Url.EndsWith("/get?test=1");
response.Resource.Args.Should().Contain("test", "1");
} }
[Test] [Test]
@ -646,6 +647,7 @@ namespace NzbDrone.Common.Test.Http
public class HttpBinResource public class HttpBinResource
{ {
public Dictionary<string, object> Args { get; set; }
public Dictionary<string, object> Headers { get; set; } public Dictionary<string, object> Headers { get; set; }
public string Origin { get; set; } public string Origin { get; set; }
public string Url { get; set; } public string Url { get; set; }

@ -355,7 +355,7 @@ namespace NzbDrone.Common.Http
FormData.Add(new HttpFormData FormData.Add(new HttpFormData
{ {
Name = key, Name = key,
ContentData = Encoding.UTF8.GetBytes(value.ToString()) ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
}); });
return this; return this;

@ -1,13 +1,13 @@
using System; using System;
using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.Core.Download.Clients.QBittorrent; using NzbDrone.Core.Download.Clients.QBittorrent;
using NzbDrone.Core.MediaFiles.TorrentInfo;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
@ -20,25 +20,29 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{ {
Subject.Definition = new DownloadClientDefinition(); Subject.Definition = new DownloadClientDefinition();
Subject.Definition.Settings = new QBittorrentSettings Subject.Definition.Settings = new QBittorrentSettings
{ {
Host = "127.0.0.1", Host = "127.0.0.1",
Port = 2222, Port = 2222,
Username = "admin", Username = "admin",
Password = "pass", Password = "pass",
MusicCategory = "tv" MusicCategory = "music"
}; };
Mocker.GetMock<ITorrentFileInfoReader>() Mocker.GetMock<ITorrentFileInfoReader>()
.Setup(s => s.GetHashFromTorrentFile(It.IsAny<Byte[]>())) .Setup(s => s.GetHashFromTorrentFile(It.IsAny<byte[]>()))
.Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
Mocker.GetMock<IHttpClient>() Mocker.GetMock<IHttpClient>()
.Setup(s => s.Get(It.IsAny<HttpRequest>())) .Setup(s => s.Get(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); .Returns<HttpRequest>(r => new HttpResponse(r, new HttpHeader(), new byte[0]));
Mocker.GetMock<IQBittorrentProxy>() Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>())) .Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentPreferences()); .Returns(new QBittorrentPreferences { DhtEnabled = true });
Mocker.GetMock<IQBittorrentProxySelector>()
.Setup(s => s.GetProxy(It.IsAny<QBittorrentSettings>(), It.IsAny<bool>()))
.Returns(Mocker.GetMock<IQBittorrentProxy>().Object);
} }
protected void GivenRedirectToMagnet() protected void GivenRedirectToMagnet()
@ -48,7 +52,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
Mocker.GetMock<IHttpClient>() Mocker.GetMock<IHttpClient>()
.Setup(s => s.Get(It.IsAny<HttpRequest>())) .Setup(s => s.Get(It.IsAny<HttpRequest>()))
.Returns<HttpRequest>(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.SeeOther)); .Returns<HttpRequest>(r => new HttpResponse(r, httpHeader, new byte[0], System.Net.HttpStatusCode.SeeOther));
} }
protected void GivenRedirectToTorrent() protected void GivenRedirectToTorrent()
@ -91,25 +95,27 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
protected void GivenHighPriority() protected void GivenHighPriority()
{ {
Subject.Definition.Settings.As<QBittorrentSettings>().OlderTvPriority = (int) QBittorrentPriority.First; Subject.Definition.Settings.As<QBittorrentSettings>().OlderTvPriority = (int)QBittorrentPriority.First;
Subject.Definition.Settings.As<QBittorrentSettings>().RecentTvPriority = (int) QBittorrentPriority.First; Subject.Definition.Settings.As<QBittorrentSettings>().RecentTvPriority = (int)QBittorrentPriority.First;
} }
protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true) protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true)
{ {
Mocker.GetMock<IQBittorrentProxy>() Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>())) .Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentPreferences .Returns(new QBittorrentPreferences
{ {
RemoveOnMaxRatio = removeOnMaxRatio, RemoveOnMaxRatio = removeOnMaxRatio,
MaxRatio = maxRatio MaxRatio = maxRatio
}); });
} }
protected virtual void GivenTorrents(List<QBittorrentTorrent> torrents) protected virtual void GivenTorrents(List<QBittorrentTorrent> torrents)
{ {
if (torrents == null) if (torrents == null)
{
torrents = new List<QBittorrentTorrent>(); torrents = new List<QBittorrentTorrent>();
}
Mocker.GetMock<IQBittorrentProxy>() Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>())) .Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>()))
@ -154,7 +160,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single(); var item = Subject.GetItems().Single();
VerifyPaused(item); VerifyPaused(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero); item.RemainingTime.Should().NotHaveValue();
} }
[TestCase("pausedUP")] [TestCase("pausedUP")]
@ -185,6 +191,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
[TestCase("queuedDL")] [TestCase("queuedDL")]
[TestCase("checkingDL")] [TestCase("checkingDL")]
[TestCase("metaDL")]
public void queued_item_should_have_required_properties(string state) public void queued_item_should_have_required_properties(string state)
{ {
var torrent = new QBittorrentTorrent var torrent = new QBittorrentTorrent
@ -202,7 +209,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single(); var item = Subject.GetItems().Single();
VerifyQueued(item); VerifyQueued(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero); item.RemainingTime.Should().NotHaveValue();
} }
[Test] [Test]
@ -244,7 +251,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single(); var item = Subject.GetItems().Single();
VerifyWarning(item); VerifyWarning(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero); item.RemainingTime.Should().NotHaveValue();
} }
[Test] [Test]
@ -252,9 +259,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{ {
GivenSuccessfulDownload(); GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum(); var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode); var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty(); id.Should().NotBeNullOrEmpty();
} }
@ -264,14 +271,27 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{ {
GivenSuccessfulDownload(); GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum(); var remoteAlbum = CreateRemoteAlbum();
remoteEpisode.Release.DownloadUrl = magnetUrl; remoteAlbum.Release.DownloadUrl = magnetUrl;
var id = Subject.Download(remoteEpisode); var id = Subject.Download(remoteAlbum);
id.Should().Be(expectedHash); id.Should().Be(expectedHash);
} }
public void Download_should_refuse_magnet_if_dht_is_disabled()
{
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentPreferences { DhtEnabled = false });
var remoteAlbum = CreateRemoteAlbum();
remoteAlbum.Release.DownloadUrl = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp";
Assert.Throws<NotSupportedException>(() => Subject.Download(remoteAlbum));
}
[Test] [Test]
public void Download_should_set_top_priority() public void Download_should_set_top_priority()
{ {
@ -305,7 +325,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
ExceptionVerification.ExpectedWarns(1); ExceptionVerification.ExpectedWarns(1);
} }
[Test] [Test]
public void should_return_status_with_outputdirs() public void should_return_status_with_outputdirs()
{ {
var config = new QBittorrentPreferences var config = new QBittorrentPreferences
@ -330,9 +350,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
GivenRedirectToMagnet(); GivenRedirectToMagnet();
GivenSuccessfulDownload(); GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum(); var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode); var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty(); id.Should().NotBeNullOrEmpty();
} }
@ -343,9 +363,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
GivenRedirectToTorrent(); GivenRedirectToTorrent();
GivenSuccessfulDownload(); GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum(); var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode); var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty(); id.Should().NotBeNullOrEmpty();
} }
@ -446,6 +466,56 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
item.CanMoveFiles.Should().BeTrue(); item.CanMoveFiles.Should().BeTrue();
} }
[Test]
public void should_be_removable_and_should_allow_move_files_if_overridden_max_ratio_reached_and_paused()
{
GivenMaxRatio(2.0f);
var torrent = new QBittorrentTorrent
{
Hash = "HASH",
Name = _title,
Size = 1000,
Progress = 1.0,
Eta = 8640000,
State = "pausedUP",
Label = "",
SavePath = "",
Ratio = 1.0f,
RatioLimit = 0.8f
};
GivenTorrents(new List<QBittorrentTorrent> { torrent });
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeTrue();
item.CanMoveFiles.Should().BeTrue();
}
[Test]
public void should_not_be_removable_if_overridden_max_ratio_not_reached_and_paused()
{
GivenMaxRatio(0.2f);
var torrent = new QBittorrentTorrent
{
Hash = "HASH",
Name = _title,
Size = 1000,
Progress = 1.0,
Eta = 8640000,
State = "pausedUP",
Label = "",
SavePath = "",
Ratio = 0.5f,
RatioLimit = 0.8f
};
GivenTorrents(new List<QBittorrentTorrent> { torrent });
var item = Subject.GetItems().Single();
item.CanBeRemoved.Should().BeFalse();
item.CanMoveFiles.Should().BeFalse();
}
[Test] [Test]
public void should_get_category_from_the_category_if_set() public void should_get_category_from_the_category_if_set()
{ {
@ -503,5 +573,19 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var torrent = Newtonsoft.Json.JsonConvert.DeserializeObject<QBittorrentTorrent>(json); var torrent = Newtonsoft.Json.JsonConvert.DeserializeObject<QBittorrentTorrent>(json);
torrent.Eta.ToString().Should().Be("18446744073709335000"); torrent.Eta.ToString().Should().Be("18446744073709335000");
} }
[Test]
public void Test_should_force_api_version_check()
{
// Set TestConnection up to fail quick
Mocker.GetMock<IQBittorrentProxy>()
.Setup(v => v.GetApiVersion(It.IsAny<QBittorrentSettings>()))
.Returns(new Version(1, 0));
Subject.Test();
Mocker.GetMock<IQBittorrentProxySelector>()
.Verify(v => v.GetProxy(It.IsAny<QBittorrentSettings>(), true), Times.Once());
}
} }
} }

@ -48,9 +48,25 @@ namespace NzbDrone.Core.Download.Clients.Deluge
public string GetVersion(DelugeSettings settings) public string GetVersion(DelugeSettings settings)
{ {
var response = ProcessRequest<string>(settings, "daemon.info"); try
{
var response = ProcessRequest<string>(settings, "daemon.info");
return response; return response;
}
catch (DownloadClientException ex)
{
if (ex.Message.Contains("Unknown method"))
{
// Deluge v2 beta replaced 'daemon.info' with 'daemon.get_version'.
// It may return or become official, for now we just retry with the get_version api.
var response = ProcessRequest<string>(settings, "daemon.get_version");
return response;
}
throw;
}
} }
public Dictionary<string, object> GetConfig(DelugeSettings settings) public Dictionary<string, object> GetConfig(DelugeSettings settings)

@ -1,25 +1,25 @@
using System; using System;
using System.Linq;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Net;
using FluentValidation.Results;
using NLog;
using NzbDrone.Common.Disk; using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.MediaFiles.TorrentInfo; 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.Parser.Model;
using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.QBittorrent namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
public class QBittorrent : TorrentClientBase<QBittorrentSettings> public class QBittorrent : TorrentClientBase<QBittorrentSettings>
{ {
private readonly IQBittorrentProxy _proxy; private readonly IQBittorrentProxySelector _proxySelector;
public QBittorrent(IQBittorrentProxy proxy, public QBittorrent(IQBittorrentProxySelector proxySelector,
ITorrentFileInfoReader torrentFileInfoReader, ITorrentFileInfoReader torrentFileInfoReader,
IHttpClient httpClient, IHttpClient httpClient,
IConfigService configService, IConfigService configService,
@ -28,16 +28,23 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
Logger logger) Logger logger)
: base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger)
{ {
_proxy = proxy; _proxySelector = proxySelector;
} }
private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings);
protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink) protected override string AddFromMagnetLink(RemoteAlbum remoteAlbum, string hash, string magnetLink)
{ {
_proxy.AddTorrentFromUrl(magnetLink, Settings); if (!Proxy.GetConfig(Settings).DhtEnabled)
{
throw new NotSupportedException("Magnet Links not supported if DHT is disabled");
}
Proxy.AddTorrentFromUrl(magnetLink, Settings);
if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) if (Settings.MusicCategory.IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings); Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
} }
var isRecentAlbum = remoteAlbum.IsRecentAlbum(); var isRecentAlbum = remoteAlbum.IsRecentAlbum();
@ -45,23 +52,28 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First || if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First ||
!isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First) !isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First)
{ {
_proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
} }
SetInitialState(hash.ToLower()); SetInitialState(hash.ToLower());
if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue))
{
Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings);
}
return hash; return hash;
} }
protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, Byte[] fileContent) protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, Byte[] fileContent)
{ {
_proxy.AddTorrentFromFile(filename, fileContent, Settings); Proxy.AddTorrentFromFile(filename, fileContent, Settings);
try try
{ {
if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) if (Settings.MusicCategory.IsNotNullOrWhiteSpace())
{ {
_proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings); Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -76,7 +88,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First || if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First ||
!isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First) !isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First)
{ {
_proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -86,6 +98,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
SetInitialState(hash.ToLower()); SetInitialState(hash.ToLower());
if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue))
{
Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings);
}
return hash; return hash;
} }
@ -93,28 +110,29 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public override IEnumerable<DownloadClientItem> GetItems() public override IEnumerable<DownloadClientItem> GetItems()
{ {
var config = _proxy.GetConfig(Settings); var config = Proxy.GetConfig(Settings);
var torrents = _proxy.GetTorrents(Settings); var torrents = Proxy.GetTorrents(Settings);
var queueItems = new List<DownloadClientItem>(); var queueItems = new List<DownloadClientItem>();
foreach (var torrent in torrents) foreach (var torrent in torrents)
{ {
var item = new DownloadClientItem(); var item = new DownloadClientItem
item.DownloadId = torrent.Hash.ToUpper(); {
item.Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label; DownloadId = torrent.Hash.ToUpper(),
item.Title = torrent.Name; Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label,
item.TotalSize = torrent.Size; Title = torrent.Name,
item.DownloadClient = Definition.Name; TotalSize = torrent.Size,
item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); DownloadClient = Definition.Name,
item.RemainingTime = GetRemainingTime(torrent); RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)),
item.SeedRatio = torrent.Ratio; RemainingTime = GetRemainingTime(torrent),
SeedRatio = torrent.Ratio,
item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)),
};
// Avoid removing torrents that haven't reached the global max ratio. // 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). // 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"; item.CanMoveFiles = item.CanBeRemoved = (torrent.State == "pausedUP" && HasReachedSeedLimit(torrent, config));
if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name)
{ {
@ -152,6 +170,18 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
item.Message = "The download is stalled with no connections"; item.Message = "The download is stalled with no connections";
break; break;
case "metaDL": // torrent magnet is being downloaded
if (config.DhtEnabled)
{
item.Status = DownloadItemStatus.Queued;
}
else
{
item.Status = DownloadItemStatus.Warning;
item.Message = "qBittorrent cannot resolve magnet link with DHT disabled";
}
break;
case "downloading": // torrent is being downloaded and data is being transfered case "downloading": // torrent is being downloaded and data is being transfered
default: // new status in API? default to downloading default: // new status in API? default to downloading
item.Status = DownloadItemStatus.Downloading; item.Status = DownloadItemStatus.Downloading;
@ -166,12 +196,12 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public override void RemoveItem(string hash, bool deleteData) public override void RemoveItem(string hash, bool deleteData)
{ {
_proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings);
} }
public override DownloadClientInfo GetStatus() public override DownloadClientInfo GetStatus()
{ {
var config = _proxy.GetConfig(Settings); var config = Proxy.GetConfig(Settings);
var destDir = new OsPath(config.SavePath); var destDir = new OsPath(config.SavePath);
@ -185,7 +215,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
protected override void Test(List<ValidationFailure> failures) protected override void Test(List<ValidationFailure> failures)
{ {
failures.AddIfNotNull(TestConnection()); failures.AddIfNotNull(TestConnection());
if (failures.Any()) return; if (failures.Any())
{
return;
}
failures.AddIfNotNull(TestPrioritySupport()); failures.AddIfNotNull(TestPrioritySupport());
failures.AddIfNotNull(TestGetTorrents()); failures.AddIfNotNull(TestGetTorrents());
} }
@ -194,8 +227,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
try try
{ {
var version = _proxy.GetVersion(Settings); var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings);
if (version < 5) if (version < Version.Parse("1.5"))
{ {
// API version 5 introduced the "save_path" property in /query/torrents // API version 5 introduced the "save_path" property in /query/torrents
return new NzbDroneValidationFailure("Host", "Unsupported client version") return new NzbDroneValidationFailure("Host", "Unsupported client version")
@ -203,7 +236,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." 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 // API version 6 introduced support for labels
if (Settings.MusicCategory.IsNotNullOrWhiteSpace()) if (Settings.MusicCategory.IsNotNullOrWhiteSpace())
@ -225,8 +258,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
// Complain if qBittorrent is configured to remove torrents on max ratio // 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) if ((config.MaxRatioEnabled || config.MaxSeedingTimeEnabled) && config.RemoveOnMaxRatio)
{ {
return new NzbDroneValidationFailure(String.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit") return new NzbDroneValidationFailure(String.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit")
{ {
@ -275,7 +308,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
try try
{ {
var config = _proxy.GetConfig(Settings); var config = Proxy.GetConfig(Settings);
if (!config.QueueingEnabled) if (!config.QueueingEnabled)
{ {
@ -302,7 +335,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
try try
{ {
_proxy.GetTorrents(Settings); Proxy.GetTorrents(Settings);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -320,13 +353,13 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
switch ((QBittorrentState)Settings.InitialState) switch ((QBittorrentState)Settings.InitialState)
{ {
case QBittorrentState.ForceStart: case QBittorrentState.ForceStart:
_proxy.SetForceStart(hash, true, Settings); Proxy.SetForceStart(hash, true, Settings);
break; break;
case QBittorrentState.Start: case QBittorrentState.Start:
_proxy.ResumeTorrent(hash, Settings); Proxy.ResumeTorrent(hash, Settings);
break; break;
case QBittorrentState.Pause: case QBittorrentState.Pause:
_proxy.PauseTorrent(hash, Settings); Proxy.PauseTorrent(hash, Settings);
break; break;
} }
} }
@ -343,7 +376,48 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return null; return null;
} }
// qBittorrent sends eta=8640000 if unknown such as queued
if (torrent.Eta == 8640000)
{
return null;
}
return TimeSpan.FromSeconds((int)torrent.Eta); return TimeSpan.FromSeconds((int)torrent.Eta);
} }
protected bool HasReachedSeedLimit(QBittorrentTorrent torrent, QBittorrentPreferences config)
{
if (torrent.RatioLimit >= 0)
{
if (torrent.Ratio < torrent.RatioLimit)
{
return false;
}
}
else if (torrent.RatioLimit == -2 && config.MaxRatioEnabled)
{
if (torrent.Ratio < config.MaxRatio)
{
return false;
}
}
if (torrent.SeedingTimeLimit >= 0)
{
if (torrent.SeedingTime < torrent.SeedingTimeLimit)
{
return false;
}
}
else if (torrent.RatioLimit == -2 && config.MaxSeedingTimeEnabled)
{
if (torrent.SeedingTime < config.MaxSeedingTime)
{
return false;
}
}
return true;
}
} }
} }

@ -14,10 +14,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
[JsonProperty(PropertyName = "max_ratio")] [JsonProperty(PropertyName = "max_ratio")]
public float MaxRatio { get; set; } // Get the global share ratio limit public float MaxRatio { get; set; } // Get the global share ratio limit
[JsonProperty(PropertyName = "max_seeding_time_enabled")]
public bool MaxSeedingTimeEnabled { get; set; } // True if share time limit is enabled
[JsonProperty(PropertyName = "max_seeding_time")]
public long MaxSeedingTime { get; set; } // Get the global share time limit in minutes
[JsonProperty(PropertyName = "max_ratio_act")] [JsonProperty(PropertyName = "max_ratio_act")]
public bool RemoveOnMaxRatio { get; set; } // Action performed when a torrent reaches the maximum share ratio. [false = pause, true = remove] public bool RemoveOnMaxRatio { get; set; } // Action performed when a torrent reaches the maximum share ratio. [false = pause, true = remove]
[JsonProperty(PropertyName = "queueing_enabled")] [JsonProperty(PropertyName = "queueing_enabled")]
public bool QueueingEnabled { get; set; } = true; public bool QueueingEnabled { get; set; } = true;
[JsonProperty(PropertyName = "dht")]
public bool DhtEnabled { get; set; } // DHT enabled (needed for more peers and magnet downloads)
} }
} }

@ -0,0 +1,89 @@
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<QBittorrentTorrent> GetTorrents(QBittorrentSettings settings);
void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings);
void AddTorrentFromFile(string fileName, byte[] fileContent, QBittorrentSettings settings);
void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings);
void SetTorrentLabel(string hash, string label, QBittorrentSettings settings);
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, 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<IQBittorrentProxy> _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<IQBittorrentProxy>(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");
}
}
}

@ -1,40 +1,23 @@
using System;
using System.Collections.Generic;
using System.Net;
using NLog; using NLog;
using NzbDrone.Common.Cache; using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http; using NzbDrone.Common.Http;
using NzbDrone.Common.Serializer; using NzbDrone.Common.Serializer;
using System;
using System.Collections.Generic;
using System.Net;
namespace NzbDrone.Core.Download.Clients.QBittorrent namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
// API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation
public interface IQBittorrentProxy public class QBittorrentProxyV1 : IQBittorrentProxy
{
int GetVersion(QBittorrentSettings settings);
QBittorrentPreferences GetConfig(QBittorrentSettings settings);
List<QBittorrentTorrent> 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
{ {
private readonly IHttpClient _httpClient; private readonly IHttpClient _httpClient;
private readonly Logger _logger; private readonly Logger _logger;
private readonly ICached<Dictionary<string, string>> _authCookieCache; private readonly ICached<Dictionary<string, string>> _authCookieCache;
public QBittorrentProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_logger = logger; _logger = logger;
@ -42,10 +25,54 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
_authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(GetType(), "authCookies"); _authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(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 request = BuildRequest(settings).Resource("/version/api");
var response = ProcessRequest<int>(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; return response;
} }
@ -63,7 +90,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/query/torrents") var request = BuildRequest(settings).Resource("/query/torrents")
.AddQueryParam("label", settings.MusicCategory) .AddQueryParam("label", settings.MusicCategory)
.AddQueryParam("category", settings.MusicCategory); .AddQueryParam("category", settings.MusicCategory);
var response = ProcessRequest<List<QBittorrentTorrent>>(request, settings); var response = ProcessRequest<List<QBittorrentTorrent>>(request, settings);
return response; return response;
@ -94,7 +120,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
} }
public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) public void AddTorrentFromFile(string fileName, byte[] fileContent, QBittorrentSettings settings)
{ {
var request = BuildRequest(settings).Resource("/command/upload") var request = BuildRequest(settings).Resource("/command/upload")
.Post() .Post()
@ -107,7 +133,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause)
{ {
request.AddFormParameter("paused", true); request.AddFormParameter("paused", "true");
} }
var result = ProcessRequest(request, settings); var result = ProcessRequest(request, settings);
@ -119,7 +145,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
} }
public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) public void RemoveTorrent(string hash, bool removeData, QBittorrentSettings settings)
{ {
var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete")
.Post() .Post()
@ -138,7 +164,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{ {
ProcessRequest(setCategoryRequest, settings); 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 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) if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound)
@ -153,6 +179,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
} }
} }
public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings)
{
// Not supported on api v1
}
public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings)
{ {
var request = BuildRequest(settings).Resource("/command/topPrio") var request = BuildRequest(settings).Resource("/command/topPrio")
@ -166,7 +197,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
catch (DownloadClientException ex) catch (DownloadClientException ex)
{ {
// qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled // 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) if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden)
{ {
return; return;
@ -182,7 +212,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/command/pause") var request = BuildRequest(settings).Resource("/command/pause")
.Post() .Post()
.AddFormParameter("hash", hash); .AddFormParameter("hash", hash);
ProcessRequest(request, settings); ProcessRequest(request, settings);
} }
@ -191,7 +220,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/command/resume") var request = BuildRequest(settings).Resource("/command/resume")
.Post() .Post()
.AddFormParameter("hash", hash); .AddFormParameter("hash", hash);
ProcessRequest(request, settings); ProcessRequest(request, settings);
} }
@ -207,12 +235,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) private HttpRequestBuilder BuildRequest(QBittorrentSettings settings)
{ {
var requestBuilder = var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port)
new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port) {
{ LogResponseContent = true,
LogResponseContent = true, NetworkCredential = new NetworkCredential(settings.Username, settings.Password)
NetworkCredential = new NetworkCredential(settings.Username, settings.Password) };
};
return requestBuilder; return requestBuilder;
} }

@ -0,0 +1,359 @@
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<Dictionary<string, string>> _authCookieCache;
public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger)
{
_httpClient = httpClient;
_logger = logger;
_authCookieCache = cacheManager.GetCache<Dictionary<string, string>>(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<QBittorrentPreferences>(request, settings);
return response;
}
public List<QBittorrentTorrent> GetTorrents(QBittorrentSettings settings)
{
var request = BuildRequest(settings).Resource("/api/v2/torrents/info")
.AddQueryParam("category", settings.MusicCategory);
var response = ProcessRequest<List<QBittorrentTorrent>>(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.MusicCategory.IsNotNullOrWhiteSpace())
{
request.AddFormParameter("category", settings.MusicCategory);
}
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.MusicCategory.IsNotNullOrWhiteSpace())
{
request.AddFormParameter("category", settings.MusicCategory);
}
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, bool 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 SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings)
{
var ratioLimit = seedConfiguration.Ratio.HasValue ? seedConfiguration.Ratio : -2;
var seedingTimeLimit = seedConfiguration.SeedTime.HasValue ? (long)seedConfiguration.SeedTime.Value.TotalMinutes : -2;
var request = BuildRequest(settings).Resource("/api/v2/torrents/setShareLimits")
.Post()
.AddFormParameter("hashes", hash)
.AddFormParameter("ratioLimit", ratioLimit)
.AddFormParameter("seedingTimeLimit", seedingTimeLimit);
try
{
ProcessRequest(request, settings);
}
catch (DownloadClientException ex)
{
// setShareLimits was added in api v2.0.1 so catch it case of the unlikely event that someone has api v2.0
if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound)
{
return;
}
throw;
}
}
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<TResult>(HttpRequestBuilder requestBuilder, QBittorrentSettings settings)
where TResult : new()
{
var responseContent = ProcessRequest(requestBuilder, settings);
return Json.Deserialize<TResult>(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())
{
if (reauthenticate)
{
throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.");
}
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);
}
}
}

@ -25,5 +25,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public string SavePath { get; set; } // Torrent save path public string SavePath { get; set; } // Torrent save path
public float Ratio { get; set; } // Torrent share ratio public float Ratio { get; set; } // Torrent share ratio
[JsonProperty(PropertyName = "ratio_limit")] // Per torrent seeding ratio limit (-2 = use global, -1 = unlimited)
public float RatioLimit { get; set; } = -2;
[JsonProperty(PropertyName = "seeding_time")]
public long SeedingTime { get; set; } // Torrent seeding time
[JsonProperty(PropertyName = "seeding_time_limit")] // Per torrent seeding time limit (-2 = use global, -1 = unlimited)
public long SeedingTimeLimit { get; set; } = -2;
} }
} }

@ -353,11 +353,12 @@
<Compile Include="Download\Clients\Pneumatic\Pneumatic.cs" /> <Compile Include="Download\Clients\Pneumatic\Pneumatic.cs" />
<Compile Include="Download\Clients\Pneumatic\PneumaticSettings.cs" /> <Compile Include="Download\Clients\Pneumatic\PneumaticSettings.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentPreferences.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrentPreferences.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentProxyV1.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentProxyV2.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentState.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrentState.cs" />
<Compile Include="Download\Clients\rTorrent\RTorrentDirectoryValidator.cs" /> <Compile Include="Download\Clients\rTorrent\RTorrentDirectoryValidator.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrent.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrent.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentPriority.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrentPriority.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentProxy.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentSettings.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrentSettings.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentTorrent.cs" /> <Compile Include="Download\Clients\QBittorrent\QBittorrentTorrent.cs" />
<Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdPriorityTypeConverter.cs" /> <Compile Include="Download\Clients\Sabnzbd\JsonConverters\SabnzbdPriorityTypeConverter.cs" />
@ -1228,6 +1229,7 @@
<Compile Include="Validation\RuleBuilderExtensions.cs" /> <Compile Include="Validation\RuleBuilderExtensions.cs" />
<Compile Include="Validation\UrlValidator.cs" /> <Compile Include="Validation\UrlValidator.cs" />
<Compile Include="Datastore\Migration\008_change_quality_size_mb_to_kb.cs" /> <Compile Include="Datastore\Migration\008_change_quality_size_mb_to_kb.cs" />
<Compile Include="Download\Clients\QBittorrent\QBittorrentProxySelector.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<BootstrapperPackage Include=".NETFramework,Version=v4.0,Profile=Client"> <BootstrapperPackage Include=".NETFramework,Version=v4.0,Profile=Client">

Loading…
Cancel
Save