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
This commit is contained in:
Qstick 2019-03-03 21:53:46 -05:00 committed by GitHub
parent 61b0b2681a
commit 1e48ea58b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 791 additions and 119 deletions

View File

@ -75,11 +75,12 @@ namespace NzbDrone.Common.Test.Http
[Test]
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);
response.Resource.Url.Should().Be(request.Url.FullUri);
response.Resource.Url.EndsWith("/get?test=1");
response.Resource.Args.Should().Contain("test", "1");
}
[Test]
@ -646,6 +647,7 @@ namespace NzbDrone.Common.Test.Http
public class HttpBinResource
{
public Dictionary<string, object> Args { get; set; }
public Dictionary<string, object> Headers { get; set; }
public string Origin { get; set; }
public string Url { get; set; }

View File

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

View File

@ -1,13 +1,13 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Linq;
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.Core.MediaFiles.TorrentInfo;
using NzbDrone.Test.Common;
namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
@ -20,25 +20,29 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{
Subject.Definition = new DownloadClientDefinition();
Subject.Definition.Settings = new QBittorrentSettings
{
Host = "127.0.0.1",
Port = 2222,
Username = "admin",
Password = "pass",
MusicCategory = "tv"
};
{
Host = "127.0.0.1",
Port = 2222,
Username = "admin",
Password = "pass",
MusicCategory = "music"
};
Mocker.GetMock<ITorrentFileInfoReader>()
.Setup(s => s.GetHashFromTorrentFile(It.IsAny<Byte[]>()))
.Setup(s => s.GetHashFromTorrentFile(It.IsAny<byte[]>()))
.Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951");
Mocker.GetMock<IHttpClient>()
.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>()
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentPreferences());
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.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()
@ -48,7 +52,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
Mocker.GetMock<IHttpClient>()
.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()
@ -91,25 +95,27 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
protected void GivenHighPriority()
{
Subject.Definition.Settings.As<QBittorrentSettings>().OlderTvPriority = (int) QBittorrentPriority.First;
Subject.Definition.Settings.As<QBittorrentSettings>().RecentTvPriority = (int) QBittorrentPriority.First;
Subject.Definition.Settings.As<QBittorrentSettings>().OlderTvPriority = (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>()
.Setup(s => s.GetConfig(It.IsAny<QBittorrentSettings>()))
.Returns(new QBittorrentPreferences
{
RemoveOnMaxRatio = removeOnMaxRatio,
MaxRatio = maxRatio
});
{
RemoveOnMaxRatio = removeOnMaxRatio,
MaxRatio = maxRatio
});
}
protected virtual void GivenTorrents(List<QBittorrentTorrent> torrents)
{
if (torrents == null)
{
torrents = new List<QBittorrentTorrent>();
}
Mocker.GetMock<IQBittorrentProxy>()
.Setup(s => s.GetTorrents(It.IsAny<QBittorrentSettings>()))
@ -154,7 +160,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single();
VerifyPaused(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero);
item.RemainingTime.Should().NotHaveValue();
}
[TestCase("pausedUP")]
@ -185,6 +191,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
[TestCase("queuedDL")]
[TestCase("checkingDL")]
[TestCase("metaDL")]
public void queued_item_should_have_required_properties(string state)
{
var torrent = new QBittorrentTorrent
@ -202,7 +209,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single();
VerifyQueued(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero);
item.RemainingTime.Should().NotHaveValue();
}
[Test]
@ -244,7 +251,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
var item = Subject.GetItems().Single();
VerifyWarning(item);
item.RemainingTime.Should().NotBe(TimeSpan.Zero);
item.RemainingTime.Should().NotHaveValue();
}
[Test]
@ -252,9 +259,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum();
var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode);
var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty();
}
@ -264,14 +271,27 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
{
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum();
remoteEpisode.Release.DownloadUrl = magnetUrl;
var remoteAlbum = CreateRemoteAlbum();
remoteAlbum.Release.DownloadUrl = magnetUrl;
var id = Subject.Download(remoteEpisode);
var id = Subject.Download(remoteAlbum);
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]
public void Download_should_set_top_priority()
{
@ -305,7 +325,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
ExceptionVerification.ExpectedWarns(1);
}
[Test]
[Test]
public void should_return_status_with_outputdirs()
{
var config = new QBittorrentPreferences
@ -330,9 +350,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
GivenRedirectToMagnet();
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum();
var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode);
var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty();
}
@ -343,9 +363,9 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
GivenRedirectToTorrent();
GivenSuccessfulDownload();
var remoteEpisode = CreateRemoteAlbum();
var remoteAlbum = CreateRemoteAlbum();
var id = Subject.Download(remoteEpisode);
var id = Subject.Download(remoteAlbum);
id.Should().NotBeNullOrEmpty();
}
@ -446,6 +466,56 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests
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]
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);
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());
}
}
}

View File

@ -48,9 +48,25 @@ namespace NzbDrone.Core.Download.Clients.Deluge
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)

View File

