From d6997b0588e28cd6ac16a7632ad8fb8c23751f3d Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Tue, 1 Jan 2019 15:01:24 -0800 Subject: [PATCH 01/11] Fixed getting parent path from a path without another slash Fixed: Manual Import failing for some paths --- src/NzbDrone.Common.Test/PathExtensionFixture.cs | 2 ++ src/NzbDrone.Common/Extensions/PathExtensions.cs | 3 ++- .../MediaFiles/ImportApprovedEpisodesFixture.cs | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index ec5451029..19eadea7a 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -140,6 +140,8 @@ public void path_should_be_parent_on_windows_only(string parentPath, string chil [TestCase(@"C:\Test\mydir", @"C:\Test")] [TestCase(@"C:\Test\", @"C:")] [TestCase(@"C:\", null)] + [TestCase(@"/", null)] + [TestCase(@"/test", null)] public void path_should_return_parent(string path, string parentPath) { path.GetParentPath().Should().Be(parentPath); diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index c7fc857e8..f67733808 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -71,10 +71,11 @@ public static string GetParentPath(this string childPath) var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); - if (index != -1) + if (index > 0) { return parentPath.Substring(0, index); } + return null; } diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 3c510140c..9c4f6a8b9 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -317,5 +317,20 @@ public void should_use_folder_info_release_title_to_find_relative_path() Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}\\subfolder\\{name}.mkv".AsOsAgnostic()))); } + + [Test] + public void should_get_relative_path_when_there_is_no_grandparent() + { + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + var outputPath = Path.Combine(@"C:\".AsOsAgnostic()); + var localEpisode = _approvedDecisions.First().LocalEpisode; + + localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; + localEpisode.Path = Path.Combine(outputPath, name + ".mkv"); + + Subject.Import(new List { _approvedDecisions.First() }, true, null); + + Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}.mkv".AsOsAgnostic()))); + } } } From 900dfd92d0f4b645dc0358b3cd83005fa533475c Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 5 Jan 2019 15:20:52 -0800 Subject: [PATCH 02/11] Fixed: Getting parent of UNC paths --- .../PathExtensionFixture.cs | 22 +++++++++++++++---- .../Extensions/PathExtensions.cs | 15 +++++-------- .../ImportApprovedEpisodesFixture.cs | 19 +++++++++++++++- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index 19eadea7a..53d27ea8c 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -138,20 +138,34 @@ public void path_should_be_parent_on_windows_only(string parentPath, string chil } [TestCase(@"C:\Test\mydir", @"C:\Test")] - [TestCase(@"C:\Test\", @"C:")] + [TestCase(@"C:\Test\", @"C:\")] [TestCase(@"C:\", null)] + [TestCase(@"\\server\share", null)] + [TestCase(@"\\server\share\test", @"\\server\share")] + public void path_should_return_parent_windows(string path, string parentPath) + { + WindowsOnly(); + path.GetParentPath().Should().Be(parentPath); + } + [TestCase(@"/", null)] [TestCase(@"/test", null)] - public void path_should_return_parent(string path, string parentPath) + public void path_should_return_parent_mono(string path, string parentPath) { + MonoOnly(); path.GetParentPath().Should().Be(parentPath); } [Test] public void path_should_return_parent_for_oversized_path() { - var path = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories"; - var parentPath = @"/media/2e168617-f2ae-43fb-b88c-3663af1c8eea/downloads/sabnzbd/nzbdrone/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing/With.Alot.Of.Nested.Directories/Some.Real.Big.Thing"; + MonoOnly(); + + // This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/ + // It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/ + + var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories"; + var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing"; path.GetParentPath().Should().Be(parentPath); } diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index f67733808..5a29bdc9c 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -24,6 +24,8 @@ public static class PathExtensions private static readonly string UPDATE_CLIENT_FOLDER_NAME = "NzbDrone.Update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar; + private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(? path).IsNotNullOrWhiteSpace(); @@ -67,16 +69,11 @@ public static string GetRelativePath(this string parentPath, string childPath) public static string GetParentPath(this string childPath) { - var parentPath = childPath.TrimEnd('\\', '/'); + var cleanPath = OsInfo.IsWindows + ? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "") + : childPath.TrimEnd(Path.DirectorySeparatorChar); - var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); - - if (index > 0) - { - return parentPath.Substring(0, index); - } - - return null; + return Directory.GetParent(cleanPath)?.FullName; } public static bool IsParentPath(this string parentPath, string childPath) diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 9c4f6a8b9..5606044e5 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -322,7 +322,7 @@ public void should_use_folder_info_release_title_to_find_relative_path() public void should_get_relative_path_when_there_is_no_grandparent() { var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; - var outputPath = Path.Combine(@"C:\".AsOsAgnostic()); + var outputPath = @"C:\".AsOsAgnostic(); var localEpisode = _approvedDecisions.First().LocalEpisode; localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; @@ -332,5 +332,22 @@ public void should_get_relative_path_when_there_is_no_grandparent() Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}.mkv".AsOsAgnostic()))); } + + [Test] + public void should_get_relative_path_when_there_is_no_grandparent_for_UNC_path() + { + WindowsOnly(); + + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + var outputPath = @"\\server\share"; + var localEpisode = _approvedDecisions.First().LocalEpisode; + + localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; + localEpisode.Path = Path.Combine(outputPath, name + ".mkv"); + + Subject.Import(new List { _approvedDecisions.First() }, true, null); + + Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}.mkv"))); + } } } From a1f02916d42154d69676aa8e671e913096c73e61 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 9 Jan 2019 17:51:54 -0800 Subject: [PATCH 03/11] Fixed: Importing of completed download when not a child of the download client output path --- .../MediaFiles/ImportApprovedEpisodesFixture.cs | 16 ++++++++++++++++ .../EpisodeImport/ImportApprovedEpisodes.cs | 10 ++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 5606044e5..e1e5e1d5a 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -349,5 +349,21 @@ public void should_get_relative_path_when_there_is_no_grandparent_for_UNC_path() Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}.mkv"))); } + + [Test] + public void should_use_folder_info_release_title_to_find_relative_path_when_file_is_not_in_download_client_item_output_directory() + { + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + var outputPath = Path.Combine(@"C:\Test\Unsorted\TV\".AsOsAgnostic(), name); + var localEpisode = _approvedDecisions.First().LocalEpisode; + + _downloadClientItem.OutputPath = new OsPath(Path.Combine(@"C:\Test\Unsorted\TV-Other\".AsOsAgnostic(), name)); + localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; + localEpisode.Path = Path.Combine(outputPath, "subfolder", name + ".mkv"); + + Subject.Import(new List { _approvedDecisions.First() }, true, _downloadClientItem); + + Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}\\subfolder\\{name}.mkv".AsOsAgnostic()))); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index fb88ad8bf..f44dcdf0a 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -151,12 +151,18 @@ public List Import(List decisions, bool newDownloa private string GetOriginalFilePath(DownloadClientItem downloadClientItem, LocalEpisode localEpisode) { + var path = localEpisode.Path; + if (downloadClientItem != null) { - return downloadClientItem.OutputPath.Directory.ToString().GetRelativePath(localEpisode.Path); + var outputDirectory = downloadClientItem.OutputPath.Directory.ToString(); + + if (outputDirectory.IsParentPath(path)) + { + return outputDirectory.GetRelativePath(path); + } } - var path = localEpisode.Path; var folderEpisodeInfo = localEpisode.FolderEpisodeInfo; if (folderEpisodeInfo != null) From 4a2277b4249414ecbf56433cc84201af6b29abb7 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 10 Jan 2019 20:32:18 -0800 Subject: [PATCH 04/11] Fix path tests --- src/NzbDrone.Common.Test/PathExtensionFixture.cs | 6 +++--- src/NzbDrone.Common/Extensions/PathExtensions.cs | 5 +++++ .../MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/NzbDrone.Common.Test/PathExtensionFixture.cs b/src/NzbDrone.Common.Test/PathExtensionFixture.cs index 53d27ea8c..b85fdfcdd 100644 --- a/src/NzbDrone.Common.Test/PathExtensionFixture.cs +++ b/src/NzbDrone.Common.Test/PathExtensionFixture.cs @@ -149,7 +149,7 @@ public void path_should_return_parent_windows(string path, string parentPath) } [TestCase(@"/", null)] - [TestCase(@"/test", null)] + [TestCase(@"/test", "/")] public void path_should_return_parent_mono(string path, string parentPath) { MonoOnly(); @@ -164,8 +164,8 @@ public void path_should_return_parent_for_oversized_path() // This test will fail on Windows if long path support is not enabled: https://www.howtogeek.com/266621/how-to-make-windows-10-accept-file-paths-over-260-characters/ // It will also fail if the app isn't configured to use long path (such as resharper): https://blogs.msdn.microsoft.com/jeremykuhne/2016/07/30/net-4-6-2-and-long-paths-on-windows-10/ - var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories"; - var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing"; + var path = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories".AsOsAgnostic(); + var parentPath = @"C:\media\2e168617-f2ae-43fb-b88c-3663af1c8eea\downloads\sabnzbd\nzbdrone\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing\With.Alot.Of.Nested.Directories\Some.Real.Big.Thing".AsOsAgnostic(); path.GetParentPath().Should().Be(parentPath); } diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index 5a29bdc9c..f4c0d0d21 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -73,6 +73,11 @@ public static string GetParentPath(this string childPath) ? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "") : childPath.TrimEnd(Path.DirectorySeparatorChar); + if (cleanPath.IsNullOrWhiteSpace()) + { + return null; + } + return Directory.GetParent(cleanPath)?.FullName; } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index f44dcdf0a..7593615f6 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -183,7 +183,7 @@ private string GetOriginalFilePath(DownloadClientItem downloadClientItem, LocalE return grandparentPath.GetRelativePath(path); } - return Path.Combine(Path.GetFileName(parentPath), Path.GetFileName(path)); + return Path.GetFileName(path); } private string GetSceneName(DownloadClientItem downloadClientItem, LocalEpisode localEpisode) From 2c95f07cb277a8bdf3c9e87eac2132ab1dd8ff73 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 10 Jan 2019 23:26:25 -0800 Subject: [PATCH 05/11] Another path test fix --- .../ImportApprovedEpisodesFixture.cs | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index e1e5e1d5a..3186a09cc 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -319,10 +319,29 @@ public void should_use_folder_info_release_title_to_find_relative_path() } [Test] - public void should_get_relative_path_when_there_is_no_grandparent() + public void should_get_relative_path_when_there_is_no_grandparent_windows() { + WindowsOnly(); + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; - var outputPath = @"C:\".AsOsAgnostic(); + var outputPath = @"C:\"; + var localEpisode = _approvedDecisions.First().LocalEpisode; + + localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; + localEpisode.Path = Path.Combine(outputPath, name + ".mkv"); + + Subject.Import(new List { _approvedDecisions.First() }, true, null); + + Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}.mkv".AsOsAgnostic()))); + } + + [Test] + public void should_get_relative_path_when_there_is_no_grandparent_mono() + { + MonoOnly(); + + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + var outputPath = "/"; var localEpisode = _approvedDecisions.First().LocalEpisode; localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; From c3c6b3d166038b1508dca658f24af388b013b522 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Wed, 6 Feb 2019 19:26:27 -0800 Subject: [PATCH 06/11] Fixed: Importing completed downloads from NZBGet with post processing script failing Fixes #2919 # Conflicts: # src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs --- .../MediaFiles/ImportApprovedEpisodesFixture.cs | 17 +++++++++++++++++ .../EpisodeImport/ImportApprovedEpisodes.cs | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs index 3186a09cc..67c86a815 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedEpisodesFixture.cs @@ -384,5 +384,22 @@ public void should_use_folder_info_release_title_to_find_relative_path_when_file Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}\\subfolder\\{name}.mkv".AsOsAgnostic()))); } + + [Test] + public void should_use_folder_info_release_title_to_find_relative_path_when_download_client_item_has_an_empty_output_path() + { + var name = "Series.Title.S01E01.720p.HDTV.x264-Sonarr"; + var outputPath = Path.Combine(@"C:\Test\Unsorted\TV\".AsOsAgnostic(), name); + var localEpisode = _approvedDecisions.First().LocalEpisode; + + _downloadClientItem.OutputPath = new OsPath(); + localEpisode.FolderEpisodeInfo = new ParsedEpisodeInfo { ReleaseTitle = name }; + localEpisode.Path = Path.Combine(outputPath, "subfolder", name + ".mkv"); + + Subject.Import(new List { _approvedDecisions.First() }, true, _downloadClientItem); + + Mocker.GetMock().Verify(v => v.Add(It.Is(c => c.OriginalFilePath == $"{name}\\subfolder\\{name}.mkv".AsOsAgnostic()))); + } + } } diff --git a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs index 7593615f6..5454aa62c 100644 --- a/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs +++ b/src/NzbDrone.Core/MediaFiles/EpisodeImport/ImportApprovedEpisodes.cs @@ -153,7 +153,7 @@ private string GetOriginalFilePath(DownloadClientItem downloadClientItem, LocalE { var path = localEpisode.Path; - if (downloadClientItem != null) + if (downloadClientItem != null && !downloadClientItem.OutputPath.IsEmpty) { var outputDirectory = downloadClientItem.OutputPath.Directory.ToString(); From aa462161178d4ec132da3f5fc75d12562714e147 Mon Sep 17 00:00:00 2001 From: Mark Bebbington Date: Fri, 8 Feb 2019 19:05:29 +0000 Subject: [PATCH 07/11] Fixed: qBittorrent api v2 support (qbit v4.1+) fixes #2887 closes #2951 ref #2945 --- .../QBittorrentTests/QBittorrentFixture.cs | 22 +- .../Clients/QBittorrent/QBittorrent.cs | 71 ++-- .../QBittorrent/QBittorrentProxySelector.cs | 88 +++++ ...ttorrentProxy.cs => QBittorrentProxyV1.cs} | 115 +++--- .../Clients/QBittorrent/QBittorrentProxyV2.cs | 328 ++++++++++++++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 4 +- 6 files changed, 545 insertions(+), 83 deletions(-) create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs rename src/NzbDrone.Core/Download/Clients/QBittorrent/{QBittorrentProxy.cs => QBittorrentProxyV1.cs} (77%) create mode 100644 src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs diff --git a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index ecac74902..cc36a447b 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -37,8 +37,12 @@ public void Setup() .Returns(r => new HttpResponse(r, new HttpHeader(), new Byte[0])); Mocker.GetMock() - .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences()); + .Setup(s => s.GetConfig(It.IsAny())) + .Returns(new QBittorrentPreferences()); + + Mocker.GetMock() + .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) + .Returns(Mocker.GetMock().Object); } protected void GivenRedirectToMagnet() @@ -508,5 +512,19 @@ public void should_handle_eta_biginteger() torrent.Eta.ToString().Should().Be("18446744073709335000"); } + + [Test] + public void Test_should_force_api_version_check() + { + // Set TestConnection up to fail quick + Mocker.GetMock() + .Setup(v => v.GetApiVersion(It.IsAny())) + .Returns(new Version(1, 0)); + + Subject.Test(); + + Mocker.GetMock() + .Verify(v => v.GetProxy(It.IsAny(), true), Times.Once()); + } } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 66c12449f..a519134b4 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -17,9 +17,9 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { public class QBittorrent : TorrentClientBase { - private readonly IQBittorrentProxy _proxy; + private readonly IQBittorrentProxySelector _proxySelector; - public QBittorrent(IQBittorrentProxy proxy, + public QBittorrent(IQBittorrentProxySelector proxySelector, ITorrentFileInfoReader torrentFileInfoReader, IHttpClient httpClient, IConfigService configService, @@ -28,16 +28,18 @@ public QBittorrent(IQBittorrentProxy proxy, Logger logger) : base(torrentFileInfoReader, httpClient, configService, diskProvider, remotePathMappingService, logger) { - _proxy = proxy; + _proxySelector = proxySelector; } + private IQBittorrentProxy Proxy => _proxySelector.GetProxy(Settings); + protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) { - _proxy.AddTorrentFromUrl(magnetLink, Settings); + Proxy.AddTorrentFromUrl(magnetLink, Settings); if (Settings.TvCategory.IsNotNullOrWhiteSpace()) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + Proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); } var isRecentEpisode = remoteEpisode.IsRecentEpisode(); @@ -45,7 +47,7 @@ protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } SetInitialState(hash.ToLower()); @@ -55,13 +57,13 @@ protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string hash, string filename, Byte[] fileContent) { - _proxy.AddTorrentFromFile(filename, fileContent, Settings); + Proxy.AddTorrentFromFile(filename, fileContent, Settings); try { if (Settings.TvCategory.IsNotNullOrWhiteSpace()) { - _proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); + Proxy.SetTorrentLabel(hash.ToLower(), Settings.TvCategory, Settings); } } catch (Exception ex) @@ -76,7 +78,7 @@ protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string if (isRecentEpisode && Settings.RecentTvPriority == (int)QBittorrentPriority.First || !isRecentEpisode && Settings.OlderTvPriority == (int)QBittorrentPriority.First) { - _proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); + Proxy.MoveTorrentToTopInQueue(hash.ToLower(), Settings); } } catch (Exception ex) @@ -93,28 +95,29 @@ protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string public override IEnumerable GetItems() { - var config = _proxy.GetConfig(Settings); - var torrents = _proxy.GetTorrents(Settings); + var config = Proxy.GetConfig(Settings); + var torrents = Proxy.GetTorrents(Settings); var queueItems = new List(); foreach (var torrent in torrents) { - var item = new DownloadClientItem(); - item.DownloadId = torrent.Hash.ToUpper(); - item.Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label; - item.Title = torrent.Name; - item.TotalSize = torrent.Size; - item.DownloadClient = Definition.Name; - item.RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)); - item.RemainingTime = GetRemainingTime(torrent); - item.SeedRatio = torrent.Ratio; - - item.OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)); - + var item = new DownloadClientItem() + { + DownloadId = torrent.Hash.ToUpper(), + Category = torrent.Category.IsNotNullOrWhiteSpace() ? torrent.Category : torrent.Label, + Title = torrent.Name, + TotalSize = torrent.Size, + DownloadClient = Definition.Name, + RemainingSize = (long)(torrent.Size * (1.0 - torrent.Progress)), + RemainingTime = GetRemainingTime(torrent), + SeedRatio = torrent.Ratio, + OutputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, new OsPath(torrent.SavePath)), + }; // Avoid removing torrents that haven't reached the global max ratio. // Removal also requires the torrent to be paused, in case a higher max ratio was set on the torrent itself (which is not exposed by the api). item.CanMoveFiles = item.CanBeRemoved = (!config.MaxRatioEnabled || config.MaxRatio <= torrent.Ratio) && torrent.State == "pausedUP"; + if (!item.OutputPath.IsEmpty && item.OutputPath.FileName != torrent.Name) { @@ -166,12 +169,12 @@ public override IEnumerable GetItems() public override void RemoveItem(string hash, bool deleteData) { - _proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); + Proxy.RemoveTorrent(hash.ToLower(), deleteData, Settings); } public override DownloadClientInfo GetStatus() { - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); var destDir = new OsPath(config.SavePath); @@ -194,8 +197,8 @@ private ValidationFailure TestConnection() { try { - var version = _proxy.GetVersion(Settings); - if (version < 5) + var version = _proxySelector.GetProxy(Settings, true).GetApiVersion(Settings); + if (version < Version.Parse("1.5")) { // API version 5 introduced the "save_path" property in /query/torrents return new NzbDroneValidationFailure("Host", "Unsupported client version") @@ -203,7 +206,7 @@ private ValidationFailure TestConnection() DetailedDescription = "Please upgrade to qBittorrent version 3.2.4 or higher." }; } - else if (version < 6) + else if (version < Version.Parse("1.6")) { // API version 6 introduced support for labels if (Settings.TvCategory.IsNotNullOrWhiteSpace()) @@ -225,7 +228,7 @@ private ValidationFailure TestConnection() } // Complain if qBittorrent is configured to remove torrents on max ratio - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); if (config.MaxRatioEnabled && config.RemoveOnMaxRatio) { return new NzbDroneValidationFailure(String.Empty, "qBittorrent is configured to remove torrents when they reach their Share Ratio Limit") @@ -275,7 +278,7 @@ private ValidationFailure TestPrioritySupport() try { - var config = _proxy.GetConfig(Settings); + var config = Proxy.GetConfig(Settings); if (!config.QueueingEnabled) { @@ -302,7 +305,7 @@ private ValidationFailure TestGetTorrents() { try { - _proxy.GetTorrents(Settings); + Proxy.GetTorrents(Settings); } catch (Exception ex) { @@ -320,13 +323,13 @@ private void SetInitialState(string hash) switch ((QBittorrentState)Settings.InitialState) { case QBittorrentState.ForceStart: - _proxy.SetForceStart(hash, true, Settings); + Proxy.SetForceStart(hash, true, Settings); break; case QBittorrentState.Start: - _proxy.ResumeTorrent(hash, Settings); + Proxy.ResumeTorrent(hash, Settings); break; case QBittorrentState.Pause: - _proxy.PauseTorrent(hash, Settings); + Proxy.PauseTorrent(hash, Settings); break; } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs new file mode 100644 index 000000000..0cbc44343 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; + +using NLog; +using NzbDrone.Common.Cache; + +using NzbDrone.Common.Http; + + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + public interface IQBittorrentProxy + { + bool IsApiSupported(QBittorrentSettings settings); + Version GetApiVersion(QBittorrentSettings settings); + string GetVersion(QBittorrentSettings settings); + QBittorrentPreferences GetConfig(QBittorrentSettings settings); + List GetTorrents(QBittorrentSettings settings); + + void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); + void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); + + void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); + void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); + void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); + void PauseTorrent(string hash, QBittorrentSettings settings); + void ResumeTorrent(string hash, QBittorrentSettings settings); + void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); + } + + public interface IQBittorrentProxySelector + { + IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force = false); + } + + public class QBittorrentProxySelector : IQBittorrentProxySelector + { + private readonly IHttpClient _httpClient; + private readonly ICached _proxyCache; + private readonly Logger _logger; + + private readonly IQBittorrentProxy _proxyV1; + private readonly IQBittorrentProxy _proxyV2; + + public QBittorrentProxySelector(QBittorrentProxyV1 proxyV1, + QBittorrentProxyV2 proxyV2, + IHttpClient httpClient, + ICacheManager cacheManager, + Logger logger) + { + _httpClient = httpClient; + _proxyCache = cacheManager.GetCache(GetType()); + _logger = logger; + + _proxyV1 = proxyV1; + _proxyV2 = proxyV2; + } + + public IQBittorrentProxy GetProxy(QBittorrentSettings settings, bool force) + { + var proxyKey = $"{settings.Host}_{settings.Port}"; + + if (force) + { + _proxyCache.Remove(proxyKey); + } + + return _proxyCache.Get(proxyKey, () => FetchProxy(settings), TimeSpan.FromMinutes(10.0)); + } + + private IQBittorrentProxy FetchProxy(QBittorrentSettings settings) + { + if (_proxyV2.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v2"); + return _proxyV2; + } + + if (_proxyV1.IsApiSupported(settings)) + { + _logger.Trace("Using qbitTorrent API v1"); + return _proxyV1; + } + + throw new DownloadClientException("Unable to determine qBittorrent API version"); + } + } +} diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs similarity index 77% rename from src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs rename to src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index 600802a73..eb76695dd 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -11,41 +11,68 @@ namespace NzbDrone.Core.Download.Clients.QBittorrent { // API https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-Documentation - public interface IQBittorrentProxy - { - int GetVersion(QBittorrentSettings settings); - QBittorrentPreferences GetConfig(QBittorrentSettings settings); - List GetTorrents(QBittorrentSettings settings); - - void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings); - void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings); - - void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings); - void SetTorrentLabel(string hash, string label, QBittorrentSettings settings); - void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings); - void PauseTorrent(string hash, QBittorrentSettings settings); - void ResumeTorrent(string hash, QBittorrentSettings settings); - void SetForceStart(string hash, bool enabled, QBittorrentSettings settings); - } - - public class QBittorrentProxy : IQBittorrentProxy + public class QBittorrentProxyV1 : IQBittorrentProxy { private readonly IHttpClient _httpClient; private readonly Logger _logger; private readonly ICached> _authCookieCache; - public QBittorrentProxy(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + public QBittorrentProxyV1(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) { _httpClient = httpClient; _logger = logger; - _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } - public int GetVersion(QBittorrentSettings settings) + public bool IsApiSupported(QBittorrentSettings settings) { + // We can do the api test without having to authenticate since v4.1 will return 404 on the request. var request = BuildRequest(settings).Resource("/version/api"); - var response = ProcessRequest(request, settings); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + // Version request does not require authentication and will return 404 if it doesn't exist. + var request = BuildRequest(settings).Resource("/version/api"); + var response = Version.Parse("1." + ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + // Version request does not require authentication. + var request = BuildRequest(settings).Resource("/version/qbittorrent"); + var response = ProcessRequest(request, settings).TrimStart('v'); return response; } @@ -63,7 +90,6 @@ public List GetTorrents(QBittorrentSettings settings) var request = BuildRequest(settings).Resource("/query/torrents") .AddQueryParam("label", settings.TvCategory) .AddQueryParam("category", settings.TvCategory); - var response = ProcessRequest>(request, settings); return response; @@ -107,7 +133,7 @@ public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentS if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) { - request.AddFormParameter("paused", true); + request.AddFormParameter("paused", "true"); } var result = ProcessRequest(request, settings); @@ -122,8 +148,8 @@ public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentS public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource(removeData ? "/command/deletePerm" : "/command/delete") - .Post() - .AddFormParameter("hashes", hash); + .Post() + .AddFormParameter("hashes", hash); ProcessRequest(request, settings); } @@ -138,7 +164,7 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti { ProcessRequest(setCategoryRequest, settings); } - catch(DownloadClientException ex) + catch (DownloadClientException ex) { // if setCategory fails due to method not being found, then try older setLabel command for qBittorrent < v.3.3.5 if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.NotFound) @@ -151,14 +177,14 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti ProcessRequest(setLabelRequest, settings); } } + } public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/topPrio") - .Post() - .AddFormParameter("hashes", hash); - + .Post() + .AddFormParameter("hashes", hash); try { ProcessRequest(request, settings); @@ -166,7 +192,6 @@ public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) catch (DownloadClientException ex) { // qBittorrent rejects all Prio commands with 403: Forbidden if Options -> BitTorrent -> Torrent Queueing is not enabled - #warning FIXME: so wouldn't the reauthenticate logic trigger on Forbidden? if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Forbidden) { return; @@ -180,9 +205,8 @@ public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) public void PauseTorrent(string hash, QBittorrentSettings settings) { var request = BuildRequest(settings).Resource("/command/pause") - .Post() - .AddFormParameter("hash", hash); - + .Post() + .AddFormParameter("hash", hash); ProcessRequest(request, settings); } @@ -191,7 +215,6 @@ public void ResumeTorrent(string hash, QBittorrentSettings settings) var request = BuildRequest(settings).Resource("/command/resume") .Post() .AddFormParameter("hash", hash); - ProcessRequest(request, settings); } @@ -200,17 +223,17 @@ public void SetForceStart(string hash, bool enabled, QBittorrentSettings setting var request = BuildRequest(settings).Resource("/command/setForceStart") .Post() .AddFormParameter("hashes", hash) - .AddFormParameter("value", enabled ? "true": "false"); - + .AddFormParameter("value", enabled ? "true" : "false"); ProcessRequest(request, settings); } private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) { - var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port); - requestBuilder.LogResponseContent = true; - requestBuilder.NetworkCredential = new NetworkCredential(settings.Username, settings.Password); - + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; return requestBuilder; } @@ -274,11 +297,11 @@ private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSe { _authCookieCache.Remove(authKey); - var authLoginRequest = BuildRequest(settings).Resource("/login") - .Post() - .AddFormParameter("username", settings.Username ?? string.Empty) - .AddFormParameter("password", settings.Password ?? string.Empty) - .Build(); + var authLoginRequest = BuildRequest(settings).Resource( "/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); HttpResponse response; try diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs new file mode 100644 index 000000000..0f0c6c1b4 --- /dev/null +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Download.Clients.QBittorrent +{ + // API https://github.com/qbittorrent/qBittorrent/wiki/Web-API-Documentation + + public class QBittorrentProxyV2 : IQBittorrentProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + private readonly ICached> _authCookieCache; + + public QBittorrentProxyV2(IHttpClient httpClient, ICacheManager cacheManager, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + + _authCookieCache = cacheManager.GetCache>(GetType(), "authCookies"); + } + + public bool IsApiSupported(QBittorrentSettings settings) + { + // We can do the api test without having to authenticate since v3.2.0-v4.0.4 will return 404 on the request. + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + request.SuppressHttpError = true; + + try + { + var response = _httpClient.Execute(request.Build()); + + // Version request will return 404 if it doesn't exist. + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (response.StatusCode == HttpStatusCode.Forbidden) + { + return true; + } + + if (response.HasHttpError) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", new HttpException(response)); + } + + return true; + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + + public Version GetApiVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/webapiVersion"); + var response = Version.Parse(ProcessRequest(request, settings)); + + return response; + } + + public string GetVersion(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/version"); + var response = ProcessRequest(request, settings).TrimStart('v'); + + // eg "4.2alpha" + return response; + } + + public QBittorrentPreferences GetConfig(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/app/preferences"); + var response = ProcessRequest(request, settings); + + return response; + } + + public List GetTorrents(QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/info") + .AddQueryParam("category", settings.TvCategory); + var response = ProcessRequest>(request, settings); + + return response; + } + + public void AddTorrentFromUrl(string torrentUrl, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormParameter("urls", torrentUrl); + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", true); + } + + var result = ProcessRequest(request, settings); + + // Note: Older qbit versions returned nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent by url"); + } + } + + public void AddTorrentFromFile(string fileName, Byte[] fileContent, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/add") + .Post() + .AddFormUpload("torrents", fileName, fileContent); + + if (settings.TvCategory.IsNotNullOrWhiteSpace()) + { + request.AddFormParameter("category", settings.TvCategory); + } + + if ((QBittorrentState)settings.InitialState == QBittorrentState.Pause) + { + request.AddFormParameter("paused", "true"); + } + + var result = ProcessRequest(request, settings); + + // Note: Current qbit versions return nothing, so we can't do != "Ok." here. + if (result == "Fails.") + { + throw new DownloadClientException("Download client failed to add torrent"); + } + } + + public void RemoveTorrent(string hash, Boolean removeData, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/delete") + .Post() + .AddFormParameter("hashes", hash); + + if (removeData) + { + request.AddFormParameter("deleteFiles", "true"); + } + + ProcessRequest(request, settings); + } + + public void SetTorrentLabel(string hash, string label, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setCategory") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("category", label); + ProcessRequest(request, settings); + } + + public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/topPrio") + .Post() + .AddFormParameter("hashes", hash); + + try + { + ProcessRequest(request, settings); + } + catch (DownloadClientException ex) + { + // qBittorrent rejects all Prio commands with 409: Conflict if Options -> BitTorrent -> Torrent Queueing is not enabled + if (ex.InnerException is HttpException && (ex.InnerException as HttpException).Response.StatusCode == HttpStatusCode.Conflict) + { + return; + } + + throw; + } + + } + + public void PauseTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/pause") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void ResumeTorrent(string hash, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/resume") + .Post() + .AddFormParameter("hashes", hash); + ProcessRequest(request, settings); + } + + public void SetForceStart(string hash, bool enabled, QBittorrentSettings settings) + { + var request = BuildRequest(settings).Resource("/api/v2/torrents/setForceStart") + .Post() + .AddFormParameter("hashes", hash) + .AddFormParameter("value", enabled ? "true" : "false"); + ProcessRequest(request, settings); + } + + private HttpRequestBuilder BuildRequest(QBittorrentSettings settings) + { + var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port) + { + LogResponseContent = true, + NetworkCredential = new NetworkCredential(settings.Username, settings.Password) + }; + return requestBuilder; + } + + private TResult ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + where TResult : new() + { + var responseContent = ProcessRequest(requestBuilder, settings); + + return Json.Deserialize(responseContent); + } + + private string ProcessRequest(HttpRequestBuilder requestBuilder, QBittorrentSettings settings) + { + AuthenticateClient(requestBuilder, settings); + + var request = requestBuilder.Build(); + request.LogResponseContent = true; + + HttpResponse response; + try + { + response = _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Debug("Authentication required, logging in."); + + AuthenticateClient(requestBuilder, settings, true); + + request = requestBuilder.Build(); + + response = _httpClient.Execute(request); + } + else + { + throw new DownloadClientException("Failed to connect to qBittorrent, check your settings.", ex); + } + } + catch (WebException ex) + { + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + return response.Content; + } + + private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSettings settings, bool reauthenticate = false) + { + if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) + { + return; + } + + var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password); + + var cookies = _authCookieCache.Find(authKey); + + if (cookies == null || reauthenticate) + { + _authCookieCache.Remove(authKey); + + var authLoginRequest = BuildRequest(settings).Resource("/api/v2/auth/login") + .Post() + .AddFormParameter("username", settings.Username ?? string.Empty) + .AddFormParameter("password", settings.Password ?? string.Empty) + .Build(); + + HttpResponse response; + try + { + response = _httpClient.Execute(authLoginRequest); + } + catch (HttpException ex) + { + _logger.Debug("qbitTorrent authentication failed."); + if (ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent.", ex); + } + + throw new DownloadClientException("Failed to connect to qBittorrent, please check your settings.", ex); + } + catch (WebException ex) + { + throw new DownloadClientUnavailableException("Failed to connect to qBittorrent, please check your settings.", ex); + } + + if (response.Content != "Ok.") // returns "Fails." on bad login + { + _logger.Debug("qbitTorrent authentication failed."); + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } + + _logger.Debug("qBittorrent authentication succeeded."); + + cookies = response.GetCookies(); + + _authCookieCache.Set(authKey, cookies); + } + + requestBuilder.SetCookies(cookies); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index e92508e37..bf6eb22e7 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -460,7 +460,7 @@ - + @@ -1234,6 +1234,8 @@ + + From 1b939ebf4bfe8e574c2948c4f754a90faed14451 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sat, 2 Mar 2019 20:49:46 +0100 Subject: [PATCH 08/11] Fixed: Magnet Link progress visualisation and adding magnet links if dht is disabled in qBittorrent --- .../Clients/QBittorrent/QBittorrent.cs | 23 +++++++++++++++++++ .../QBittorrent/QBittorrentPreferences.cs | 5 +++- .../Clients/QBittorrent/QBittorrentProxyV2.cs | 4 ++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index a519134b4..8d99a2bd1 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -35,6 +35,11 @@ public QBittorrent(IQBittorrentProxySelector proxySelector, protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string hash, string magnetLink) { + if (!Proxy.GetConfig(Settings).DhtEnabled) + { + throw new NotSupportedException("Magnet Links not supported if DHT is disabled"); + } + Proxy.AddTorrentFromUrl(magnetLink, Settings); if (Settings.TvCategory.IsNotNullOrWhiteSpace()) @@ -155,6 +160,18 @@ public override IEnumerable GetItems() 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; @@ -346,6 +363,12 @@ private void SetInitialState(string hash) return null; } + // qBittorrent sends eta=8640000 if unknown such as queued + if (torrent.Eta == 8640000) + { + return null; + } + return TimeSpan.FromSeconds((int)torrent.Eta); } } diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs index 3278cbaab..69b358634 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.QBittorrent { @@ -19,5 +19,8 @@ public class QBittorrentPreferences [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/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 0f0c6c1b4..361680a70 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -272,6 +272,10 @@ private void AuthenticateClient(HttpRequestBuilder requestBuilder, QBittorrentSe { if (settings.Username.IsNullOrWhiteSpace() || settings.Password.IsNullOrWhiteSpace()) { + if (reauthenticate) + { + throw new DownloadClientAuthenticationException("Failed to authenticate with qBittorrent."); + } return; } From faa2d632e56986b1b286dfd245d663cc11f043a6 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sun, 3 Mar 2019 19:24:01 +0100 Subject: [PATCH 09/11] New: Indexer Seed Limit settings applied to new downloads for qBittorrent closes #2607 --- .../Http/HttpRequestBuilder.cs | 2 +- .../Clients/QBittorrent/QBittorrent.cs | 38 ++++++++++++++++++- .../QBittorrent/QBittorrentPreferences.cs | 6 +++ .../QBittorrent/QBittorrentProxySelector.cs | 1 + .../Clients/QBittorrent/QBittorrentProxyV1.cs | 4 ++ .../Clients/QBittorrent/QBittorrentProxyV2.cs | 27 +++++++++++++ .../Clients/QBittorrent/QBittorrentTorrent.cs | 12 +++++- 7 files changed, 86 insertions(+), 4 deletions(-) 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 @@ public virtual HttpRequestBuilder AddFormParameter(string key, object value) 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/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index 8d99a2bd1..af43aac9a 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -57,6 +57,11 @@ protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string SetInitialState(hash.ToLower()); + if (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue) + { + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteEpisode.SeedConfiguration, Settings); + } + return hash; } @@ -93,6 +98,11 @@ protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string SetInitialState(hash.ToLower()); + if (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue) + { + Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteEpisode.SeedConfiguration, Settings); + } + return hash; } @@ -119,9 +129,10 @@ public override IEnumerable GetItems() 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) @@ -246,7 +257,7 @@ private ValidationFailure TestConnection() // Complain if qBittorrent is configured to remove torrents on max ratio 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") { @@ -371,5 +382,28 @@ private void SetInitialState(string hash) 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 69b358634..4728e9b5d 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentPreferences.cs @@ -14,6 +14,12 @@ public class QBittorrentPreferences [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] diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs index 0cbc44343..d9322bdc5 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxySelector.cs @@ -22,6 +22,7 @@ public interface IQBittorrentProxy void RemoveTorrent(string hash, Boolean 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); diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs index eb76695dd..3588a2e11 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV1.cs @@ -177,7 +177,11 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti ProcessRequest(setLabelRequest, settings); } } + } + public void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, QBittorrentSettings settings) + { + // Not supported on api v1 } public void MoveTorrentToTopInQueue(string hash, QBittorrentSettings settings) diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs index 361680a70..52d3c823e 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentProxyV2.cs @@ -165,6 +165,33 @@ public void SetTorrentLabel(string hash, string label, QBittorrentSettings setti 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") diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs index d5fa0b5e7..c1f3b0d09 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrentTorrent.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Newtonsoft.Json; namespace NzbDrone.Core.Download.Clients.QBittorrent @@ -25,5 +25,15 @@ public class QBittorrentTorrent 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; + } } From 08ba2730898d443d3ce6fa19adafbf3bee10294d Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sun, 3 Mar 2019 21:19:25 +0100 Subject: [PATCH 10/11] fixed qbittorrent tests failing due to incorrect test setup. And http tests failed due to httpbin changing their output. --- .../Http/HttpClientFixture.cs | 8 +- .../QBittorrentTests/QBittorrentFixture.cs | 94 ++++++++++++++++--- .../Clients/QBittorrent/QBittorrent.cs | 4 +- 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index bf890a81b..4bcd23222 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -130,11 +130,12 @@ public void should_execute_https_get() [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] @@ -706,6 +707,7 @@ public void should_reject_malformed_domain_cookie(string malformedCookie) 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.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs index cc36a447b..94ce08bba 100644 --- a/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs +++ b/src/NzbDrone.Core.Test/Download/DownloadClientTests/QBittorrentTests/QBittorrentFixture.cs @@ -20,13 +20,13 @@ public void Setup() { Subject.Definition = new DownloadClientDefinition(); Subject.Definition.Settings = new QBittorrentSettings - { - Host = "127.0.0.1", - Port = 2222, - Username = "admin", - Password = "pass", - TvCategory = "tv" - }; + { + Host = "127.0.0.1", + Port = 2222, + Username = "admin", + Password = "pass", + TvCategory = "tv" + }; Mocker.GetMock() .Setup(s => s.GetHashFromTorrentFile(It.IsAny())) @@ -38,7 +38,7 @@ public void Setup() Mocker.GetMock() .Setup(s => s.GetConfig(It.IsAny())) - .Returns(new QBittorrentPreferences()); + .Returns(new QBittorrentPreferences() { DhtEnabled = true }); Mocker.GetMock() .Setup(s => s.GetProxy(It.IsAny(), It.IsAny())) @@ -104,10 +104,10 @@ 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) @@ -158,7 +158,7 @@ public void paused_item_should_have_required_properties() var item = Subject.GetItems().Single(); VerifyPaused(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [TestCase("pausedUP")] @@ -189,6 +189,7 @@ public void completed_item_should_have_required_properties(string state) [TestCase("queuedDL")] [TestCase("checkingDL")] + [TestCase("metaDL")] public void queued_item_should_have_required_properties(string state) { var torrent = new QBittorrentTorrent @@ -206,7 +207,7 @@ public void queued_item_should_have_required_properties(string state) var item = Subject.GetItems().Single(); VerifyQueued(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [Test] @@ -248,7 +249,7 @@ public void stalledDL_item_should_have_required_properties() var item = Subject.GetItems().Single(); VerifyWarning(item); - item.RemainingTime.Should().NotBe(TimeSpan.Zero); + item.RemainingTime.Should().NotHaveValue(); } [Test] @@ -276,6 +277,19 @@ public void Download_should_get_hash_from_magnet_url(string magnetUrl, string ex 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 remoteEpisode = CreateRemoteEpisode(); + remoteEpisode.Release.DownloadUrl = "magnet:?xt=urn:btih:ZPBPA2P6ROZPKRHK44D5OW6NHXU5Z6KR&tr=udp"; + + Assert.Throws(() => Subject.Download(remoteEpisode)); + } + [Test] public void Download_should_set_top_priority() { @@ -450,6 +464,56 @@ public void should_be_removable_and_should_allow_move_files_if_max_ratio_reached 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() { diff --git a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs index af43aac9a..05ae7bdad 100644 --- a/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs +++ b/src/NzbDrone.Core/Download/Clients/QBittorrent/QBittorrent.cs @@ -57,7 +57,7 @@ protected override string AddFromMagnetLink(RemoteEpisode remoteEpisode, string SetInitialState(hash.ToLower()); - if (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue) + if (remoteEpisode.SeedConfiguration != null && (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue)) { Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteEpisode.SeedConfiguration, Settings); } @@ -98,7 +98,7 @@ protected override string AddFromTorrentFile(RemoteEpisode remoteEpisode, string SetInitialState(hash.ToLower()); - if (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue) + if (remoteEpisode.SeedConfiguration != null && (remoteEpisode.SeedConfiguration.Ratio.HasValue || remoteEpisode.SeedConfiguration.SeedTime.HasValue)) { Proxy.SetTorrentSeedingConfiguration(hash.ToLower(), remoteEpisode.SeedConfiguration, Settings); } From e52fcf843c092441f6d79128d744a2183d7871fa Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sun, 3 Mar 2019 23:10:02 +0100 Subject: [PATCH 11/11] Handle Deluge v2 beta breaking change in their api. closes #2412 --- .../Download/Clients/Deluge/DelugeProxy.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs index 21a34e2be..80912667f 100644 --- a/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs +++ b/src/NzbDrone.Core/Download/Clients/Deluge/DelugeProxy.cs @@ -48,9 +48,25 @@ public DelugeProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger lo 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)