diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index 67c67afd1..459c0fde6 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -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(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 Args { get; set; } public Dictionary Headers { get; set; } public string Origin { get; set; } public string Url { get; set; } diff --git a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs index b75be10f1..d4ccc26d3 100644 --- a/src/NzbDrone.Common/Http/HttpRequestBuilder.cs +++ b/src/NzbDrone.Common/Http/HttpRequestBuilder.cs @@ -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; diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index 35cd9e9ab..e42e4a088 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -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() - .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) + .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) .Returns("CBC2F069FE8BB2F544EAE707D75BCD3DE9DCF951"); Mocker.GetMock() .Setup(s => s.Get(It.IsAny())) - .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); + .Returns(r => new HttpResponse(r, new HttpHeader(), new byte[0])); Mocker.GetMock() - .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences()); + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences { DhtEnabled = true }); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(Mocker.GetMock().Object); } protected void GivenRedirectToMagnet() @@ -48,7 +52,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.QBittorrentTests Mocker.GetMock() .Setup(s => s.Get(It.IsAny())) - .Returns(r => new HttpResponse(r, httpHeader, new Byte[0], System.Net.HttpStatusCode.SeeOther)); + .Returns(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().OlderTvPriority = (int) QBittorrentPriority.First; - Subject.Definition.Settings.As().RecentTvPriority = (int) QBittorrentPriority.First; + Subject.Definition.Settings.As().OlderTvPriority = (int)QBittorrentPriority.First; + Subject.Definition.Settings.As().RecentTvPriority = (int)QBittorrentPriority.First; } - protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true) + protected void GivenMaxRatio(float maxRatio, bool removeOnMaxRatio = true) { Mocker.GetMock() .Setup(s => s.GetConfig(It.IsAny())) .Returns(new QBittorrentPreferences - { - RemoveOnMaxRatio = removeOnMaxRatio, - MaxRatio = maxRatio - }); + { + RemoveOnMaxRatio = removeOnMaxRatio, + MaxRatio = maxRatio + }); } protected virtual void GivenTorrents(List torrents) { if (torrents == null) + { torrents = new List(); + } Mocker.GetMock() .Setup(s => s.GetTorrents(It.IsAny())) @@ -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() + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences { DhtEnabled = false }); + + var remoteAlbum = CreateRemoteAlbum(); + remoteAlbum.Release.DownloadUrl = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp"; + + Assert.Throws(() => 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 { 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 { 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(json); torrent.Eta.ToString().Should().Be("18446744073709335000"); } + + [Test] + public void Test_should_force_api_version_check() + { + // Set TestConnection up to fail quick + Mocker.GetMock() + .Setup(v => v.GetApiVersion(It.IsAny())) + .Returns(new Version(1, 0)); + + Subject.Test(); + + Mocker.GetMock() + .Verify(v => v.GetProxy(It.IsAny(), true), Times.Once()); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs index d45ab725e..45f6727f1 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -48,9 +48,25 @@ namespace NzbDrone.Core.Download.Clients.Deluge public string GetVersion(DelugeSettings settings) { - var response = ProcessRequest(settings, "daemon.info"); + try + { + var response = ProcessRequest(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(settings, "daemon.get_version"); + + return response; + } + + throw; + } } public Dictionary GetConfig(DelugeSettings settings) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 4a1febde9..82f6a328f 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -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 { - 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 GetItems() { - var config = _proxy.GetConfig(Settings); - var torrents = _proxy.GetTorrents(Settings); + var config = Proxy.GetConfig(Settings); + var torrents = Proxy.GetTorrents(Settings); var queueItems = new List(); foreach (var torrent in torrents) { - var item = new DownloadClientItem(); - item.DownloadId = torrent.Hash.ToUpper(); - item.Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label; - item.Title = torrent.Name; - item.TotalSize = torrent.Size; - item.DownloadClient = Definition.Name; - item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); - item.RemainingTime = GetRemainingTime(torrent); - item.SeedRatio = torrent.Ratio; - - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); + var item = new DownloadClientItem + { + DownloadId = torrent.Hash.ToUpper(), + Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClient = Definition.Name, + RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), + RemainingTime = GetRemainingTime(torrent), + SeedRatio = torrent.Ratio, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)), + }; // Avoid removing torrents that haven't reached the global max ratio. // Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api). - item.CanMoveFiles = item.CanBeRemoved = (!config.MaxRatioEnabled || config.MaxRatio <= torrent.Ratio) && torrent.State == "pausedUP"; + 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 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; + } } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs index 2f647f5c9..4728e9b5d 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -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) } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs new file mode 100644 index 000000000..2cc71735d --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -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 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 _proxyCache; + private readonly Logger _logger; + + private readonly IQBittorrentProxy _proxyV1; + private readonly IQBittorrentProxy _proxyV2; + + public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, + QBittorrentProxyV2 proxyV2, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _proxyCache = cacheManager.GetCache(GetType()); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private IQBittorrentProxy FetchProxy(QBittorrentSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v2"); + return _proxyV2; + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v1"); + return _proxyV1; + } + + throw new DownloadClientException("Unable to determine qBittorrent API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs similarity index 81% rename from src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs rename to src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index c2ccea7ca..072b675ed 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -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 GetTorrents(QBittorrentSettings settings); - - void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); - void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); - - void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); - void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); - void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); - void PauseTorrent(string hash, QBittorrentSettings settings); - void ResumeTorrent(string hash, QBittorrentSettings settings); - void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); - } - - public class QBittorrentProxy : IQBittorrentProxy + public class QBittorrentProxyV1 : IQBittorrentProxy { private readonly IHttpClient _httpClient; private readonly Logger _logger; private readonly ICached> _authCookieCache; - public QBittorrentProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) { _httpClient = httpClient; _logger = logger; @@ -42,10 +25,54 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); } - public int GetVersion(QBittorrentSettings settings) + public bool IsApiSupported(QBittorrentSettings settings) { + // We can do the api test without having to authenticate since v4.1 will return 404 on the request. var request = BuildRequest(settings).Resource("/version/api"); - var response = ProcessRequest(request, settings); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + // Version request does not require authentication and will return 404 if it doesn't exist. + var request = BuildRequest(settings).Resource("/version/api"); + var response = Version.Parse("1." + ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + // Version request does not require authentication. + var request = BuildRequest(settings).Resource("/version/qbittorrent"); + var response = ProcessRequest(request, settings).TrimStart('v'); return response; } @@ -63,7 +90,6 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent var request = BuildRequest(settings).Resource("/query/torrents") .AddQueryParam("label", settings.MusicCategory) .AddQueryParam("category", settings.MusicCategory); - var response = ProcessRequest>(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; } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs new file mode 100644 index 000000000..5606dc377 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -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> _authCookieCache; + + public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v3.2.0-v4.0.4 will return 404 on the request. + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + var response = Version.Parse(ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/version"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + // eg "4.2alpha" + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/info") + .AddQueryParam("category", settings.MusicCategory); + var response = ProcessRequest>(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormParameter("urls", torrentUrl); + if (settings.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(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + 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); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index 04b803401..cda1b25ce 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -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; + } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index d20c63480..3a85b3ef5 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -353,11 +353,12 @@ + + - @@ -1228,6 +1229,7 @@ +