@ -1,25 +1,25 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using FluentValidation.Results;
using NLog;
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;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.Download.Clients.QBittorrent
{
public class QBittorrent : TorrentClientBase<QBittorrentSettings>
{
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,23 @@ 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(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())
{
_proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
}
var isRecentAlbum = remoteAlbum.IsRecentAlbum();
@ -45,23 +52,28 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First ||
!isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First)
{
_proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
}
SetInitialState(hash.ToLower());
if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue))
{
Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings);
}
return hash;
}
protected override string AddFromTorrentFile(RemoteAlbum remoteAlbum, string hash, string filename, Byte[] fileContent)
{
_proxy.AddTorrentFromFile(filename, fileContent, Settings);
Proxy.AddTorrentFromFile(filename, fileContent, Settings);
try
{
if (Settings.MusicCategory.IsNotNullOrWhiteSpace())
{
_proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
Proxy.SetTorrentLabel(hash.ToLower(), Settings.MusicCategory, Settings);
}
}
catch (Exception ex)
@ -76,7 +88,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
if (isRecentAlbum && Settings.RecentTvPriority == (int)QBittorrentPriority.First ||
!isRecentAlbum && Settings.OlderTvPriority == (int)QBittorrentPriority.First)
{
_proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings);
}
}
catch (Exception ex)
@ -86,6 +98,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
SetInitialState(hash.ToLower());
if (remoteAlbum.SeedConfiguration != null && (remoteAlbum.SeedConfiguration.Ratio.HasValue || remoteAlbum.SeedConfiguration.SeedTime.HasValue))
{
Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteAlbum.SeedConfiguration, Settings);
}
return hash;
}
@ -93,28 +110,29 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public override IEnumerable<DownloadClientItem> 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<DownloadClientItem>();
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";
item.CanMoveFiles = item.CanBeRemoved = (torrent.State == "pausedUP" && HasReachedSeedLimit(torrent, config));
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";
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
default: // new status in API? default to downloading
item.Status = DownloadItemStatus.Downloading;
@ -166,12 +196,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);
@ -185,7 +215,10 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
protected override void Test(List<ValidationFailure> failures)
{
failures.AddIfNotNull(TestConnection());
if (failures.Any()) return;
if (failures.Any())
{
return;
}
failures.AddIfNotNull(TestPrioritySupport());
failures.AddIfNotNull(TestGetTorrents());
}
@ -194,8 +227,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 +236,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.MusicCategory.IsNotNullOrWhiteSpace())
@ -225,8 +258,8 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
}
// Complain if qBittorrent is configured to remove torrents on max ratio
var config = _proxy.GetConfig(Settings);
if (config.MaxRatioEnabled && config.RemoveOnMaxRatio)
var config = Proxy.GetConfig(Settings);
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")
{
@ -275,7 +308,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
try
{
var config = _proxy.GetConfig(Settings);
var config = Proxy.GetConfig(Settings);
if (!config.QueueingEnabled)
{
@ -302,7 +335,7 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
{
try
{
_proxy.GetTorrents(Settings);
Proxy.GetTorrents(Settings);
}
catch (Exception ex)
{
@ -320,13 +353,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;
}
}
@ -343,7 +376,48 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
return null;
}
// qBittorrent sends eta=8640000 if unknown such as queued
if (torrent.Eta == 8640000)
{
return null;
}
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;
}
}
}

View File

@ -14,10 +14,19 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
[JsonProperty(PropertyName = "max_ratio")]
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")]
public bool RemoveOnMaxRatio { get; set; } // Action performed when a torrent reaches the maximum share ratio. [false = pause, true = remove]
[JsonProperty(PropertyName = "queueing_enabled")]
public bool QueueingEnabled { get; set; } = true;
[JsonProperty(PropertyName = "dht")]
public bool DhtEnabled { get; set; } // DHT enabled (needed for more peers and magnet downloads)
}
}

View File

@ -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");
}
}
}

View File

@ -1,40 +1,23 @@
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;
using System;
using System.Collections.Generic;
using System.Net;
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<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
public class QBittorrentProxyV1 : IQBittorrentProxy
{
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
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;
_logger = logger;
@ -42,10 +25,54 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
_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 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;
}
@ -63,7 +90,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/query/torrents")
.AddQueryParam("label", settings.MusicCategory)
.AddQueryParam("category", settings.MusicCategory);
var response = ProcessRequest<List<QBittorrentTorrent>>(request, settings);
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")
.Post()
@ -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);
@ -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")
.Post()
@ -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)
@ -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)
{
var request = BuildRequest(settings).Resource("/command/topPrio")
@ -166,7 +197,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;
@ -182,7 +212,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/command/pause")
.Post()
.AddFormParameter("hash", hash);
ProcessRequest(request, settings);
}
@ -191,7 +220,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
var request = BuildRequest(settings).Resource("/command/resume")
.Post()
.AddFormParameter("hash", hash);
ProcessRequest(request, settings);
}
@ -207,12 +235,11 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
private HttpRequestBuilder BuildRequest(QBittorrentSettings settings)
{
var requestBuilder =
new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port)
{
LogResponseContent = true,
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;
}

View File

@ -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);
}
}
}

View File

@ -25,5 +25,15 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent
public string SavePath { get; set; } // Torrent save path
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;
}
}

View File

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